Skip to content

Commit c234f26

Browse files
authored
Parser: Properly resolve nonce: true for javascript_tag (#1452)
In Rails, `nonce: true` on `javascript_include_tag` and `javascript_tag` resolve to `content_security_policy_nonce` at runtime, and `nonce: false` omits the attribute. Other helpers like `tag.script` and `content_tag` pass it through as a literal value. Previously, `javascript_include_tag` and `javascript_tag` also just passed along and transformed the `nonce` value as a literal value. This pulls request adds `resolve_nonce_attribute()` to the parser to handle this transformation for `javascript_include_tag` and `javascript_tag`. So a document like: ```erb <%= javascript_tag nonce: true do %> alert('Hello') <% end %> ``` Now properly parses as: ```diff @ DocumentNode (location: (1:0)-(4:0)) └── children: (2 items) ├── @ HTMLElementNode (location: (1:0)-(3:9)) │ ├── open_tag: │ │ └── @ ERBOpenTagNode (location: (1:0)-(1:36)) │ │ ├── tag_opening: "<%=" (location: (1:0)-(1:3)) │ │ ├── content: " javascript_tag nonce: true do " (location: (1:3)-(1:34)) │ │ ├── tag_closing: "%>" (location: (1:34)-(1:36)) │ │ ├── tag_name: "script" (location: (1:4)-(1:18)) │ │ └── children: (1 item) │ │ └── @ HTMLAttributeNode (location: (1:19)-(1:30)) │ │ ├── name: │ │ │ └── @ HTMLAttributeNameNode (location: (1:19)-(1:24)) │ │ │ └── children: (1 item) │ │ │ └── @ LiteralNode (location: (1:19)-(1:24)) │ │ │ └── content: "nonce" │ │ │ │ │ ├── equals: ": " (location: (1:24)-(1:26)) │ │ └── value: │ │ └── @ HTMLAttributeValueNode (location: (1:26)-(1:30)) │ │ ├── open_quote: ∅ │ │ ├── children: (1 item) - │ │ │ └── @ LiteralNode (location: (1:26)-(1:30)) - │ │ │ └── content: "true" + │ │ │ └── @ RubyLiteralNode (location: (1:26)-(1:30)) + │ │ │ └── content: "content_security_policy_nonce" │ │ │ │ │ ├── close_quote: ∅ │ │ └── quoted: false │ │ │ ├── tag_name: "script" (location: (1:4)-(1:18)) │ ├── body: (1 item) │ │ └── @ LiteralNode (location: (1:36)-(3:0)) │ │ └── content: "\n alert('Hello')\n" │ │ │ ├── close_tag: │ │ └── @ ERBEndNode (location: (3:0)-(3:9)) │ │ ├── tag_opening: "<%" (location: (3:0)-(3:2)) │ │ ├── content: " end " (location: (3:2)-(3:7)) │ │ └── tag_closing: "%>" (location: (3:7)-(3:9)) │ │ │ ├── is_void: false │ └── element_source: "ActionView::Helpers::JavaScriptHelper#javascript_tag" │ └── @ HTMLTextNode (location: (3:9)-(4:0)) └── content: "\n" ``` So that it can get properly transformed from/to: ```erb <script nonce="<%= content_security_policy_nonce %>"> alert('Hello') </script> ``` Additionally, it updates the `html-require-script-nonce` linter rule to flag `tag.script` and `content_tag :script` using `nonce: true` or `nonce: false`, since those produce literal attribute values that will not match the CSP header. The rule now flags the following: ```erb <%= tag.script nonce: true do %> alert('Hello') <% end %> ``` with: ```txt `nonce: true` on `tag.script` outputs a literal `nonce="true"` attribute, which will not match the Content Security Policy header and the browser will block the script. Only `javascript_tag` and `javascript_include_tag` resolve `nonce: true` to the per-request `content_security_policy_nonce`. Use `javascript_tag` with `nonce: true` instead. ``` Follow up on #1384
1 parent dc5a00a commit c234f26

File tree

19 files changed

+564
-19
lines changed

19 files changed

+564
-19
lines changed

javascript/packages/linter/src/rules/html-require-script-nonce.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import { BaseRuleVisitor } from "./rule-utils.js"
33
import { getTagLocalName, getAttribute, getStaticAttributeValue, hasAttributeValue, findAttributeByName, isERBOpenTagNode } from "@herb-tools/core"
44

55
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
6-
import type { ParseResult, ParserOptions, HTMLElementNode } from "@herb-tools/core"
6+
import type { ParseResult, ParserOptions, HTMLElementNode, HTMLAttributeNode } from "@herb-tools/core"
7+
8+
const HELPERS_WITH_CSP_NONCE_SUPPORT = [
9+
"ActionView::Helpers::AssetTagHelper#javascript_include_tag",
10+
"ActionView::Helpers::JavaScriptHelper#javascript_tag",
11+
]
712

813
class RequireScriptNonceVisitor extends BaseRuleVisitor {
914
visitHTMLElementNode(node: HTMLElementNode): void {
@@ -24,7 +29,39 @@ class RequireScriptNonceVisitor extends BaseRuleVisitor {
2429
"Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.",
2530
node.tag_name!.location,
2631
)
32+
33+
return
34+
}
35+
36+
this.checkLiteralNonceOnTagHelper(node, nonceAttribute)
37+
}
38+
39+
private checkLiteralNonceOnTagHelper(node: HTMLElementNode, nonceAttribute: HTMLAttributeNode): void {
40+
if (!node.element_source) return
41+
if (HELPERS_WITH_CSP_NONCE_SUPPORT.includes(node.element_source)) return
42+
43+
const nonceValue = getStaticAttributeValue(nonceAttribute)
44+
45+
if (nonceValue === "true" || nonceValue === "false") {
46+
this.addOffense(
47+
`\`nonce: ${nonceValue}\` on \`${this.helperName(node)}\` outputs a literal \`nonce="${nonceValue}"\` attribute, which will not match the Content Security Policy header and the browser will block the script. Only \`javascript_tag\` and \`javascript_include_tag\` resolve \`nonce: true\` to the per-request \`content_security_policy_nonce\`. Use \`javascript_tag\` with \`nonce: true\` instead.`,
48+
nonceAttribute.name!.location,
49+
undefined,
50+
"error",
51+
)
52+
}
53+
}
54+
55+
private helperName(node: HTMLElementNode): string {
56+
if (node.element_source === "ActionView::Helpers::TagHelper#content_tag") {
57+
return "content_tag"
58+
}
59+
60+
if (node.element_source === "ActionView::Helpers::TagHelper#tag") {
61+
return "tag.script"
2762
}
63+
64+
return node.element_source ?? "unknown"
2865
}
2966

3067
private isJavaScriptTag(node: HTMLElementNode): boolean {

javascript/packages/linter/test/rules/html-require-script-nonce.test.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,16 +100,76 @@ describe("html-require-script-nonce", () => {
100100
`)
101101
})
102102

103-
test("passes when tag.script is used with nonce", () => {
104-
expectNoOffenses(dedent`
103+
test("warns when tag.script is used with nonce: true", () => {
104+
expectError(
105+
'`nonce: true` on `tag.script` outputs a literal `nonce="true"` attribute, which will not match the Content Security Policy header and the browser will block the script. Only `javascript_tag` and `javascript_include_tag` resolve `nonce: true` to the per-request `content_security_policy_nonce`. Use `javascript_tag` with `nonce: true` instead.'
106+
)
107+
108+
assertOffenses(dedent`
105109
<%= tag.script nonce: true %>
106110
`)
107111
})
108112
})
109113

114+
describe("literal nonce warnings for tag helpers", () => {
115+
test("warns when content_tag :script uses nonce: true", () => {
116+
expectError(
117+
'`nonce: true` on `content_tag` outputs a literal `nonce="true"` attribute, which will not match the Content Security Policy header and the browser will block the script. Only `javascript_tag` and `javascript_include_tag` resolve `nonce: true` to the per-request `content_security_policy_nonce`. Use `javascript_tag` with `nonce: true` instead.'
118+
)
119+
120+
assertOffenses(dedent`
121+
<%= content_tag(:script, "alert(1)", nonce: true) %>
122+
`)
123+
})
124+
125+
test("warns when content_tag :script uses nonce: false", () => {
126+
expectError(
127+
'`nonce: false` on `content_tag` outputs a literal `nonce="false"` attribute, which will not match the Content Security Policy header and the browser will block the script. Only `javascript_tag` and `javascript_include_tag` resolve `nonce: true` to the per-request `content_security_policy_nonce`. Use `javascript_tag` with `nonce: true` instead.'
128+
)
129+
130+
assertOffenses(dedent`
131+
<%= content_tag(:script, "alert(1)", nonce: false) %>
132+
`)
133+
})
134+
135+
test("warns when tag.script uses nonce: true", () => {
136+
expectError(
137+
'`nonce: true` on `tag.script` outputs a literal `nonce="true"` attribute, which will not match the Content Security Policy header and the browser will block the script. Only `javascript_tag` and `javascript_include_tag` resolve `nonce: true` to the per-request `content_security_policy_nonce`. Use `javascript_tag` with `nonce: true` instead.'
138+
)
139+
140+
assertOffenses(dedent`
141+
<%= tag.script(nonce: true) { "alert(1)".html_safe } %>
142+
`)
143+
})
144+
145+
test("warns when tag.script uses nonce: false", () => {
146+
expectError(
147+
'`nonce: false` on `tag.script` outputs a literal `nonce="false"` attribute, which will not match the Content Security Policy header and the browser will block the script. Only `javascript_tag` and `javascript_include_tag` resolve `nonce: true` to the per-request `content_security_policy_nonce`. Use `javascript_tag` with `nonce: true` instead.'
148+
)
149+
150+
assertOffenses(dedent`
151+
<%= tag.script(nonce: false) { "alert(1)".html_safe } %>
152+
`)
153+
})
154+
155+
test("does not warn when javascript_include_tag uses nonce: true", () => {
156+
expectNoOffenses(dedent`
157+
<%= javascript_include_tag "application", nonce: true %>
158+
`)
159+
})
160+
161+
test("does not warn when javascript_tag uses nonce: true", () => {
162+
expectNoOffenses(dedent`
163+
<%= javascript_tag nonce: true do %>
164+
alert('Hello')
165+
<% end %>
166+
`)
167+
})
168+
})
169+
110170
test("passes using unrelated content_tag", () => {
111171
expectNoOffenses(dedent`
112-
<%= content_tag :div, "hello" %>
172+
<%= content_tag :div, "hello", nonce: true %>
113173
`)
114174
})
115175
})

