Skip to content

Commit 031fff6

Browse files
heiskrCopilot
andauthored
Ban dangerouslySetInnerHTML via custom lint rule (#61968)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 797e1ee commit 031fff6

5 files changed

Lines changed: 147 additions & 0 deletions

File tree

eslint.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ export default [
103103
// Custom rules
104104
'custom-rules/use-custom-logger': 'error',
105105

106+
// Disallow dangerouslySetInnerHTML; render trusted HTML via RenderedHTML /
107+
// renderHTMLString or a hast tree instead (github/docs-engineering#6619).
108+
'custom-rules/no-dangerously-set-inner-html': 'error',
109+
106110
// Prevent direct res.redirect() usage — use res.safeRedirect() instead
107111
// to avoid open redirect vulnerabilities via protocol-relative URLs.
108112
'no-restricted-syntax': [

src/eslint-rules/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
module.exports = {
33
rules: {
44
"use-custom-logger": require("./use-custom-logger/use-custom-logger"),
5+
"no-dangerously-set-inner-html": require("./no-dangerously-set-inner-html/no-dangerously-set-inner-html"),
56
},
67
};
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
module.exports = {
2+
meta: {
3+
type: "problem",
4+
docs: {
5+
description:
6+
"Disallow dangerouslySetInnerHTML; render trusted HTML with RenderedHTML/renderHTMLString or a hast tree instead",
7+
category: "Possible Errors",
8+
recommended: false,
9+
},
10+
schema: [], // no options
11+
messages: {
12+
noDanger:
13+
"Do not use dangerouslySetInnerHTML. Render trusted HTML with the RenderedHTML component (or renderHTMLString), or pass a hast tree to MarkdownContent. See github/docs-engineering#6619.",
14+
},
15+
},
16+
create(context) {
17+
return {
18+
// Flag the JSX attribute form: <div dangerouslySetInnerHTML={...} />
19+
JSXAttribute(node) {
20+
if (node.name && node.name.name === "dangerouslySetInnerHTML") {
21+
context.report({ node, messageId: "noDanger" });
22+
}
23+
},
24+
// Flag the object-property form used when spreading props, e.g.
25+
// { dangerouslySetInnerHTML: { __html: html } }. Only object *expressions*
26+
// (constructing props) are unsafe; skip object *patterns* (destructuring
27+
// like `const { dangerouslySetInnerHTML, ...rest } = props`), which strip
28+
// the prop and are safe.
29+
Property(node) {
30+
if (!node.parent || node.parent.type !== "ObjectExpression") return;
31+
const key = node.key;
32+
if (
33+
key &&
34+
((key.type === "Identifier" &&
35+
key.name === "dangerouslySetInnerHTML") ||
36+
(key.type === "Literal" && key.value === "dangerouslySetInnerHTML"))
37+
) {
38+
context.report({ node, messageId: "noDanger" });
39+
}
40+
},
41+
// Flag the assignment form, including the computed string-key bypass:
42+
// props.dangerouslySetInnerHTML = { __html: html }
43+
// props['dangerouslySetInnerHTML'] = { __html: html }
44+
AssignmentExpression(node) {
45+
const left = node.left;
46+
if (!left || left.type !== "MemberExpression") return;
47+
const prop = left.property;
48+
if (!prop) return;
49+
const isDanger =
50+
(!left.computed &&
51+
prop.type === "Identifier" &&
52+
prop.name === "dangerouslySetInnerHTML") ||
53+
(left.computed &&
54+
prop.type === "Literal" &&
55+
prop.value === "dangerouslySetInnerHTML");
56+
if (isDanger) {
57+
context.report({ node, messageId: "noDanger" });
58+
}
59+
},
60+
};
61+
},
62+
};
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, it } from 'vitest'
2+
import { RuleTester, Rule } from 'eslint'
3+
import ruleModule from '../no-dangerously-set-inner-html'
4+
5+
const rule = ruleModule as Rule.RuleModule
6+
7+
const ruleTester = new RuleTester({
8+
languageOptions: {
9+
ecmaVersion: 2022,
10+
sourceType: 'module',
11+
parserOptions: {
12+
ecmaFeatures: { jsx: true },
13+
},
14+
},
15+
})
16+
17+
describe('no-dangerously-set-inner-html', () => {
18+
it('allows code that does not use dangerouslySetInnerHTML', () => {
19+
ruleTester.run('no-dangerously-set-inner-html', rule, {
20+
valid: [
21+
{ code: `const el = <RenderedHTML as="div" html={html} />` },
22+
{ code: `const el = <MarkdownContent hast={hast} />` },
23+
{ code: `const props = { className: 'x', children: nodes }` },
24+
// Destructuring that strips the prop is safe and must not be flagged.
25+
{ code: `const { dangerouslySetInnerHTML, ...safeProps } = props` },
26+
],
27+
invalid: [],
28+
})
29+
})
30+
31+
it('flags the JSX attribute form', () => {
32+
ruleTester.run('no-dangerously-set-inner-html', rule, {
33+
valid: [],
34+
invalid: [
35+
{
36+
code: `const el = <div dangerouslySetInnerHTML={{ __html: html }} />`,
37+
errors: [{ messageId: 'noDanger' }],
38+
},
39+
],
40+
})
41+
})
42+
43+
it('flags the object-property form used when spreading props', () => {
44+
ruleTester.run('no-dangerously-set-inner-html', rule, {
45+
valid: [],
46+
invalid: [
47+
{
48+
code: `const childProps = { dangerouslySetInnerHTML: { __html: children } }`,
49+
errors: [{ messageId: 'noDanger' }],
50+
},
51+
// String-literal key form must also be flagged.
52+
{
53+
code: `const childProps = { 'dangerouslySetInnerHTML': { __html: children } }`,
54+
errors: [{ messageId: 'noDanger' }],
55+
},
56+
],
57+
})
58+
})
59+
60+
it('flags the assignment form', () => {
61+
ruleTester.run('no-dangerously-set-inner-html', rule, {
62+
valid: [],
63+
invalid: [
64+
{
65+
code: `props.dangerouslySetInnerHTML = { __html: html }`,
66+
errors: [{ messageId: 'noDanger' }],
67+
},
68+
// Computed string-key assignment is a trivial bypass and must be flagged.
69+
{
70+
code: `props['dangerouslySetInnerHTML'] = { __html: html }`,
71+
errors: [{ messageId: 'noDanger' }],
72+
},
73+
],
74+
})
75+
})
76+
})

src/pages/_document.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export default class MyDocument extends Document {
1717
data-dark-theme={defaultCSSTheme.darkTheme}
1818
>
1919
<Head>
20+
{/* Inline color-mode script must run before paint to avoid a flash; it
21+
injects executable JS (not content HTML), so RenderedHTML/hast do
22+
not apply here. */}
23+
{/* eslint-disable-next-line custom-rules/no-dangerously-set-inner-html */}
2024
<script dangerouslySetInnerHTML={{ __html: colorModeScript }} />
2125
</Head>
2226
<body>

0 commit comments

Comments
 (0)