운영 어드민 화면이 외부 CDN 에서 스크립트와 CSS 를 불러오고 있었다. 차트, 슬라이더, 날짜 라이브러리, jQuery 같은 것들이다. 일부 화면은 앱 안의 WebView 로도 열린다.

전부 integrity 없이 불러오고 있었다. CDN 의 파일이 바뀌거나 도메인 주인이 바뀌면, 그 스크립트는 우리 화면의 권한으로 실행된다. 관리자 세션이 살아 있는 화면에서.


SRI 가 하는 일

<script src="https://cdnjs.cloudflare.com/ajax/libs/<lib>/<version>/<file>.min.js"
        integrity="sha384-..." crossorigin="anonymous"></script>
browser  ---- GET file.min.js ---->  CDN
browser  <--- bytes ---------------  CDN
sha384(bytes) == integrity ?   yes -> execute
                               no  -> block + console error

받은 바이트의 해시가 적어 둔 값과 다르면 실행하지 않는다. 한 바이트만 달라도. crossorigin="anonymous" 도 같이 필요하다. 다른 출처 리소스는 CORS 로 받아야 브라우저가 내용을 검사할 수 있다. 빠지면 해시가 맞아도 막힌다.


대상 고르기

JSP 전체에서 외부 <script> · <link> 를 찾았다. 여러 줄에 걸친 태그도 있어서 한 줄 grep 으로는 빠진다. 태그 96개, 고유 URL 12종이 나왔다. SRI 의 전제는 그 URL 이 가리키는 바이트가 앞으로도 같다는 것이다. 그래서 먼저 걸렀다.

종류 처리
버전이 박힌 URL (.../lib/1.2.3/lib.min.js) SRI 적용
버전 없는 URL (외부 위젯, latest) 제외 — 언제든 바뀐다
Google Fonts CSS 제외 — 브라우저마다 응답이 다르다
IE 조건부 주석 안의 스크립트 삭제 (아래)

최종 적용은 고유 URL 7종, 태그 72개, 파일 35개였다.


해시 계산 — 그리고 교차 확인

파일을 직접 받아 계산했다. openssl 과 Python hashlib 두 가지로 계산해 같은지도 봤다.

curl -sL "https://cdnjs.cloudflare.com/ajax/libs/<lib>/<version>/<file>" -o file.js

openssl dgst -sha384 -binary file.js | openssl base64 -A
# or
shasum -b -a 384 file.js | awk '{print $1}' | xxd -r -p | base64

그런데 이것만으로는 부족하다. 지금 받은 파일이 이미 바뀐 상태라면, 바뀐 파일의 해시를 정답으로 박제하게 된다. 받은 바이트가 “원래 그 파일”인지는 독립된 출처와 대조해야 안다.

cdnjs 는 API 로 파일별 SRI(sha512)를 준다. 받은 바이트의 sha512 가 같으면, 같은 바이트로 계산한 sha384 도 믿을 수 있다.

curl -s "https://api.cdnjs.com/libraries/<lib>/<version>?fields=sri" | jq -r '.sri["<file>"]'
echo "sha512-$(openssl dgst -sha512 -binary file.js | openssl base64 -A)"

jsDelivr 처럼 npm 패키지를 그대로 내주는 CDN 은 레지스트리에 기록된 tarball 무결성과 대조한다. tarball 이 레지스트리 기록과 같고, 그 안의 파일이 CDN 파일과 바이트까지 같으면 된다.

npm view <pkg>@<version> dist.integrity                       # sha512-...
npm pack <pkg>@<version>
echo "sha512-$(openssl dgst -sha512 -binary <pkg>-<version>.tgz | openssl base64 -A)"
tar -xzf <pkg>-<version>.tgz
cmp package/<path> file.js && echo same

틀린 해시로 정말 막히는지

“넣었으니 됐다”가 아니라 막히는 것까지 봐야 한다. 한 태그의 integrity 마지막 글자를 바꿔 대조군을 만들었다.

Failed to find a valid digest in the 'integrity' attribute for resource
'https://.../file.min.js' with computed SHA-384 integrity '...'.
The resource has been blocked.

정상 해시 7종은 모두 로드되고, 틀린 해시는 차단됐다. 둘 다 봐야 검증이다.


함정: jsDelivr 가 만들어 주는 .min.js

날짜 라이브러리의 한국어 로케일을 이렇게 불러오고 있었다.

<script src="https://cdn.jsdelivr.net/npm/dayjs@<version>/locale/ko.min.js"></script>

교차 확인하려고 npm 패키지를 열었더니 ko.min.js 라는 파일이 없었다. 패키지에는 ko.js 만 있다. jsDelivr 는 패키지에 없는 .min.js 를 요청받으면 그 자리에서 minify 해서 돌려준다. 받은 파일 맨 위에는 jsDelivr 가 붙인 주석이 있었고, 그 아래는 원본과 같았다.

