Files
cieanyau 651c36367f
前端验证 / verify (push) Failing after 15m2s
接入 ESLint 与 Prettier 并加入 CI 静态门禁
2026-11-07 01:24:36 +08:00

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 };