본문으로 건너뛰기
언어

@plott/webkit-sanitize0.4.0

사용자·편집기가 만든 HTML 을 화면에 그려도 되게 거른다(허용 목록, iframe 호스트 허용 목록, 링크 rel 강제) — 서버·브라우저 어디서나. 그리고 파일을 내려줄 때의 안전한 응답 헤더, XML(RSS)·메일 헤더에 값을 끼울 때의 escapeXml·headerSafe

설치

pnpm
pnpm add @plott/webkit-sanitize@0.4.0

진입점(exports)

import타입파일
@plott/webkit-sanitize./dist/index.d.ts./dist/index.js
@plott/webkit-sanitize/text./dist/text.d.ts./dist/text.js
@plott/webkit-sanitize/files./dist/files.d.ts./dist/files.js
@plott/webkit-sanitize/url./dist/url.d.ts./dist/url.js

사용자·편집기(리치 텍스트 에디터)가 만든 HTML 을 화면에 그려도 되게 거르고, XML·메일 헤더에 사용자 입력을 안전하게 끼우고, 사용자가 올린 파일을 내려줄 때의 안전한 응답 헤더를 만드는 패키지입니다. 읽는 사람은 상세 페이지에 편집기 HTML 을 그리거나, RSS·메일을 만들거나, 첨부 파일을 서빙하는 라우트(BFF)를 만드는 프론트엔드 개발자입니다.

HTML 거르기는 sanitize-html(순수 JS 파서)을 씁니다. jsdom 을 쓰지 않아 서버리스·Turbopack 번들에서도 동작합니다. /text 와 /files 는 런타임 의존성이 없습니다.

진입점

import어디서주요 export
@plott/webkit-sanitize서버·브라우저 어디서나sanitizeHtml, escapeHtml, htmlToText, SanitizeOptions, SanitizeConfigError, /text 의 모든 export
@plott/webkit-sanitize/text서버·브라우저(파서를 가져오지 않음)escapeXml, xmlText, xmlAttr, headerSafe, assertHeaderSafe, UnsafeHeaderValueError
@plott/webkit-sanitize/files서버·브라우저(파서를 가져오지 않음)sniffFileType, contentDisposition, fileResponseHeaders, secureErrorHeaders, SNIFF_BYTES, MAX_FILE_NAME_BYTES
@plott/webkit-sanitize/url서버·브라우저(파서를 가져오지 않음)normalizeUrl, classifyUrl, normalizeHosts, relTokens, DENIED_SCHEMES, UrlInfo (@plott/webkit-markdown 이 같은 규칙을 씁니다)

붙이기

HTML 거르기

ts
import { escapeHtml, htmlToText, sanitizeHtml } from "@plott/webkit-sanitize";
declare const post: { bodyHtml: string };declare const guest: { name: string };
const safe = sanitizeHtml(post.bodyHtml, {  iframeHosts: ["www.youtube.com", "player.vimeo.com"],  linkTarget: "blank",});export const innerHtml = { dangerouslySetInnerHTML: { __html: safe } };
const description = htmlToText(post.bodyHtml).slice(0, 120);
const html = `<b>${escapeHtml(guest.name)}</b> 님`;

sanitizeHtml 의 결과는 HTML 이고 htmlToText 의 결과는 평문입니다. 평문을 HTML 로 다시 끼울 때는 escapeHtml 을 거칩니다. 입력 HTML 이 깨져 있어도 던지지 않습니다. 던지는 경우는 설정이 안전하지 않을 때(SanitizeConfigError)뿐입니다.

두 번째 인자는 옵션 객체입니다. 사용자가 쓴 글(댓글·게시글)에는 sanitizeHtml(html, { linkRel: "nofollow ugc" }) 처럼 linkRel 을 줍니다.

옵션기본값뜻
iframeHosts없음iframe 을 허용할 호스트. 정확히 일치해야 하고 https 만 됩니다. 포트는 기본 포트(443)만 통하고, 다른 포트는 항목에 host:8443 처럼 적어야 합니다. 비어 있으면 iframe 은 모두 버립니다
imagestruefalse 면 img 를 모두 버립니다
imageHosts없음주면 이 호스트(포트 규칙은 iframeHosts 와 같음)의 절대 주소 이미지만 남깁니다
allowRelativeImagestrueimageHosts 를 줄 때 상대 주소 이미지(같은 사이트)를 남길지
linkTarget없음"blank" 면 바깥(http·https 절대 주소) 링크에 target="_blank" 를 겁니다
linkRel없음주면 href 가 있는 모든 링크의 rel 을 이 값(공백으로 구분)으로 정합니다. 안 주면 target="_blank" 링크에만 noopener noreferrer
extraTags없음기본 목록에 더하는 태그. 위험 태그를 넣으면 SanitizeConfigError 로 던집니다
extraAttributes없음더하는 속성({ 태그 또는 "*": ["속성"] }). 금지 속성은 적어도 걸러집니다

