Skip to content

Commit bc2eb93

Browse files
authored
Linter: Implement html-require-script-nonce rule (#1384)
closes #543 handles three things 1. html: `<script>` tags 2. erb: Rails javascript helpers, i.e. `javascript_tag`, `javascript_include_tag`, ~and `javascript_pack_tag`~ 3. erb: Rails tag helpers, i.e. `tag.script` uses #1374 to handle both `<script>` and `javascript_tag` helpers via `BaseRuleVisitor#visitHTMLElementNode`. > [!NOTE] > Does not handle `nil` values, e.g. `tag.script nonce: nil` based on the specs of the inspiration. If wanted, we can add more specs and handlers.
1 parent 4856642 commit bc2eb93

File tree

7 files changed

+303
-5
lines changed

7 files changed

+303
-5
lines changed

javascript/packages/linter/docs/rules/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ This page contains documentation for all Herb Linter rules.
5353
- [`html-attribute-values-require-quotes`](./html-attribute-values-require-quotes.md) - Requires quotes around attribute values
5454
- [`html-avoid-both-disabled-and-aria-disabled`](./html-avoid-both-disabled-and-aria-disabled.md) - Avoid using both `disabled` and `aria-disabled` attributes
5555
- [`html-body-only-elements`](./html-body-only-elements.md) - Require content elements inside `<body>`.
56-
- [`html-details-has-summary`](./html-details-has-summary.md) - Require `<summary>` in `<details>` elements
5756
- [`html-boolean-attributes-no-value`](./html-boolean-attributes-no-value.md) - Prevents values on boolean attributes
57+
- [`html-details-has-summary`](./html-details-has-summary.md) - Require `<summary>` in `<details>` elements
5858
- [`html-head-only-elements`](./html-head-only-elements.md) - Require head-scoped elements inside `<head>`.
5959
- [`html-iframe-has-title`](./html-iframe-has-title.md) - `iframe` elements must have a `title` attribute
6060
- [`html-img-require-alt`](./html-img-require-alt.md) - Requires `alt` attributes on `<img>` tags
@@ -74,6 +74,7 @@ This page contains documentation for all Herb Linter rules.
7474
- [`html-no-space-in-tag`](./html-no-space-in-tag.md) - Disallow spaces in HTML tags
7575
- [`html-no-title-attribute`](./html-no-title-attribute.md) - Avoid using the `title` attribute
7676
- [`html-no-underscores-in-attribute-names`](./html-no-underscores-in-attribute-names.md) - Disallow underscores in HTML attribute names
77+
- [`html-require-script-nonce`](./html-require-script-nonce.md) - Require `nonce` attribute on script tags and helpers
7778
- [`html-tag-name-lowercase`](./html-tag-name-lowercase.md) - Enforces lowercase tag names in HTML
7879
- [`parser-no-errors`](./parser-no-errors.md) - Disallow parser errors in HTML+ERB documents
7980
- [`svg-tag-name-capitalization`](./svg-tag-name-capitalization.md) - Enforces proper camelCase capitalization for SVG elements
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Linter Rule: Require nonce attribute on script tags and helpers
2+
3+
**Rule:** `html-require-script-nonce`
4+
5+
## Description
6+
7+
Require a `nonce` attribute on inline `<script>` tags, Rails JavaScript helper methods (`javascript_tag`, `javascript_include_tag`) and tag helpers (`tag.script`). This helps enforce a Content Security Policy (CSP) that mitigates cross-site scripting (XSS) attacks.
8+
9+
## Rationale
10+
11+
A Content Security Policy with a nonce-based approach ensures that only scripts with a valid, server-generated nonce are executed by the browser. Without a nonce, inline scripts and dynamically included scripts may be blocked by CSP, or worse, CSP may need to be relaxed with `unsafe-inline`, defeating its purpose.
12+
13+
Adding `nonce` attributes to all script tags and helpers ensures:
14+
15+
- Scripts are allowed by the CSP without weakening it
16+
- Protection against XSS attacks that attempt to inject unauthorized scripts
17+
- Consistent security practices across the codebase
18+
19+
## Examples
20+
21+
### ✅ Good
22+
23+
HTML script tags with a nonce:
24+
25+
```erb
26+
<script nonce="<%= request.content_security_policy_nonce %>">
27+
alert("Hello, world!")
28+
</script>
29+
```
30+
31+
```erb
32+
<script type="text/javascript" nonce="<%= request.content_security_policy_nonce %>">
33+
console.log("Hello")
34+
</script>
35+
```
36+
37+
Rails helpers with `nonce: true`:
38+
39+
```erb
40+
<%= javascript_tag nonce: true do %>
41+
alert("Hello, world!")
42+
<% end %>
43+
```
44+
45+
```erb
46+
<%= javascript_include_tag "application", nonce: true %>
47+
```
48+
49+
```erb
50+
<%= tag.script nonce: true do %>
51+
alert("Hello, world!")
52+
<% end %>
53+
```
54+
55+
Non-JavaScript script types (not flagged):
56+
57+
```erb
58+
<script type="application/json">
59+
{"key": "value"}
60+
</script>
61+
```
62+
63+
```erb
64+
<script type="application/ld+json">
65+
{"@context": "https://schema.org"}
66+
</script>
67+
```
68+
69+
### 🚫 Bad
70+
71+
HTML script tags without a nonce:
72+
73+
```erb
74+
<script>
75+
alert("Hello, world!")
76+
</script>
77+
```
78+
79+
```erb
80+
<script type="text/javascript">
81+
console.log("Hello")
82+
</script>
83+
```
84+
85+
Rails helpers without `nonce: true`:
86+
87+
```erb
88+
<%= javascript_tag do %>
89+
alert("Hello, world!")
90+
<% end %>
91+
```
92+
93+
```erb
94+
<%= javascript_include_tag "application" %>
95+
```
96+
97+
```erb
98+
<%= tag.script do %>
99+
alert("Hello, world!")
100+
<% end %>
101+
```
102+
103+
## References
104+
105+
- [Inspiration: ERB Lint `RequireScriptNonce` rule](https://github.com/Shopify/erb_lint/blob/main/lib/erb_lint/linters/require_script_nonce.rb)
106+
- [MDN: Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
107+
- [Rails: `content_security_policy_nonce`](https://api.rubyonrails.org/classes/ActionDispatch/ContentSecurityPolicy/Request.html)

javascript/packages/linter/src/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import { HTMLNoSpaceInTagRule } from "./rules/html-no-space-in-tag.js"
7777
import { HTMLNoTitleAttributeRule } from "./rules/html-no-title-attribute.js"
7878
import { HTMLNoUnderscoresInAttributeNamesRule } from "./rules/html-no-underscores-in-attribute-names.js"
7979
import { HTMLRequireClosingTagsRule } from "./rules/html-require-closing-tags.js"
80+
import { HTMLRequireScriptNonceRule } from "./rules/html-require-script-nonce.js"
8081
import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
8182

8283
import { ParserNoErrorsRule } from "./rules/parser-no-errors.js"
@@ -163,6 +164,7 @@ export const rules: RuleClass[] = [
163164
HTMLNoTitleAttributeRule,
164165
HTMLNoUnderscoresInAttributeNamesRule,
165166
HTMLRequireClosingTagsRule,
167+
HTMLRequireScriptNonceRule,
166168
HTMLTagNameLowercaseRule,
167169

168170
ParserNoErrorsRule,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { ParserRule } from "../types.js"
2+
import { BaseRuleVisitor } from "./rule-utils.js"
3+
import { getTagLocalName, getAttribute, getStaticAttributeValue, hasAttributeValue, findAttributeByName, isERBOpenTagNode } from "@herb-tools/core"
4+
5+
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
6+
import type { ParseResult, ParserOptions, HTMLElementNode } from "@herb-tools/core"
7+
8+
class RequireScriptNonceVisitor extends BaseRuleVisitor {
9+
visitHTMLElementNode(node: HTMLElementNode): void {
10+
if (getTagLocalName(node) === "script") {
11+
this.checkScriptNonce(node)
12+
}
13+
14+
super.visitHTMLElementNode(node)
15+
}
16+
17+
private checkScriptNonce(node: HTMLElementNode): void {
18+
if (!this.isJavaScriptTag(node)) return
19+
20+
const nonceAttribute = this.findAttribute(node, "nonce")
21+
22+
if (!nonceAttribute || !hasAttributeValue(nonceAttribute)) {
23+
this.addOffense(
24+
"Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.",
25+
node.tag_name!.location,
26+
)
27+
}
28+
}
29+
30+
private isJavaScriptTag(node: HTMLElementNode): boolean {
31+
const typeAttribute = this.findAttribute(node, "type")
32+
if (!typeAttribute) return true
33+
34+
const typeValue = getStaticAttributeValue(typeAttribute)
35+
if (typeValue === null) return true
36+
37+
return typeValue === "text/javascript" || typeValue === "application/javascript"
38+
}
39+
40+
private findAttribute(node: HTMLElementNode, name: string) {
41+
if (isERBOpenTagNode(node.open_tag)) {
42+
return findAttributeByName(node.open_tag.children, name)
43+
}
44+
45+
return getAttribute(node, name)
46+
}
47+
}
48+
49+
export class HTMLRequireScriptNonceRule extends ParserRule {
50+
static ruleName = "html-require-script-nonce"
51+
52+
get defaultConfig(): FullRuleConfig {
53+
return {
54+
enabled: true,
55+
severity: "error"
56+
}
57+
}
58+
59+
get parserOptions(): Partial<ParserOptions> {
60+
return {
61+
action_view_helpers: true,
62+
}
63+
}
64+
65+
check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
66+
const visitor = new RequireScriptNonceVisitor(this.ruleName, context)
67+
68+
visitor.visit(result.value)
69+
70+
return visitor.offenses
71+
}
72+
}

javascript/packages/linter/src/rules/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ export * from "./actionview-no-silent-render.js"
88

99
export * from "./erb-comment-syntax.js"
1010
export * from "./erb-no-case-node-children.js"
11-
export * from "./erb-no-empty-control-flow.js"
1211
export * from "./erb-no-conditional-open-tag.js"
1312
export * from "./erb-no-duplicate-branch-elements.js"
13+
export * from "./erb-no-empty-control-flow.js"
1414
export * from "./erb-no-empty-tags.js"
1515
export * from "./erb-no-extra-newline.js"
1616
export * from "./erb-no-extra-whitespace-inside-tags.js"
@@ -77,6 +77,7 @@ export * from "./html-no-space-in-tag.js"
7777
export * from "./html-no-title-attribute.js"
7878
export * from "./html-no-underscores-in-attribute-names.js"
7979
export * from "./html-require-closing-tags.js"
80+
export * from "./html-require-script-nonce.js"
8081
export * from "./html-tag-name-lowercase.js"
8182

8283
export * from "./svg-tag-name-capitalization.js"

javascript/packages/linter/test/__snapshots__/cli.test.ts.snap

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import dedent from "dedent"
2+
import { describe, test } from "vitest"
3+
import { HTMLRequireScriptNonceRule } from "../../src/rules/html-require-script-nonce.js"
4+
import { createLinterTest } from "../helpers/linter-test-helper.js"
5+
6+
const { expectNoOffenses, expectError, assertOffenses } = createLinterTest(HTMLRequireScriptNonceRule)
7+
8+
describe("html-require-script-nonce", () => {
9+
describe("HTML script tags", () => {
10+
test("passes when nonce attribute is present with a value", () => {
11+
expectNoOffenses('<script nonce="abc123"></script>')
12+
})
13+
14+
test("passes when nonce attribute is present with ERB value", () => {
15+
expectNoOffenses('<script nonce="<%= request.content_security_policy_nonce %>"></script>')
16+
})
17+
18+
test("fails when nonce attribute is missing", () => {
19+
expectError("Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.")
20+
21+
assertOffenses("<script></script>")
22+
})
23+
24+
test("fails when nonce attribute has no value", () => {
25+
expectError("Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.")
26+
27+
assertOffenses("<script nonce></script>")
28+
})
29+
30+
test("fails when type is text/javascript and nonce is missing", () => {
31+
expectError("Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.")
32+
33+
assertOffenses('<script type="text/javascript"></script>')
34+
})
35+
36+
test("fails when type is application/javascript and nonce is missing", () => {
37+
expectError("Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.")
38+
39+
assertOffenses('<script type="application/javascript"></script>')
40+
})
41+
42+
test("passes when type is text/javascript and nonce is present", () => {
43+
expectNoOffenses('<script type="text/javascript" nonce="abc123"></script>')
44+
})
45+
46+
test("passes when type is application/javascript and nonce is present", () => {
47+
expectNoOffenses('<script type="application/javascript" nonce="abc123"></script>')
48+
})
49+
50+
test("passes when type is not JavaScript", () => {
51+
expectNoOffenses('<script type="application/json"></script>')
52+
})
53+
54+
test("passes when type is application/ld+json", () => {
55+
expectNoOffenses('<script type="application/ld+json">{"@context": "https://schema.org"}</script>')
56+
})
57+
58+
test("ignores non-script tags", () => {
59+
expectNoOffenses('<div nonce="abc123"></div>')
60+
})
61+
})
62+
63+
describe("ERB javascript helpers", () => {
64+
test("fails when javascript_tag is used without nonce", () => {
65+
expectError("Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.")
66+
67+
assertOffenses(dedent`
68+
<%= javascript_tag %>
69+
`)
70+
})
71+
72+
test("fails when javascript_include_tag is used without nonce", () => {
73+
expectError("Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.")
74+
75+
assertOffenses(dedent`
76+
<%= javascript_include_tag "script" %>
77+
`)
78+
})
79+
80+
test("passes when javascript_tag is used with nonce", () => {
81+
expectNoOffenses(dedent`
82+
<%= javascript_tag nonce: true %>
83+
`)
84+
})
85+
86+
test("passes when javascript_include_tag is used with nonce", () => {
87+
expectNoOffenses(dedent`
88+
<%= javascript_include_tag "script", nonce: true %>
89+
`)
90+
})
91+
92+
})
93+
94+
describe("tag.script helper", () => {
95+
test("fails when tag.script is used without nonce", () => {
96+
expectError("Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.")
97+
98+
assertOffenses(dedent`
99+
<%= tag.script %>
100+
`)
101+
})
102+
103+
test("passes when tag.script is used with nonce", () => {
104+
expectNoOffenses(dedent`
105+
<%= tag.script nonce: true %>
106+
`)
107+
})
108+
})
109+
110+
test("passes using unrelated content_tag", () => {
111+
expectNoOffenses(dedent`
112+
<%= content_tag :div, "hello" %>
113+
`)
114+
})
115+
})

0 commit comments

Comments
 (0)