2026년 8월 29일 · 5분 읽기
default-src 'none'이 삼킨 미리보기 두 개
공유 지면에서 PDF와 오디오 미리보기가 죽어 있었다. 원인은 복호나 저장이 아니라 정책 한 줄에 지시자 두 개가 없던 것이다.
"pdf, md, doc 미리보기가 안 된다"는 보고를 받고 복호 경로부터 열었다. 바이트는 잘 도착했고 앱 쪽도 정상이었다. 막고 있던 것은 응답 헤더 한 줄이었다. 사진은 보이고 PDF와 소리만 죽어 있던 이유가 그 줄에 적혀 있었다.
미리보기는 세 태그로 그린다
공유 지면은 서버가 아니라 브라우저에서 바이트를 복호한다. 복호한 결과는 blob: URL로 세우고, 종류에 따라 서로 다른 태그가 그것을 소비한다.
| 첨부 | 태그 | 필요한 지시자 |
|---|---|---|
| 사진 | <img> |
img-src |
<iframe> |
frame-src |
|
| 소리 | <audio> |
media-src |
같은 blob: 하나를 쓰는데 허용은 태그별로 따로 필요하다. 이 사실을 놓치면 "사진은 되는데 PDF만 안 된다"가 복호 버그로 읽힌다.
권한 표로 보면 구멍이 보인다
고치기 전 정책은 img-src에만 blob:을 열어 두었다.
두 칸이 비어 있었고, 비어 있다는 것은 "제한 없음"이 아니라 "default-src를 따른다"는 뜻이다. 그 값이 'none'이었으므로 결과는 완전 차단이다.
규격은 폴백을 이렇게 정의한다
구현 버그가 아니라 명세된 동작이다.
The default-src directive serves as a fallback for the other fetch directives. If a default-src directive is present in a policy, its value will be used as the policy's default source list.
default-src 'none'으로 시작하는 정책은 "적지 않은 것은 전부 막는다"는 선언이다. 그래서 표면을 하나 추가할 때마다 지시자도 하나 늘어난다. 정책을 짧게 유지하려고 지시자를 생략하면 그 표면이 조용히 죽는다.
고친 정책과 계약
지시자 두 개를 열고, 원격 출처는 하나도 열지 않았다.
// src/server.ts:81
"Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data: blob:; frame-src blob:; media-src blob:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; upgrade-insecure-requests",blob:은 이 지면이 스스로 복호한 같은 출처 메모리다. 외부에서 무엇을 불러오는 권한이 아니다. 그래서 계약 테스트는 세 표면이 열렸는지와 함께 원격 출처가 하나도 없는지를 같이 요구한다.
// src/server.test.ts:613-620
test("CSP는 미리보기가 쓰는 세 표면 모두에 blob:을 연다", async () => {
const policy = (await handle(new Request(`https://share.test/p/${token}`))).headers.get("Content-Security-Policy") ?? "";
expect(policy).toContain("img-src 'self' data: blob:");
expect(policy).toContain("frame-src blob:");
expect(policy).toContain("media-src blob:");
// 원격 출처는 하나도 열지 않는다. blob:은 이 지면이 스스로 복호한 같은 출처 메모리다.
expect(policy).not.toMatch(/https?:\/\//);
expect(policy).toContain("default-src 'none'");
});두 지시자를 빼면 이 계약이 바로 걸린다.
$ bun test src/server.test.ts # frame-src·media-src를 뺀 판
616 | expect(policy).toContain("frame-src blob:");
^
error: expect(received).toContain(expected)
(fail) 첨부 이미지 지면 > CSP는 미리보기가 쓰는 세 표면 모두에 blob:을 연다 [0.20ms]
43 pass
1 fail
Ran 46 tests across 1 file. [27.00ms]
되돌리면 43 → 44 pass, 1 → 0 fail이다. 실패까지 0.20 ms가 걸렸고 스위트 전체는 27.00 ms다. 같은 스위트, 같은 명령, 정책 한 줄의 차이다.
스크립트 응답은 더 좁은 정책을 쓴다
같은 서버가 문서 지면과 클라이언트 스크립트에 서로 다른 정책을 보낸다. 스크립트 응답에는 미리보기가 없으므로 blob:도 필요 없다.
// src/server.ts:254
if (url.pathname === "/client.js") return new Response(CLIENT_JS, { headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "default-src 'none'; script-src 'self'" } });정책을 응답마다 좁히면 표면이 늘어나도 넓은 정책 하나가 모든 응답을 덮는 일이 없다.
운영 지면이 실제로 보내는 것
정책은 코드에 있는 것과 배포된 것이 다를 수 있다. 그래서 운영 주소에서 직접 읽었다.
$ curl -sSI https://share.justsend.cloud/p/does-not-exist
HTTP/2 404
content-security-policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data: blob:; frame-src blob:; media-src blob:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; upgrade-insecure-requests
없는 문서를 요청해도 헤더는 같다. 정책이 문서 존재 여부가 아니라 응답 경로에 붙어 있다는 뜻이고, 배포본이 코드와 같은 문자열을 보낸다는 뜻이다.
그릴 수 없는 포맷과 남은 대가
모든 포맷을 그릴 수 있는 것은 아니다. .docx처럼 브라우저가 렌더할 수 없는 첨부는 미리보기 대신 다운로드만 남긴다. 이때 중요한 것은 미리보기가 빠진 자리가 죽은 줄이 되지 않는 것이다. 다운로드 링크는 살아 있어야 하고, "받을 수 없음" 문구는 뜨지 않아야 한다. 바이트가 아예 없는 첨부만 그렇게 말한다.
그릴 수 없는 것과 받을 수 없는 것은 다르다
이 구분은 테스트 두 개로 고정돼 있다. 하나는 미리보기가 없어도 다운로드가 서는 경우(src/server.test.ts:626), 다른 하나는 바이트가 아예 없어 둘 다 못 하는 경우(src/server.test.ts:648)다. 앞의 경우에 "받을 수 없음"을 띄우면 사용자는 파일이 사라졌다고 읽는다. 실제로는 그리지 못할 뿐이고 받을 수는 있다.
정책이 길어지는 것은 대가다. 표면이 늘어날 때마다 지시자가 늘고, 그 목록은 사람이 읽기 어려워진다. 그래서 테스트가 세 표면을 이름으로 요구한다 — 목록이 길어지는 것은 받아들이고, 그 안에서 무엇이 빠졌는지는 사람이 아니라 계약이 본다.
이 사건이 남긴 규칙은 하나다. 정책을 default-src 'none'으로 시작했다면, 새 미리보기 표면을 붙이는 작업은 코드 변경이 아니라 정책 변경까지 포함하는 작업이다. 태그를 하나 더 쓰기로 정한 순간 지시자도 하나 더 필요하고, 그 사실을 기억에 맡기면 다음 표면도 조용히 죽는다.
그래서 표면을 늘리는 커밋에는 계약 테스트가 함께 온다. 테스트가 요구하는 것은 정책의 아름다움이 아니라 목록에 그 표면이 있느냐다. 사람이 정책 한 줄을 눈으로 읽어 빠진 지시자를 찾는 일은 세 번째 표면부터 실패하기 시작한다.
정책 한 줄을 고치는 일은 작아 보인다. 그런데 이 사건에서 실제로 걸린 시간은 헤더를 고치는 5분이 아니라, 복호와 저장과 앱 쪽을 차례로 열어 본 앞의 시간이었다. 브라우저가 자원을 막을 때 화면에는 "미리보기를 만들 수 없습니다"만 남고, 그 문장은 원인을 하나도 말해 주지 않는다.
그래서 이 지면의 진단 순서를 바꿨다. 미리보기가 죽으면 먼저 응답 헤더를 읽고, 그다음에 바이트를 의심한다. 헤더는 한 번의 요청으로 확인되고, 복호 경로는 그렇지 않다.