javascript/packages/rewriter/test/action-view-tag-helper-to-html.test.ts

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,18 @@ describe("ActionViewTagHelperToHTMLRewriter", () => {
206206

207207
expect(transform(input)).toBe(expected)
208208
})
209+
210+
test("tag.script with nonce true passes through as literal", () => {
211+
expect(transform('<%= tag.script(nonce: true) { "alert(1)".html_safe } %>')).toBe(
212+
'<script nonce="true"><%= "alert(1)".html_safe %></script>'
213+
)
214+
})
215+
216+
test("tag.script with nonce false passes through as literal", () => {
217+
expect(transform('<%= tag.script(nonce: false) { "alert(1)".html_safe } %>')).toBe(
218+
'<script nonce="false"><%= "alert(1)".html_safe %></script>'
219+
)
220+
})
209221
})
210222

211223
describe("content_tag helpers", () => {
@@ -232,6 +244,18 @@ describe("ActionViewTagHelperToHTMLRewriter", () => {
232244
"<div>Block</div>"
233245
)
234246
})
247+
248+
test("content_tag :script with nonce true passes through as literal", () => {
249+
expect(transform('<%= content_tag(:script, "alert(1)", nonce: true) %>')).toBe(
250+
'<script nonce="true">alert(1)</script>'
251+
)
252+
})
253+
254+
test("content_tag :script with nonce false passes through as literal", () => {
255+
expect(transform('<%= content_tag(:script, "alert(1)", nonce: false) %>')).toBe(
256+
'<script nonce="false">alert(1)</script>'
257+
)
258+
})
235259
})
236260

