Skip to content

Commit aef98e4

Browse files
authored
fix(security): add safe html rendering primitive (koala73#3918)
1 parent 75ed983 commit aef98e4

11 files changed

Lines changed: 1474 additions & 95 deletions

scripts/enforce-safe-html.mjs

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,21 @@ function fingerprint(file, line) {
7474
return `${file}:${hash}`;
7575
}
7676

77+
function setContentFingerprint(file, line, lineNumber) {
78+
const normalized = line.replace(/\s+/g, ' ').trim();
79+
const hash = createHash('sha256').update(`setContent\0${file}\0${lineNumber}\0${normalized}`).digest('hex').slice(0, 16);
80+
return `${file}:setContent:${hash}`;
81+
}
82+
83+
function isSetContentCall(line) {
84+
return /\.\s*setContent\s*\(/.test(line);
85+
}
86+
87+
function isCommentOnlyLine(line) {
88+
const trimmed = line.trim();
89+
return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*');
90+
}
91+
7792
export function findUnsafeHtmlAssignments(root = repoRoot) {
7893
const findings = [];
7994

@@ -85,16 +100,32 @@ export function findUnsafeHtmlAssignments(root = repoRoot) {
85100
const lines = readFileSync(filePath, 'utf8').split('\n');
86101
for (let i = 0; i < lines.length; i += 1) {
87102
const line = lines[i];
88-
if (!/\.(?:innerHTML|outerHTML)\s*=/.test(line)) continue;
89-
if (isClearOperation(line)) continue;
90-
if (hasAuditComment(lines, i)) continue;
91-
92-
findings.push({
93-
file: rel,
94-
line: i + 1,
95-
code: line.trim(),
96-
fingerprint: fingerprint(rel, line),
97-
});
103+
if (/\.(?:innerHTML|outerHTML)\s*=/.test(line)) {
104+
if (isClearOperation(line)) continue;
105+
if (hasAuditComment(lines, i)) continue;
106+
107+
findings.push({
108+
file: rel,
109+
line: i + 1,
110+
kind: 'direct-html-assignment',
111+
code: line.trim(),
112+
fingerprint: fingerprint(rel, line),
113+
});
114+
continue;
115+
}
116+
117+
if (isSetContentCall(line)) {
118+
if (isCommentOnlyLine(line)) continue;
119+
if (hasAuditComment(lines, i)) continue;
120+
121+
findings.push({
122+
file: rel,
123+
line: i + 1,
124+
kind: 'panel-set-content',
125+
code: line.trim(),
126+
fingerprint: setContentFingerprint(rel, line, i + 1),
127+
});
128+
}
98129
}
99130
}
100131
}
@@ -110,10 +141,10 @@ function readBaseline(baselinePath) {
110141

111142
function writeBaseline(baselinePath, findings) {
112143
const entries = findings
113-
.map(({ file, line, code, fingerprint }) => ({ file, line, fingerprint, code }))
144+
.map(({ file, line, kind, code, fingerprint }) => ({ file, line, kind, fingerprint, code }))
114145
.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
115146
const payload = {
116-
note: 'Baseline of legacy direct innerHTML/outerHTML assignments for scripts/enforce-safe-html.mjs. Do not add entries for new code; route through src/utils/dom-utils.ts or add a wm-safe-html audited comment for narrow exceptions.',
147+
note: 'Baseline of legacy direct innerHTML/outerHTML assignments and Panel.setContent() call sites for scripts/enforce-safe-html.mjs. Do not add entries for new code; route through src/utils/dom-utils.ts, Panel.setSafeContent(), or add a wm-safe-html audited comment for narrow exceptions.',
117148
entries,
118149
};
119150
writeFileSync(baselinePath, `${JSON.stringify(payload, null, 2)}\n`);
@@ -132,14 +163,15 @@ function main() {
132163
const baseline = readBaseline(args.baseline);
133164
const newFindings = findings.filter(finding => !baseline.has(finding.fingerprint));
134165
if (newFindings.length === 0) {
135-
console.log(`Safe HTML guard passed (${findings.length} legacy assignments tracked).`);
166+
console.log(`Safe HTML guard passed (${findings.length} legacy HTML sinks tracked).`);
136167
return;
137168
}
138169

139170
console.error('Direct innerHTML/outerHTML assignment is blocked.');
140-
console.error('Use setTrustedHtml()/trustedHtml() from src/utils/dom-utils.ts, use clearChildren()/replaceChildren(), or add an adjacent `wm-safe-html: audited - ...` comment for a narrow intentional exception.');
171+
console.error('Panel.setContent() calls are also blocked unless they are already baselined or have an adjacent `wm-safe-html: audited - ...` comment.');
172+
console.error('Use setTrustedHtml()/trustedHtml() from src/utils/dom-utils.ts, Panel.setSafeContent(), use clearChildren()/replaceChildren(), or add an adjacent audit comment for a narrow intentional exception.');
141173
for (const finding of newFindings.slice(0, 25)) {
142-
console.error(`- ${finding.file}:${finding.line}: ${finding.code}`);
174+
console.error(`- ${finding.file}:${finding.line} [${finding.kind}]: ${finding.code}`);
143175
}
144176
if (newFindings.length > 25) {
145177
console.error(`...and ${newFindings.length - 25} more.`);

0 commit comments

Comments
 (0)