기본 허용 태그는 문단·제목·목록·인용·코드·hr·br, 글자 꾸밈(strong·em·u·s·mark·sub·sup), a, img, 표(thead·tbody·tfoot·tr·th·td·colgroup·col·caption), figure·figcaption·span·div 입니다.

메일 본문에 사용자 입력

메일 HTML 을 문자열로 만들 때 사용자 값(이름·메모)은 escapeHtml 로 감쌉니다(& < > " ' \`` 를 바꿉니다). 감싸지 않으면 받는 사람의 메일 화면에 입력한 태그·링크가 그대로 그려집니다. 제목 같은 헤더 값은 HTML 이스케이프가 아니라 headerSafe입니다. 입력의 줄바꿈이 살아 있으면Bcc:` 같은 헤더가 하나 더 생깁니다.

ts
import { escapeHtml, headerSafe } from "@plott/webkit-sanitize";
declare const guest: { name: string; memo: string };
const subject = headerSafe(`[예약 확인] ${guest.name}`, { maxLength: 120 });const html = `<p><b>${escapeHtml(guest.name)}</b> 님, 메모: ${escapeHtml(guest.memo)}</p>`;

수신자 주소처럼 고쳐서 쓰면 안 되고 거절해야 하는 값은 assertHeaderSafe(address, "to") 로 던지게 합니다.

i18n 의 escapeValue: false 와 dangerouslySetInnerHTML 을 함께 쓰면 번역 문구에 끼운 서버 값이 그대로 HTML 이 됩니다. 값은 escapeHtml 로 감싸고, 태그가 필요한 문구는 끼운 뒤 sanitizeHtml 로 한 번 더 거릅니다.

XML(RSS·sitemap) 에 값 끼우기

ts
import { escapeXml, xmlAttr } from "@plott/webkit-sanitize/text";
declare const post: { title: string };declare const url: string;
export const item = `<item><title>${escapeXml(post.title)}</title><link href="${xmlAttr(url)}"/></item>`;
함수뜻
escapeXml(text)& < > " ' 를 엔티티로 바꾸고 XML 1.0 이 글자로 허용하지 않는 것(탭·LF·CR 을 뺀 제어 문자, U+FFFE·FFFF, 짝 없는 서로게이트)을 지웁니다
xmlText(text)요소 본문용. & < > 만 바꾸고 무효 글자를 지웁니다
xmlAttr(text)속성값용. escapeXml 에 더해 탭·줄바꿈을 문자 참조(&#x9; 등)로 바꿉니다
headerSafe(value, { maxLength? })한 줄이어야 하는 값에서 제어 문자를 지웁니다. 탭은 공백 하나가 됩니다. maxLength 는 사용자가 보는 글자(grapheme) 단위입니다. 기본은 제한 없음
assertHeaderSafe(value, label?)값이 이미 헤더에 안전한지 확인하고 아니면 UnsafeHeaderValueError. label 기본값은 header value

문자열이 아닌 값을 넘기면 TypeError, maxLength 가 음이 아닌 정수가 아니면 RangeError 를 던집니다. 메시지는 @plott/webkit-sanitize: 로 시작합니다.

파일 내려주기

파일명이 .pdf 여도 내용은 HTML 일 수 있고, 저장된 content-type 은 올린 사람이 정한 값입니다. 이 값을 믿고 inline 으로 내보내면 첨부 하나가 회사 도메인에서 도는 스크립트가 됩니다(저장형 XSS).

ts
import { fileResponseHeaders, secureErrorHeaders, SNIFF_BYTES } from "@plott/webkit-sanitize/files";
declare const open: (req: Request) => Promise<{ name: string; peek(size: number): Promise<Uint8Array>; stream: ReadableStream } | null>;
export async function GET(req: Request) {  const file = await open(req);  if (!file) return new Response("Not Found", { status: 404, headers: secureErrorHeaders() });
  const head = await file.peek(SNIFF_BYTES);  const headers = fileResponseHeaders({ fileName: file.name, head, inline: true });  return new Response(file.stream, { headers });}
함수뜻
sniffFileType(bytes)앞 12바이트로 png·jpeg·gif·webp·pdf·zip 을 가립니다. 모르면 null
contentDisposition(kind, fileName, { maxNameBytes? })inline/attachment 와 파일명을 filename="<ASCII 대체>"; filename*=UTF-8''<인코딩> 으로 만듭니다. 이름은 maxNameBytes(기본 MAX_FILE_NAME_BYTES = 200 바이트)로 잘립니다. 양의 정수가 아니면 RangeError
fileResponseHeaders({ fileName, head?, inline?, renderable?, csp?, maxNameBytes? })응답 Headers(Content-Type·Content-Disposition·X-Content-Type-Options·Content-Security-Policy)

fileResponseHeaders 의 선택 옵션입니다.

옵션기본값뜻
renderable["png","jpg","jpeg","gif","webp","pdf"]inline 으로 그릴 수 있는 확장자. 이 목록 안에서 줄이기만 하고 늘릴 수는 없습니다(그 밖의 이름은 RangeError). 확장자가 맞아도 head 의 바이트가 맞지 않으면 그리지 않는 규칙은 그대로입니다
cspdefault-src 'none'; style-src 'unsafe-inline'; sandbox인라인 PDF 가 아닌 응답에 싣는 Content-Security-Policy
maxNameBytes200파일명 바이트 상한

| secureErrorHeaders(init?) | 오류 응답(401·403·404 …)에 싣는 최소 헤더. 넘긴 헤더는 보존하되 nosniff·no-store 는 덮어쓸 수 없습니다 |

sniffFileType 은 브라우저 업로드 코드에서도 쓸 수 있습니다. 서버 판정을 대신하지는 않습니다.

기본으로 지키는 것

HTML

  • script·style·svg·math·object·embed·폼 컨트롤은 항상 버립니다. svg 안의 script 는 글자까지 남기지 않습니다.
  • extraTags 로 위험 태그를 열려 하면 던집니다(SanitizeConfigError). script·style·svg·math·template·noscript·iframe(iframeHosts 로만 엽니다)와, 안의 글이 원문 그대로 파싱돼 변이 XSS 가 되는 xmp·noembed·noframes·plaintext·title·textarea, 폼·object·embed 등이 해당합니다. 조용히 무시하지 않으니 설정 실수를 바로 알 수 있습니다.
  • 이벤트 핸들러(on*)와 style·srcdoc·formaction·action·xlink:href·ping·background·data 는 extraAttributes 에 적어도 걸러집니다. 편집기 출력의 onerror 로 스크립트가 실행되거나 srcdoc 으로 같은 출처 문서가 주입되는 것을 막습니다.
  • href·src·cite·poster·srcset 은 http·https·mailto·tel 과 상대 주소만 받습니다. javascript:·vbscript:·data: 는 엔티티·공백·탭으로 숨겨도 버립니다. data: 이미지도 버리므로 붙여넣은 이미지는 업로드해 주소로 바꿔서 저장해야 합니다.
  • 호스트 비교는 브라우저가 읽는 방식으로 합니다. imageHosts·iframeHosts 를 판단하기 전에 주소 안의 탭·개행과 앞뒤 제어 문자·공백을 지우고 URL 로 풀어 나온 호스트(와 포트)를 비교합니다. ht&#9;tps://evil.com/x.png 는 바깥 호스트로 보고 버립니다. 풀리지 않는 주소도 버립니다. 풀리지 않는 호스트 항목은 문자열 그대로 비교합니다.
  • 프로토콜 상대 주소(//evil.com)는 버립니다. 현재 페이지의 스킴을 물려받는 외부 주소이기 때문입니다.
  • style 은 text-align(left·right·center·justify)과 color·background-color(hex 또는 rgb() 값)만 남깁니다. url(...) 같은 함수 값은 받지 않아 background:url(javascript:…) 가 통하지 않습니다.
  • class 는 code·pre 의 language-* 만 남깁니다. 사이트의 CSS 클래스를 끌어와 화면을 덮는 일을 막습니다. id·name 도 받지 않습니다(document.<name> 으로 전역을 덮는 DOM clobbering).
  • 입력 HTML 의 rel 은 항상 버리고, target="_blank" 인 링크는 rel="noopener noreferrer" 로 덮어씁니다. 새 탭이 window.opener 로 원래 페이지를 조작하는 것(reverse tabnabbing)을 막습니다. _blank 가 아닌 target 은 지웁니다. linkRel 의 토큰은 영문 소문자와 - 로 된 것만 남깁니다. 검색엔진 신호가 필요한 글은 linkRel: "nofollow ugc" 를 씁니다.
  • iframe 은 iframeHosts 에 정확히 있는 https 호스트만 남깁니다. youtube.com 을 적어도 www.youtube.com·youtube.com.evil.com 은 통과하지 않습니다(하위 도메인은 따로 적습니다). 남기는 iframe 에는 sandbox·referrerpolicy·loading="lazy" 를 덮어쓰고 allow 는 지웁니다.
  • iframeHosts 에 사이트 자신의 출처나 같은 사이트의 형제 호스트를 넣지 마세요. 임베드 플레이어가 돌도록 sandbox 에 allow-scripts allow-same-origin 을 주므로, 같은 출처 문서를 임베드하면 그 안의 스크립트가 sandbox 를 스스로 풀 수 있습니다. 외부 서비스의 호스트만 적습니다.
  • 표의 colspan·rowspan·colwidth·data-colwidth·span, ol 의 start, img 의 width·height 는 숫자일 때만 남깁니다. 편집기(tiptap)의 병합 셀·열 너비·시작 번호는 유지되고, 숫자가 아닌 값이 CSS 주입 통로가 되지 못합니다.
  • htmlToText 는 엔티티를 한 번만 풉니다. 풀린 결과를 다시 풀면 &amp;lt; 가 < 로 두 번 풀려 태그가 생깁니다.

XML·헤더

  • XML 무효 글자를 지웁니다. 제어 문자가 하나만 섞여도 XML 파서는 피드 전체를 거부합니다.
  • headerSafe 는 CR·LF·NUL 을 포함한 제어 문자를 지웁니다. NEL(U+0085)·LS(U+2028)·PS(U+2029)는 일부 메일·HTTP 스택이 줄바꿈으로 읽으므로 같이 지우고, 짝 없는 서로게이트도 지웁니다. maxLength 는 ZWJ 이모지 가족이나 결합 문자를 반으로 자르지 않습니다. assertHeaderSafe 는 탭을 포함해 같은 기준으로 거절합니다. 비 ASCII 제목의 RFC 2047 인코딩은 하지 않습니다. 메일 라이브러리가 합니다.

파일

  • inline 은 세 조건이 모두 맞을 때만 나갑니다. ① inline: true 를 요청했고, ② 확장자가 그릴 수 있는 것(png·jpg·jpeg·gif·webp·pdf)이고, ③ head 의 형식이 그 확장자와 같을 때입니다. 하나라도 어긋나면 attachment + application/octet-stream 입니다. 저장된 content-type 은 받지도 쓰지도 않습니다.
  • .svg·.html 은 앞머리가 무엇이든 inline 으로 나가지 않습니다. Object.prototype 의 이름(constructor 등)이 확장자여도 마찬가지입니다.
  • 모든 응답에 X-Content-Type-Options: nosniff 를 싣습니다. 오류 응답도 secureErrorHeaders() 로 같습니다.
  • 이미지와 내려받기 응답에는 Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox 를 싣습니다. 문서로 열려도 스크립트·하위 자원이 없게 하는 두 번째 벽입니다.
  • inline PDF 에는 위 CSP 를 걸지 않습니다. CSP sandbox 가 걸린 문서에서 Chrome 내장 PDF 뷰어가 뜨지 않는다는 보고가 있습니다(미검증). inline PDF 는 nosniff 와 앞머리 대조로 지킵니다.
  • 파일명은 헤더에 안전하게 싣습니다. CR/LF·제어 문자·따옴표·역슬래시·경로 구분자와 앞의 점을 뺍니다(응답 분할 방지). 한글 이름은 filename*(RFC 5987/6266)로 인코딩하고, filename= 에는 ASCII 대체 이름을 둡니다.
  • 방향 제어 문자와 C1 제어 문자도 뺍니다. U+202A–202E·2066–2069·200E·200F·061C·0080–009F 입니다. 안 빼면 txt.exe 가 받는 쪽에서 exe.txt 로 보입니다.
  • 이름은 200 UTF-8 바이트까지입니다. 넘으면 확장자(마지막 점부터, 32바이트 이하일 때)를 지키고 앞부분을 글자 단위로 자릅니다. 한글은 퍼센트 인코딩으로 3배가 되어도 헤더가 8 KB 한도 아래에 머뭅니다.

그 밖

  • sanitizeHtml 은 sanitize-html 에 의존하고, /text·/files 는 의존성이 없습니다.
  • 모든 진입점은 window 없이 import 할 수 있고, import 시점에 부작용이 없습니다.
  • 던지는 오류의 메시지는 @plott/webkit-sanitize: 로 시작합니다.