131 lines
4.1 KiB
JavaScript
131 lines
4.1 KiB
JavaScript
/**
|
|
* 在浏览器 DOM 中重新构造允许的富文本,不直接信任后端商品/内容 HTML。
|
|
* 白名单外的普通容器展开子内容;script、表单和非 HTML 命名空间整段丢弃。
|
|
*/
|
|
var e = new Set(
|
|
`a.p.br.hr.div.span.h1.h2.h3.h4.h5.h6.b.strong.i.em.u.s.del.ins.sub.sup.small.mark.blockquote.pre.code.kbd.ul.ol.li.dl.dt.dd.table.thead.tbody.tfoot.tr.th.td.caption.colgroup.col.img.figure.figcaption`.split(
|
|
`.`,
|
|
),
|
|
),
|
|
t = new Set([
|
|
`script`,
|
|
`style`,
|
|
`iframe`,
|
|
`object`,
|
|
`embed`,
|
|
`link`,
|
|
`meta`,
|
|
`base`,
|
|
`form`,
|
|
`input`,
|
|
`button`,
|
|
`textarea`,
|
|
`select`,
|
|
`option`,
|
|
`template`,
|
|
`noscript`,
|
|
`noembed`,
|
|
`noframes`,
|
|
`xmp`,
|
|
`plaintext`,
|
|
`svg`,
|
|
`math`,
|
|
`audio`,
|
|
`video`,
|
|
`source`,
|
|
]);
|
|
// 图片额外允许有限位图类型的 base64 data URL;普通链接可用 mailto/tel,不允许脚本协议。
|
|
function n(e, t = !1) {
|
|
// eslint-disable-next-line no-control-regex -- 安全边界:拒绝富文本链接和图片中的控制字符。
|
|
if (!e || /[\u0000-\u001f\u007f]/.test(e)) return !1;
|
|
if (
|
|
t &&
|
|
/^data:image\/(?:png|jpeg|gif|webp|avif);base64,[a-z\d+/]+={0,2}$/i.test(e)
|
|
)
|
|
return !0;
|
|
try {
|
|
let n = new URL(e, `https://sanitizer.invalid/`).protocol;
|
|
return (
|
|
n === `https:` ||
|
|
n === `http:` ||
|
|
(!t && (n === `mailto:` || n === `tel:`))
|
|
);
|
|
} catch {
|
|
return !1;
|
|
}
|
|
}
|
|
/** 清洗标签和属性,只保留有限排版;新窗口链接强制添加 noopener noreferrer。 */
|
|
function r(r) {
|
|
if (!r) return ``;
|
|
let i = new DOMParser().parseFromString(r, `text/html`),
|
|
a = i.createElement(`div`);
|
|
function o(r, a) {
|
|
if (r.nodeType === 3) {
|
|
a.appendChild(i.createTextNode(r.textContent ?? ``));
|
|
return;
|
|
}
|
|
if (r.nodeType !== 1) return;
|
|
let s = r,
|
|
c = s.localName;
|
|
if (s.namespaceURI !== `http://www.w3.org/1999/xhtml` || t.has(c)) return;
|
|
let l = a;
|
|
if (e.has(c)) {
|
|
let e = i.createElement(c);
|
|
for (let t of Array.from(s.attributes)) {
|
|
let r = t.name.toLowerCase(),
|
|
i = t.value.trim();
|
|
t.namespaceURI === null &&
|
|
(r === `title` || (r === `alt` && c === `img`)
|
|
? e.setAttribute(r, t.value)
|
|
: r === `style` &&
|
|
/^(p|div|h[1-6])$/.test(c) &&
|
|
/^text-align:\s*(left|center|right|justify);?$/i.test(i)
|
|
? e.setAttribute(`style`, i.toLowerCase())
|
|
: ((r === `dir` && /^(ltr|rtl|auto)$/.test(i)) ||
|
|
(r === `href` && c === `a` && n(i)) ||
|
|
(r === `src` && c === `img` && n(i, !0)) ||
|
|
(r === `target` &&
|
|
c === `a` &&
|
|
/^(?:_blank|_self)$/.test(i)) ||
|
|
(r === `scope` &&
|
|
c === `th` &&
|
|
/^(?:row|col|rowgroup|colgroup)$/.test(i)) ||
|
|
((((r === `colspan` || r === `rowspan`) &&
|
|
(c === `td` || c === `th`)) ||
|
|
((r === `width` || r === `height`) && c === `img`) ||
|
|
(r === `span` && (c === `col` || c === `colgroup`)) ||
|
|
(r === `start` && c === `ol`)) &&
|
|
/^\d{1,4}$/.test(i) &&
|
|
Number(i) > 0)) &&
|
|
e.setAttribute(r, i));
|
|
}
|
|
(c === `a` &&
|
|
e.getAttribute(`target`) === `_blank` &&
|
|
e.setAttribute(`rel`, `noopener noreferrer`),
|
|
a.appendChild(e),
|
|
(l = e));
|
|
}
|
|
for (let e of Array.from(r.childNodes)) o(e, l);
|
|
}
|
|
for (let e of Array.from(i.body.childNodes)) o(e, a);
|
|
return a.innerHTML;
|
|
}
|
|
// 对历史双重实体编码最多解码两次;返回值只是解码文本,不能代替上面的 HTML 清洗。
|
|
function i(e) {
|
|
if (!e) return ``;
|
|
let t = e;
|
|
for (
|
|
let e = 0;
|
|
e < 2 && /&(?:amp|lt|gt|quot|#\d+|#x[0-9a-f]+);/i.test(t);
|
|
e++
|
|
) {
|
|
let e =
|
|
new DOMParser().parseFromString(t, `text/html`).documentElement
|
|
.textContent ?? t;
|
|
if (e === t) break;
|
|
t = e;
|
|
}
|
|
return t;
|
|
}
|
|
export { r as n, i as t };
|