237261
describe("nested helpers", () => {
@@ -430,6 +454,38 @@ describe("ActionViewTagHelperToHTMLRewriter", () => {
430454
`<script type="application/javascript">alert('Hello')</script>`
431455
)
432456
})
457+
458+
test("javascript_tag with nonce true resolves to content_security_policy_nonce", () => {
459+
const input = dedent`
460+
<%= javascript_tag nonce: true do %>
461+
alert('Hello')
462+
<% end %>
463+
`
464+
465+
const expected = dedent`
466+
<script nonce="<%= content_security_policy_nonce %>">
467+
alert('Hello')
468+
</script>
469+
`
470+
471+
expect(transform(input)).toBe(expected)
472+
})
473+
474+
test("javascript_tag with nonce false omits nonce attribute", () => {
475+
const input = dedent`
476+
<%= javascript_tag nonce: false do %>
477+
alert('Hello')
478+
<% end %>
479+
`
480+
481+
const expected = dedent`
482+
<script>
483+
alert('Hello')
484+
</script>
485+
`
486+
487+
expect(transform(input)).toBe(expected)
488+
})
433489
})
434490

435491
describe("javascript_include_tag helpers", () => {
@@ -454,15 +510,15 @@ describe("ActionViewTagHelperToHTMLRewriter", () => {
454510
)
455511
})
456512

457-
test("javascript_include_tag with nonce", () => {
513+
test("javascript_include_tag with nonce true resolves to content_security_policy_nonce", () => {
458514
expect(transform(`<%= javascript_include_tag "application", nonce: true %>`)).toBe(
459-
`<script src="<%= javascript_path("application") %>" nonce="true"></script>`
515+
`<script src="<%= javascript_path("application") %>" nonce="<%= content_security_policy_nonce %>"></script>`
460516
)
461517
})
462518

463-
test("javascript_include_tag with nonce false", () => {
519+
test("javascript_include_tag with nonce false omits nonce attribute", () => {
464520
expect(transform(`<%= javascript_include_tag "application", nonce: false %>`)).toBe(
465-
`<script src="<%= javascript_path("application") %>" nonce="false"></script>`
521+
`<script src="<%= javascript_path("application") %>"></script>`
466522
)
467523
})
468524

@@ -504,7 +560,7 @@ describe("ActionViewTagHelperToHTMLRewriter", () => {
504560

505561
test("javascript_include_tag with URL and nonce", () => {
506562
expect(transform(`<%= javascript_include_tag "http://www.example.com/xmlhr.js", nonce: true %>`)).toBe(
507-
`<script src="http://www.example.com/xmlhr.js" nonce="true"></script>`
563+
`<script src="http://www.example.com/xmlhr.js" nonce="<%= content_security_policy_nonce %>"></script>`
508564
)
509565
})
510566

src/analyze/action_view/attribute_extraction_helpers.c

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,58 @@ static AST_HTML_ATTRIBUTE_NODE_T* create_attribute_from_value(
254254
}
255255
}
256256

257+
static const char* get_attribute_name_string(AST_HTML_ATTRIBUTE_NODE_T* attribute) {
258+
if (!attribute || !attribute->name || !attribute->name->children) { return NULL; }
259+
if (hb_array_size(attribute->name->children) == 0) { return NULL; }
260+
261+
AST_NODE_T* first_child = (AST_NODE_T*) hb_array_get(attribute->name->children, 0);
262+
if (!first_child || first_child->type != AST_LITERAL_NODE) { return NULL; }
263+
264+
AST_LITERAL_NODE_T* literal = (AST_LITERAL_NODE_T*) first_child;
265+
return (const char*) literal->content.data;
266+
}
267+
268+
void resolve_nonce_attribute(hb_array_T* attributes, hb_allocator_T* allocator) {
269+
if (!attributes) { return; }
270+
271+
for (size_t index = 0; index < hb_array_size(attributes); index++) {
272+
AST_NODE_T* node = hb_array_get(attributes, index);
273+
if (!node || node->type != AST_HTML_ATTRIBUTE_NODE) { continue; }
274+
275+
AST_HTML_ATTRIBUTE_NODE_T* attribute = (AST_HTML_ATTRIBUTE_NODE_T*) node;
276+
const char* name = get_attribute_name_string(attribute);
277+
if (!name || strcmp(name, "nonce") != 0) { continue; }
278+
279+
if (!attribute->value || !attribute->value->children) { continue; }
280+
if (hb_array_size(attribute->value->children) == 0) { continue; }
281+
282+
AST_NODE_T* value_child = (AST_NODE_T*) hb_array_get(attribute->value->children, 0);
283+
if (!value_child || value_child->type != AST_LITERAL_NODE) { continue; }
284+
285+
AST_LITERAL_NODE_T* literal = (AST_LITERAL_NODE_T*) value_child;
286+
287+
if (hb_string_equals(literal->content, hb_string("true"))) {
288+
AST_RUBY_LITERAL_NODE_T* ruby_node = ast_ruby_literal_node_init(
289+
hb_string_from_c_string("content_security_policy_nonce"),
290+
attribute->value->base.location.start,
291+
attribute->value->base.location.end,
292+
hb_array_init(0, allocator),
293+
allocator
294+
);
295+
296+
hb_array_T* new_children = hb_array_init(1, allocator);
297+
hb_array_append(new_children, (AST_NODE_T*) ruby_node);
298+
attribute->value->children = new_children;
299+
return;
300+
}
301+
302+
if (hb_string_equals(literal->content, hb_string("false"))) {
303+
hb_array_remove(attributes, index);
304+
return;
305+
}
306+
}
307+
}
308+
257309
AST_HTML_ATTRIBUTE_NODE_T* extract_html_attribute_from_assoc(
258310
pm_assoc_node_t* assoc,
259311
const uint8_t* source,

src/analyze/action_view/tag_helpers.c

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,11 @@ static AST_NODE_T* transform_tag_helper_with_attributes(
269269
);
270270
}
271271

272+
if (attributes && handler->name
273+
&& (strcmp(handler->name, "javascript_include_tag") == 0 || strcmp(handler->name, "javascript_tag") == 0)) {
274+
resolve_nonce_attribute(attributes, allocator);
275+
}
276+
272277
char* helper_content = NULL;
273278
bool content_is_ruby_expression = false;
274279

@@ -604,6 +609,8 @@ static hb_array_T* transform_javascript_include_tag_multi_source(
604609
);
605610
if (!shared_attributes) { shared_attributes = hb_array_init(0, allocator); }
606611

612+
resolve_nonce_attribute(shared_attributes, allocator);
613+
607614
hb_array_T* elements = hb_array_init(source_count * 2, allocator);
608615

609616
for (size_t i = 0; i < source_count; i++) {
@@ -698,6 +705,12 @@ static AST_NODE_T* transform_erb_block_to_tag_helper(
698705
);
699706
}
700707

708+
if (attributes && parse_context->matched_handler && parse_context->matched_handler->name
709+
&& (strcmp(parse_context->matched_handler->name, "javascript_include_tag") == 0
710+
|| strcmp(parse_context->matched_handler->name, "javascript_tag") == 0)) {
711+
resolve_nonce_attribute(attributes, allocator);
712+
}
713+
701714
if (detect_link_to(parse_context->info->call_node, &parse_context->parser)
702715
&& parse_context->info->call_node->arguments && parse_context->info->call_node->arguments->arguments.size >= 1) {
703716
pm_node_t* first_argument = parse_context->info->call_node->arguments->arguments.nodes[0];

src/include/analyze/action_view/attribute_extraction_helpers.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ hb_array_T* extract_html_attributes_from_keyword_hash(
2323
hb_allocator_T* allocator
2424
);
2525

26+
void resolve_nonce_attribute(hb_array_T* attributes, hb_allocator_T* allocator);
27+
2628
bool has_html_attributes_in_call(pm_call_node_t* call_node);
2729

2830
hb_array_T* extract_html_attributes_from_call_node(

test/analyze/action_view/asset_tag_helper/javascript_include_tag_test.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class JavaScriptIncludeTagTest < Minitest::Spec
2424
HTML
2525
end
2626

27-
test "javascript_include_tag with nonce" do
27+
test "javascript_include_tag with nonce true" do
2828
assert_parsed_snapshot(<<~HTML, action_view_helpers: true)
2929
<%= javascript_include_tag "application", nonce: true %>
3030
HTML
@@ -113,5 +113,11 @@ class JavaScriptIncludeTagTest < Minitest::Spec
113113
<%= javascript_include_tag "application", nonce: "static-#{dynamic}" %>
114114
HTML
115115
end
116+
117+
test "javascript_include_tag with nonce false" do
118+
assert_parsed_snapshot(<<~HTML, action_view_helpers: true)
119+
<%= javascript_include_tag "application", nonce: false %>
120+
HTML
121+
end
116122
end
117123
end

test/analyze/action_view/javascript_helper/javascript_tag_test.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ class JavaScriptTagTest < Minitest::Spec
2626
HTML
2727
end
2828

29-
test "javascript_tag with nonce" do
29+
test "javascript_tag with nonce true" do
3030
assert_parsed_snapshot(<<~HTML, action_view_helpers: true)
3131
<%= javascript_tag nonce: true do %>
3232
alert('Hello')
@@ -135,5 +135,13 @@ class JavaScriptTagTest < Minitest::Spec
135135
assert_parsed_snapshot(template, action_view_helpers: true)
136136
assert_parsed_snapshot(template)
137137
end
138+
139+
test "javascript_tag with nonce false" do
140+
assert_parsed_snapshot(<<~HTML, action_view_helpers: true)
141+
<%= javascript_tag nonce: false do %>
142+
alert('Hello')
143+
<% end %>
144+
HTML
145+
end
138146
end
139147
end

0 commit comments

Comments
 (0)