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

117 lines
10 KiB
JavaScript
Raw Normal View History

/**
* 수신 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])); }