Skip to content

Commit aacd635

Browse files
committed
Linter: Implement erb-require-script-nonce rule
closes #543
1 parent 77ba292 commit aacd635

File tree

7 files changed

+413
-20
lines changed

7 files changed

+413
-20
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ This page contains documentation for all Herb Linter rules.
2828
- [`erb-no-unsafe-raw`](./erb-no-unsafe-raw.md) - Disallow `raw()` and `.html_safe` in ERB output
2929
- [`erb-no-unsafe-script-interpolation`](./erb-no-unsafe-script-interpolation.md) - Disallow unsafe ERB output inside `<script>` tags
3030
- [`erb-prefer-image-tag-helper`](./erb-prefer-image-tag-helper.md) - Prefer `image_tag` helper over `<img>` with ERB expressions
31+
- [`erb-require-script-nonce`](./erb-require-script-nonce.md) - Require `nonce` attribute on script tags and helpers
3132
- [`erb-require-trailing-newline`](./erb-require-trailing-newline.md) - Enforces that all HTML+ERB template files end with exactly one trailing newline character.
3233
- [`erb-require-whitespace-inside-tags`](./erb-require-whitespace-inside-tags.md) - Requires whitespace around ERB tags
3334
- [`erb-right-trim`](./erb-right-trim.md) - Enforce consistent right-trimming syntax.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Linter Rule: Require nonce attribute on script tags and helpers
2+
3+
**Rule:** `erb-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`, `javascript_pack_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+
<%= javascript_pack_tag "application", nonce: true %>
51+
```
52+
53+
```erb
54+
<%= tag.script nonce: true do %>
55+
alert("Hello, world!")
56+
<% end %>
57+
```
58+
59+
Non-JavaScript script types (not flagged):
60+
61+
```erb
62+
<script type="application/json">
63+
{"key": "value"}
64+
</script>
65+
```
66+
67+
```erb
68+
<script type="application/ld+json">
69+
{"@context": "https://schema.org"}
70+
</script>
71+
```
72+
73+
### 🚫 Bad
74+
75+
HTML script tags without a nonce:
76+
77+
```erb
78+
<script>
79+
alert("Hello, world!")
80+
</script>
81+
```
82+
83+
```erb
84+
<script type="text/javascript">
85+
console.log("Hello")
86+
</script>
87+
```
88+
89+
Rails helpers without `nonce: true`:
90+
91+
```erb
92+
<%= javascript_tag do %>
93+
alert("Hello, world!")
94+
<% end %>
95+
```
96+
97+
```erb
98+
<%= javascript_include_tag "application" %>
99+
```
100+
101+
```erb
102+
<%= javascript_pack_tag "application" %>
103+
```
104+
105+
```erb
106+
<%= tag.script do %>
107+
alert("Hello, world!")
108+
<% end %>
109+
```
110+
111+
## References
112+
113+
- [Inspiration: ERB Lint `RequireScriptNonce` rule](https://github.com/Shopify/erb_lint/blob/main/lib/erb_lint/linters/require_script_nonce.rb)
114+
- [MDN: Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
115+
- [Rails: `content_security_policy_nonce`](https://api.rubyonrails.org/classes/ActionDispatch/ContentSecurityPolicy/Request.html)

javascript/packages/linter/src/rules.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { ERBNoUnsafeJSAttributeRule } from "./rules/erb-no-unsafe-js-attribute.j
2626
import { ERBNoUnsafeRawRule } from "./rules/erb-no-unsafe-raw.js"
2727
import { ERBNoUnsafeScriptInterpolationRule } from "./rules/erb-no-unsafe-script-interpolation.js"
2828
import { ERBPreferImageTagHelperRule } from "./rules/erb-prefer-image-tag-helper.js"
29+
import { ERBRequireScriptNonceRule } from "./rules/erb-require-script-nonce.js"
2930
import { ERBRequireTrailingNewlineRule } from "./rules/erb-require-trailing-newline.js"
3031
import { ERBRequireWhitespaceRule } from "./rules/erb-require-whitespace-inside-tags.js"
3132
import { ERBRightTrimRule } from "./rules/erb-right-trim.js"
@@ -51,8 +52,8 @@ import { HTMLAttributeEqualsSpacingRule } from "./rules/html-attribute-equals-sp
5152
import { HTMLAttributeValuesRequireQuotesRule } from "./rules/html-attribute-values-require-quotes.js"
5253
import { HTMLAvoidBothDisabledAndAriaDisabledRule } from "./rules/html-avoid-both-disabled-and-aria-disabled.js"
5354
import { HTMLBodyOnlyElementsRule } from "./rules/html-body-only-elements.js"
54-
import { HTMLDetailsHasSummaryRule } from "./rules/html-details-has-summary.js"
5555
import { HTMLBooleanAttributesNoValueRule } from "./rules/html-boolean-attributes-no-value.js"
56+
import { HTMLDetailsHasSummaryRule } from "./rules/html-details-has-summary.js"
5657
import { HTMLHeadOnlyElementsRule } from "./rules/html-head-only-elements.js"
5758
import { HTMLIframeHasTitleRule } from "./rules/html-iframe-has-title.js"
5859
import { HTMLImgRequireAltRule } from "./rules/html-img-require-alt.js"
@@ -97,6 +98,7 @@ export const rules: RuleClass[] = [
9798
ERBNoInstanceVariablesInPartialsRule,
9899
ERBNoInterpolatedClassNamesRule,
99100
ERBNoJavascriptTagHelperRule,
101+
ERBRequireScriptNonceRule,
100102
ERBNoOutputControlFlowRule,
101103
ERBNoOutputInAttributeNameRule,
102104
ERBNoOutputInAttributePositionRule,
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { PrismVisitor, PrismNodes } from "@herb-tools/core"
2+
import { ParserRule } from "../types.js"
3+
import { BaseRuleVisitor } from "./rule-utils.js"
4+
import { getTagLocalName, getAttribute, getStaticAttributeValue, hasAttributeValue } from "@herb-tools/core"
5+
6+
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
7+
import type { ParseResult, ParserOptions, ERBContentNode, ERBBlockNode, HTMLOpenTagNode } from "@herb-tools/core"
8+
9+
const JAVASCRIPT_HELPERS = new Set([
10+
"javascript_tag",
11+
"javascript_include_tag",
12+
"javascript_pack_tag",
13+
])
14+
15+
interface JavaScriptHelperCall {
16+
name: string
17+
nonce: "unknown" | "missing" | "present";
18+
}
19+
20+
class JavaScriptTagHelperCallCollector extends PrismVisitor {
21+
public readonly javascriptTags: JavaScriptHelperCall[] = []
22+
private currentCall: JavaScriptHelperCall | null = null
23+
24+
visitCallNode(node: PrismNodes.CallNode): void {
25+
const isJavaScriptHelper = JAVASCRIPT_HELPERS.has(node.name) || this.isTagScriptCall(node)
26+
27+
if (isJavaScriptHelper) {
28+
this.currentCall = { name: node.name, nonce: "unknown" }
29+
this.javascriptTags.push(this.currentCall)
30+
}
31+
32+
super.visitCallNode(node)
33+
34+
if (isJavaScriptHelper && this.currentCall) {
35+
if (this.currentCall.nonce === "unknown") {
36+
this.currentCall.nonce = "missing"
37+
}
38+
39+
this.currentCall = null
40+
}
41+
}
42+
43+
visitAssocNode(node: PrismNodes.AssocNode): void {
44+
if (this.currentCall &&
45+
node.key.constructor.name === "SymbolNode" &&
46+
(node.key as PrismNodes.SymbolNode).unescaped.value === "nonce") {
47+
this.currentCall.nonce = "present"
48+
}
49+
50+
super.visitAssocNode(node)
51+
}
52+
53+
private isTagScriptCall(node: PrismNodes.CallNode): boolean {
54+
return node.name === "script" &&
55+
node.receiver !== null &&
56+
node.receiver.constructor.name === "CallNode" &&
57+
(node.receiver as PrismNodes.CallNode).name === "tag"
58+
}
59+
}
60+
61+
class RequireScriptNonceVisitor extends BaseRuleVisitor {
62+
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
63+
if (getTagLocalName(node) === "script") {
64+
this.checkScriptNonce(node)
65+
}
66+
}
67+
68+
visitERBContentNode(node: ERBContentNode): void {
69+
this.checkPrismNode(node)
70+
}
71+
72+
visitERBBlockNode(node: ERBBlockNode): void {
73+
this.checkPrismNode(node)
74+
75+
super.visitERBBlockNode(node)
76+
}
77+
78+
private checkScriptNonce(node: HTMLOpenTagNode): void {
79+
if (!this.isJavaScriptTag(node)) return
80+
81+
const nonceAttribute = getAttribute(node, "nonce")
82+
83+
if (!nonceAttribute || !hasAttributeValue(nonceAttribute)) {
84+
this.addOffense(
85+
"Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.",
86+
node.tag_name!.location,
87+
)
88+
}
89+
}
90+
91+
private isJavaScriptTag(node: HTMLOpenTagNode): boolean {
92+
const typeAttribute = getAttribute(node, "type")
93+
if (!typeAttribute) return true
94+
95+
const typeValue = getStaticAttributeValue(typeAttribute)
96+
if (typeValue === null) return true
97+
98+
return typeValue === "text/javascript" || typeValue === "application/javascript"
99+
}
100+
101+
private checkPrismNode(node: ERBContentNode | ERBBlockNode): void {
102+
const prismNode = node.prismNode
103+
if (!prismNode) return
104+
105+
const collector = new JavaScriptTagHelperCallCollector()
106+
collector.visit(prismNode)
107+
108+
collector.javascriptTags
109+
.filter(call => call.nonce === "missing")
110+
.forEach(() => {
111+
this.addOffense(
112+
"Missing a `nonce` attribute. Use `nonce: true`.",
113+
node.location,
114+
)
115+
})
116+
}
117+
}
118+
119+
export class ERBRequireScriptNonceRule extends ParserRule {
120+
static ruleName = "erb-require-script-nonce"
121+
122+
get defaultConfig(): FullRuleConfig {
123+
return {
124+
enabled: true,
125+
severity: "warning"
126+
}
127+
}
128+
129+
get parserOptions(): Partial<ParserOptions> {
130+
return {
131+
prism_nodes: true,
132+
}
133+
}
134+
135+
check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
136+
const visitor = new RequireScriptNonceVisitor(this.ruleName, context)
137+
138+
visitor.visit(result.value)
139+
140+
return visitor.offenses
141+
}
142+
}

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

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,38 +5,39 @@ export * from "./herb-disable-comment-base.js"
55

66
export * from "./erb-comment-syntax.js"
77
export * from "./erb-no-case-node-children.js"
8-
export * from "./erb-no-inline-case-conditions.js"
98
export * from "./erb-no-conditional-open-tag.js"
109
export * from "./erb-no-duplicate-branch-elements.js"
1110
export * from "./erb-no-empty-tags.js"
1211
export * from "./erb-no-extra-newline.js"
1312
export * from "./erb-no-extra-whitespace-inside-tags.js"
14-
export * from "./erb-no-output-control-flow.js"
15-
export * from "./erb-no-then-in-control-flow.js"
16-
export * from "./erb-no-silent-tag-in-attribute-name.js"
17-
export * from "./erb-no-trailing-whitespace.js"
18-
export * from "./erb-prefer-image-tag-helper.js"
19-
export * from "./erb-require-trailing-newline.js"
20-
export * from "./erb-require-whitespace-inside-tags.js"
13+
export * from "./erb-no-inline-case-conditions.js"
14+
export * from "./erb-no-instance-variables-in-partials.js"
2115
export * from "./erb-no-javascript-tag-helper.js"
16+
export * from "./erb-no-output-control-flow.js"
17+
export * from "./erb-no-output-in-attribute-name.js"
18+
export * from "./erb-no-output-in-attribute-position.js"
2219
export * from "./erb-no-raw-output-in-attribute-value.js"
20+
export * from "./erb-no-silent-tag-in-attribute-name.js"
2321
export * from "./erb-no-statement-in-script.js"
22+
export * from "./erb-no-then-in-control-flow.js"
23+
export * from "./erb-no-trailing-whitespace.js"
2424
export * from "./erb-no-unsafe-js-attribute.js"
2525
export * from "./erb-no-unsafe-raw.js"
2626
export * from "./erb-no-unsafe-script-interpolation.js"
27+
export * from "./erb-prefer-image-tag-helper.js"
28+
export * from "./erb-require-script-nonce.js"
29+
export * from "./erb-require-trailing-newline.js"
30+
export * from "./erb-require-whitespace-inside-tags.js"
2731
export * from "./erb-right-trim.js"
28-
export * from "./erb-no-output-in-attribute-position.js"
29-
export * from "./erb-no-output-in-attribute-name.js"
30-
export * from "./erb-no-instance-variables-in-partials.js"
3132
export * from "./erb-strict-locals-comment-syntax.js"
3233
export * from "./erb-strict-locals-required.js"
3334

34-
export * from "./herb-disable-comment-valid-rule-name.js"
35-
export * from "./herb-disable-comment-no-redundant-all.js"
36-
export * from "./herb-disable-comment-no-duplicate-rules.js"
37-
export * from "./herb-disable-comment-missing-rules.js"
3835
export * from "./herb-disable-comment-malformed.js"
36+
export * from "./herb-disable-comment-missing-rules.js"
37+
export * from "./herb-disable-comment-no-duplicate-rules.js"
38+
export * from "./herb-disable-comment-no-redundant-all.js"
3939
export * from "./herb-disable-comment-unnecessary.js"
40+
export * from "./herb-disable-comment-valid-rule-name.js"
4041

4142
export * from "./html-allowed-script-type.js"
4243
export * from "./html-anchor-require-href.js"
@@ -49,8 +50,8 @@ export * from "./html-attribute-equals-spacing.js"
4950
export * from "./html-attribute-values-require-quotes.js"
5051
export * from "./html-avoid-both-disabled-and-aria-disabled.js"
5152
export * from "./html-body-only-elements.js"
52-
export * from "./html-details-has-summary.js"
5353
export * from "./html-boolean-attributes-no-value.js"
54+
export * from "./html-details-has-summary.js"
5455
export * from "./html-head-only-elements.js"
5556
export * from "./html-iframe-has-title.js"
5657
export * from "./html-img-require-alt.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.

0 commit comments

Comments
 (0)