이런 파일은 jsDelivr 가 다시 만들면(minifier 가 바뀌거나 주석 문구가 바뀌면) 바이트가 달라진다. 그날부터 SRI 가 로케일 파일을 막고, 화면의 날짜가 조용히 영어로 나오게 된다. 에러 화면도 없이.

<script src="https://cdn.jsdelivr.net/npm/dayjs@<version>/locale/ko.js"
        integrity="sha384-..." crossorigin="anonymous"></script>

패키지 원본 ko.js 로 바꾸고 SRI 를 걸었다. npm tarball 안의 파일과 바이트가 같은 것까지 확인했다. 규칙으로 정리하면, jsDelivr 에서 SRI 를 걸 파일은 패키지에 실제로 있는 파일이어야 한다.


SRI 를 못 거는 것

  • 버전 없는 스크립트 — 외부 위젯처럼 제공자가 수시로 갱신하는 URL. 내부에서 다른 스크립트를 또 불러오기도 한다. 해시를 걸면 제공자가 갱신하는 날 화면이 깨진다
  • Google Fonts CSS — 같은 URL 이라도 브라우저(User-Agent)마다 다른 CSS 를 준다. 정답 해시가 하나가 아니다

이런 리소스는 SRI 로 다룰 수 없다. 직접 호스팅하거나 CSP 로 불러올 수 있는 출처를 좁히는 식의 다른 수단이 필요하다. 억지로 해시를 걸면 보안이 아니라 장애가 되니, SRI 작업에서는 따로 뺐다.


정리 1: IE 조건부 주석 블록

관리자 템플릿에서 복사해 온 이런 블록이 여러 화면에 있었다.

<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
<![endif]-->

여기에도 해시를 걸려고 파일을 받았더니, 라이브러리가 아니라 Hello World!! 31바이트가 왔다. 그 도메인은 더 이상 이 파일을 내주지 않는다.

  • 최신 브라우저는 조건부 주석을 무시하니 애초에 안 받는다
  • 이걸 받는 IE 는 SRI 를 지원하지 않는다
  • 원본 해시를 확인할 방법도 없다

걸 수도, 걸 의미도 없었다. 블록째 지웠다. 7개 파일, 42줄 삭제, 추가 0줄. 죽은 CDN 도메인은 그 자체로 위험이다. 도메인이 다른 사람 손에 넘어가면 그 <script> 는 새 주인의 코드를 실행한다.


정리 2: 웹 루트에 공개되던 에디터 샘플

외부 리소스를 찾다가 http 로 jQuery 를 불러오는 HTML 이 하나 걸렸다. 우리 화면이 아니라 리치 텍스트 에디터 배포본에 딸려 온 samples/ 폴더였다. 에디터를 통째로 웹 루트에 복사하면서 데모 페이지까지 같이 공개되고 있었다.

  • 참조 0건 확인 (README · LICENSE 문구 말고는 없었다)
  • samples/ 폴더만 삭제(88파일). 에디터 본체는 공지사항 작성에서 쓰니 그대로 뒀다
  • 삭제 후 브라우저에서 에디터 초기화 완료, 에디터 관련 요청 15건 모두 200, samples 요청 0건 확인

결과

  • 외부 리소스 7종(태그 72개, 파일 35개)에 sha384 SRI
  • 해시는 직접 계산하고 레지스트리(cdnjs API, npm tarball)와 교차 확인. 브라우저에서 7종 정상 로드, 틀린 해시 대조군 차단 확인
  • diff 는 72줄 추가 / 72줄 삭제. 줄바꿈(CRLF)을 그대로 둬서 속성 추가 말고는 바뀐 게 없다
  • 아주 오래된 Android WebView 는 integrity 를 무시한다. 검사를 못 할 뿐 로드는 되니 화면이 깨지지는 않는다

배운 것

  • SRI 는 버전 고정 URL 위에서만 성립한다. 바뀔 수 있는 URL 에 해시를 걸면 보안이 아니라 장애가 된다.
  • 해시는 “지금 받은 파일”이 아니라 “원래 파일”의 것이어야 한다. 레지스트리 같은 독립된 출처와 대조하자.
  • CDN 이 만들어 주는 파일은 원본이 아니다. jsDelivr 자동 minify 파일에는 SRI 를 걸지 말고 패키지 원본을 쓰자.
  • 라이브러리 버전을 올릴 땐 integrity 도 같이 바꾸자. 틀리면 그 스크립트에 기대는 화면 기능이 통째로 안 뜬다.
  • 외부 리소스를 전수 조사하면 죽은 코드도 같이 나온다. IE 블록과 샘플 폴더는 해시를 걸 대상이 아니라 지울 대상이었다.