Files
AriWeb-Suite/modules/arimail/server/sanitize.mjs
T

117 lines
10 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 수신 HTML 정제. www 사이트의 build/sanitize_mail.py 와 같은 허용 목록을 JS 로 옮겼습니다.
* 스크립트·스타일·이벤트 속성·위험한 URL 스킴은 남기지 않으며, 미리보기는 그 위에 sandbox iframe + CSP 를 더 씁니다.
*/
const ALLOWED = new Set(['p','br','strong','b','em','i','u','ul','ol','li','blockquote','div','span','h1','h2','h3','h4','pre','code','s','strike','font','a','img','hr','table','tbody','thead','tr','td','th']);
const VOID = new Set(['br','img','hr']);
const BLOCKED = new Set(['script','style','iframe','object','embed','form','svg','math','template','noscript','head','title']);
// embed 는 HTML5 빈 요소(닫는 태그 없음)라 열린 범위를 만들면 뒤의 본문이 모두 사라집니다. 원문 텍스트 요소(script/style/title/textarea 류)는
// 자기 닫는 태그까지 바로 건너뛰고, 닫는 태그가 없으면 범위를 열지 않습니다(예전에는 닫히지 않은 <title>·<form> 하나로 본문 전체가 비었습니다, 0.8.25).
const VOID_BLOCKED = new Set(['embed']);
const RAW_TEXT_BLOCKED = new Set(['script', 'style', 'title', 'noscript', 'template']);
// 껍데기만 벗기는 요소(0.9.0): form·head·object·iframe 은 태그만 버리고 안의 내용은 계속 정제합니다. 예전에는 이 태그가 범위(depth)를 열어
// <form> 으로 감싼 뉴스레터 본문이 통째로 사라지고, 닫는 태그가 없는 <form>/<iframe>/<head>(브라우저는 <body> 에서 자동으로 닫음) 뒤의 본문이 모두 비었습니다.
// 범위를 여는 것은 svg·math(안이 HTML 이 아님)뿐이며, 그마저도 <body> 시작 태그를 만나면 닫습니다.
const UNWRAP_BLOCKED = new Set(['form', 'head', 'object', 'iframe']);
// svg/math 안에서 만나면 브라우저가 외래 내용(foreign content)을 끝내는 HTML 시작 태그(HTML 명세 13.2.6.5). 닫히지 않은 <svg> 뒤의 본문이 사라지지 않게 같은 규칙으로 범위를 닫습니다(0.9.3).
const BREAKOUT = new Set(['b','big','blockquote','body','br','center','code','dd','div','dl','dt','em','embed','h1','h2','h3','h4','h5','h6','head','hr','i','img','li','listing','menu','meta','nobr','ol','p','pre','ruby','s','small','span','strong','strike','sub','sup','table','tt','u','ul','var']);
// text-decoration-line/-style/-color: Chrome 은 편집기의 밑줄·취소선을 text-decoration 이 아니라 text-decoration-line 으로 씁니다.
// 이 키가 없어 Chrome 에서 넣은 밑줄·취소선이 저장·발송에서 사라졌습니다(0.8.12 수정).
const STYLE_KEYS = new Set(['color','background-color','font-family','font-size','font-weight','font-style','text-decoration','text-decoration-line','text-decoration-style','text-decoration-color','text-align','line-height','margin','margin-left','margin-right','margin-top','margin-bottom','padding','padding-left','padding-right','padding-top','padding-bottom','border','border-collapse','border-color','border-width','border-style','width','height','min-height','max-width','vertical-align','white-space']);
// height 를 그대로 둘 요소(0.9.9). 그림은 크기 자체이고 표·칸은 내용이 많으면 어차피 늘어나므로 height 가 겹침을 만들지 않습니다.
// 그 밖의 요소(div·p·span·blockquote 등)는 height 를 min-height 로 바꿉니다: 웹 페이지·문서 편집기에서 복사한 본문에는 `<div style="height:40px">` 처럼
// 고정 높이가 붙어 오는데, 안의 글이 그 높이보다 길면 overflow 가 visible 이라 다음 문단 위로 번져 겹쳐 보입니다(편집기에서도, 받는 쪽 메일에서도).
// min-height 로 바꾸면 비어 있는 간격용 div 는 그 높이를 지키고, 글이 든 div 는 글 길이대로 자랍니다.
const KEEP_HEIGHT = new Set(['img', 'table', 'tr', 'td', 'th']);
const NUMERIC = new Set(['width','height','colspan','rowspan','cellpadding','cellspacing','border','size']);
const SIMPLE = new Set(['color','bgcolor','align','valign','face']);
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
export const escapeHtml = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
export function decodeEntities(s) {
return String(s).replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (m, e) => {
if (e[0] === '#') { const code = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m; }
return ENTITIES[e.toLowerCase()] ?? m;
});
}
function scheme(value) { const m = String(value).trim().replace(/[\u0000- ]/g, '').match(/^([a-z][a-z0-9+.-]*):/i); return m ? m[1].toLowerCase() : ''; }
function parseAttrs(raw) {
const out = []; const re = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>`]+)))?/g; let m;
while ((m = re.exec(raw))) out.push([m[1].toLowerCase(), decodeEntities(m[2] ?? m[3] ?? m[4] ?? '')]);
return out;
}
function cleanAttrs(tag, attrs) {
const clean = [];
for (const [name, value] of attrs) {
if (name === 'href' && tag === 'a' && ['https', 'http', 'mailto'].includes(scheme(value))) clean.push(`href="${escapeHtml(value)}"`);
if (name === 'src' && tag === 'img' && (['https', 'http', 'cid'].includes(scheme(value)) || /^\/api\/mail\/[A-Za-z0-9-]+\/attachments\/[A-Za-z0-9-]+(?:\?inline=1)?$/.test(value))) clean.push(`src="${escapeHtml(value)}"`);
if (name === 'style') {
const styles = [];
for (const declaration of value.split(';')) {
const i = declaration.indexOf(':'); if (i < 0) continue;
const key = declaration.slice(0, i).trim().toLowerCase(), v = declaration.slice(i + 1).trim();
// 음수 여백은 버립니다(0.9.9). margin-top:-400px 처럼 음수 여백이 붙은 문단은 앞 문단 위로 올라가 겹치거나 화면 밖으로 사라져(margin-left:-9999px),
// 편집기에서도 읽기 창에서도 글이 가려졌습니다. 양수 여백·들여쓰기는 그대로 둡니다.
if (/^margin/.test(key) && /(^|[\s,(])-\.?\d/.test(v)) continue;
if (STYLE_KEYS.has(key) && v.length < 160 && /^[a-zA-Z0-9#(),.% "'_-]+$/.test(v) && !/url|expression|javascript|behavior|var\(/i.test(v)) styles.push((key === 'height' && !KEEP_HEIGHT.has(tag) ? 'min-height' : key) + ':' + v);
}
if (styles.length) clean.push(`style="${escapeHtml(styles.join(';'))}"`);
}
if (NUMERIC.has(name) && /^[0-9]{1,4}%?$/.test(value)) clean.push(`${name}="${value}"`);
if (SIMPLE.has(name) && /^[a-zA-Z0-9# ,'-]{1,100}$/.test(value)) clean.push(`${name}="${escapeHtml(value)}"`);
if (name === 'alt' || name === 'title') clean.push(`${name}="${escapeHtml(value.slice(0, 300))}"`);
}
if (tag === 'a') clean.push('target="_blank"', 'rel="noopener noreferrer"');
return clean;
}
/**
* 허용 목록 방식 HTML 정제.
* @param {string} input 정제할 HTML
* @param {{limit?: number}} [options] limit: 받는 최대 글자 수. 넘으면 message_too_large 를 던집니다.
* 기본 40만 자는 화면 입력(미리 보기 정제 API)용입니다. 받은 메일·초안처럼 큰 HTML(뉴스레터 등)이 정상인 경로는
* 더 큰 한도를 넘기거나 store.mjs 의 safeContent 로 감싸 예외 대신 텍스트로 대체합니다.
* @returns {{html:string,text:string}}
*/
export function sanitizeHtml(input, { limit = 400000 } = {}) {
const html = String(input || '');
if (html.length > limit) throw new Error('message_too_large');
const parts = [], text = []; let depth = 0, i = 0;
const re = /<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<![^>]*>|<\?[^>]*>|<\/([a-zA-Z][a-zA-Z0-9:-]*)\s*>|<([a-zA-Z][a-zA-Z0-9:-]*)((?:\s+[^\s"'<>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'<>`]+))?)*)\s*\/?>/g;
let m;
while ((m = re.exec(html))) {
if (m.index > i) { const data = html.slice(i, m.index); if (!depth) { const decoded = decodeEntities(data); parts.push(escapeHtml(decoded)); text.push(decoded); } }
i = re.lastIndex;
const token = m[0];
if (token.startsWith('<!') || token.startsWith('<?')) continue;
if (m[1]) { // end tag
const tag = m[1].toLowerCase();
if (BLOCKED.has(tag)) { if (!UNWRAP_BLOCKED.has(tag) && !RAW_TEXT_BLOCKED.has(tag)) depth = Math.max(0, depth - 1); continue; }
if (depth) continue;
if (ALLOWED.has(tag) && !VOID.has(tag)) parts.push(`</${tag}>`);
if (['p', 'div', 'br', 'li', 'h1', 'h2', 'h3', 'h4', 'tr', 'blockquote', 'pre'].includes(tag)) text.push('\n');
continue;
}
const tag = m[2].toLowerCase();
if (depth && BREAKOUT.has(tag)) depth = 0; // svg/math 범위 안의 HTML 시작 태그: 브라우저처럼 외래 내용을 끝내고 그 태그부터 보통 HTML 로 처리합니다.
if (tag === 'body') continue; // 본문 시작 태그 자체는 쓰지 않습니다.
if (BLOCKED.has(tag)) {
if (VOID_BLOCKED.has(tag) || UNWRAP_BLOCKED.has(tag) || token.endsWith('/>')) continue;
if (RAW_TEXT_BLOCKED.has(tag)) {
// 닫는 태그까지의 내용(스크립트·스타일 본문)을 통째로 건너뜁니다. 닫는 태그가 없으면 열린 범위를 만들지 않습니다(뒤의 본문 보존).
const close = html.slice(re.lastIndex).search(new RegExp(`</${tag}\\s*>`, 'i'));
if (close >= 0) { const after = re.lastIndex + close; const endTag = html.slice(after).match(/^<\/[a-zA-Z0-9:-]+\s*>/); re.lastIndex = after + (endTag ? endTag[0].length : 0); }
else { const next = html.indexOf('<', re.lastIndex); re.lastIndex = next < 0 ? html.length : next; } // 닫는 태그가 없으면 바로 뒤의 글자(제목·스크립트 본문)만 버리고 다음 태그부터 이어 갑니다.
i = re.lastIndex; continue;
}
depth++; continue;
}
if (depth || !ALLOWED.has(tag)) continue;
const attrs = cleanAttrs(tag, parseAttrs(m[3] || ''));
parts.push(`<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>`);
if (tag === 'br') text.push('\n');
}
if (i < html.length && !depth) { const decoded = decodeEntities(html.slice(i)); parts.push(escapeHtml(decoded)); text.push(decoded); }
return { html: parts.join(''), text: text.join('').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() };
}
export function textToHtml(value) { return `<p>${escapeHtml(value || '').replaceAll('\n', '<br>')}</p>`; }
export function imageSources(html) { return [...String(html || '').matchAll(/<img\b[^>]*\bsrc="([^"]+)"/g)].map((x) => decodeEntities(x[1])); }