Skip to content

Commit dc3ee28

Browse files
authored
Linter: Warn about j() in erb-no-unsafe-script-interpolation (#1407)
This pull request addresses the comment raised by @frederikspang in #1330 (comment) by adding a specific message to the `erb-no-unsafe-script-interpolation` linter rule to warn about the `j()` and `escape_javascript()` usage inside `<script>` tags. It also updates the linter rule to base the checks off of Prism nodes instead of the previous regex-based approach.
1 parent 25d6ecd commit dc3ee28

File tree

3 files changed

+162
-8
lines changed

3 files changed

+162
-8
lines changed

javascript/packages/linter/docs/rules/erb-no-unsafe-script-interpolation.md

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44

55
## Description
66

7-
ERB interpolation in `<script>` tags must call `.to_json` to safely serialize Ruby data into JavaScript. Without `.to_json`, user-controlled values can break out of string literals and execute arbitrary JavaScript.
7+
ERB interpolation in `<script>` tags must use `.to_json` to safely serialize Ruby data into JavaScript. Without `.to_json`, user-controlled values can break out of string literals and execute arbitrary JavaScript.
8+
9+
This rule also detects usage of `j()` and `escape_javascript()` inside `<script>` tags and recommends `.to_json` instead, because `j()` is only safe when the output is placed inside quoted string literals, a subtle requirement that is easy to get wrong.
810

911
## Rationale
1012

11-
The main goal of this rule is to assert that Ruby data translates into JavaScript data, but never becomes JavaScript code. ERB output inside `<script>` tags is interpolated directly into the JavaScript context. Without proper serialization via `.to_json`, an attacker can inject arbitrary JavaScript by manipulating the interpolated value.
13+
The main goal of this rule is to assert that Ruby data translates into JavaScript data, but never becomes JavaScript code. ERB output inside `<script>` tags is interpolated directly into the JavaScript context. Without proper serialization, an attacker can inject arbitrary JavaScript by manipulating the interpolated value.
1214

1315
For example, consider:
1416

@@ -18,14 +20,65 @@ For example, consider:
1820
</script>
1921
```
2022

21-
If `user.name` contains `"; alert(1); "`, the resulting JavaScript would execute arbitrary code. Using `.to_json` properly escapes the value and wraps it in quotes:
23+
If `user.name` contains `"; alert(1); "`, it renders as:
24+
25+
```html
26+
<script>
27+
var name = ""; alert(1); "";
28+
</script>
29+
```
30+
31+
This is a Cross-Site Scripting (XSS) vulnerability, as the attacker breaks out of the string literal and executes arbitrary JavaScript.
32+
33+
Using `.to_json` properly escapes the value and wraps it in quotes:
2234

2335
```erb
2436
<script>
2537
var name = <%= user.name.to_json %>;
2638
</script>
2739
```
2840

41+
With the same malicious input `"; alert(1); "`, `.to_json` safely renders:
42+
43+
```html
44+
<script>
45+
var name = "\"; alert(1); \"";
46+
</script>
47+
```
48+
49+
The value stays contained as a string, and no code is executed.
50+
51+
### Why not `j()` or `escape_javascript()`?
52+
53+
`j()` escapes characters special inside JavaScript string literals (quotes, newlines, etc.), but it does **not** produce a quoted value. This means it's only safe when wrapped in quotes.
54+
55+
This works, but is fragile. In this example safety depends on the surrounding quotes:
56+
```erb
57+
<script>
58+
var name = '<%= j user.name %>';
59+
</script>
60+
```
61+
62+
Without quotes, `j()` provides no protection and is **UNSAFE**, so code can still be injected:
63+
64+
```erb
65+
<script>
66+
var name = <%= j user.name %>;
67+
</script>
68+
```
69+
70+
If `user.name` is `alert(1)`, `j()` passes it through unchanged (no special characters to escape), rendering:
71+
72+
```html
73+
<script>
74+
var name = alert(1);
75+
</script>
76+
```
77+
78+
This results in a Cross-Site Scripting (XSS) vulnerability, as the attacker-controlled value is interpreted as JavaScript code rather than a string/data.
79+
80+
`.to_json` is safe in any position because it always produces a valid, quoted JavaScript value.
81+
2982
## Examples
3083

3184
### ✅ Good
@@ -68,6 +121,20 @@ If `user.name` contains `"; alert(1); "`, the resulting JavaScript would execute
68121
</script>
69122
```
70123

124+
### ⚠️ Prefer `.to_json` over `j()` / `escape_javascript()`
125+
126+
```diff
127+
- const url = '<%= j @my_path %>';
128+
+ const url = <%= @my_path.to_json %>;
129+
```
130+
131+
```diff
132+
- const name = '<%= escape_javascript(user.name) %>';
133+
+ const name = <%= user.name.to_json %>;
134+
```
135+
71136
## References
72137

73138
- [Shopify/better-html — ScriptInterpolation](https://github.com/Shopify/better-html/blob/main/lib/better_html/test_helper/safe_erb/script_interpolation.rb)
139+
- [`escape_javascript` / `j`](https://api.rubyonrails.org/classes/ActionView/Helpers/JavaScriptHelper.html#method-i-escape_javascript)
140+
- [`json_escape`](https://api.rubyonrails.org/classes/ERB/Util.html#method-c-json_escape)

javascript/packages/linter/src/rules/erb-no-unsafe-script-interpolation.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { ParserRule } from "../types.js"
22
import { BaseRuleVisitor } from "./rule-utils.js"
3+
import { PrismVisitor } from "@herb-tools/core"
4+
35
import {
46
getTagLocalName,
57
getAttribute,
@@ -10,9 +12,34 @@ import {
1012
} from "@herb-tools/core"
1113

1214
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
13-
import type { ParseResult, HTMLElementNode, Node } from "@herb-tools/core"
15+
import type { ParseResult, HTMLElementNode, Node, ERBContentNode, ParserOptions, PrismNode } from "@herb-tools/core"
16+
17+
const SAFE_METHOD_NAMES = new Set([
18+
"to_json",
19+
"json_escape",
20+
])
21+
22+
const ESCAPE_JAVASCRIPT_METHOD_NAMES = new Set([
23+
"j",
24+
"escape_javascript",
25+
])
26+
27+
class SafeCallDetector extends PrismVisitor {
28+
public hasSafeCall = false
29+
public hasEscapeJavascriptCall = false
30+
31+
visitCallNode(node: PrismNode): void {
32+
if (SAFE_METHOD_NAMES.has(node.name)) {
33+
this.hasSafeCall = true
34+
}
35+
36+
if (ESCAPE_JAVASCRIPT_METHOD_NAMES.has(node.name)) {
37+
this.hasEscapeJavascriptCall = true
38+
}
1439

15-
const SAFE_PATTERN = /\.to_json\b/
40+
this.visitChildNodes(node)
41+
}
42+
}
1643

1744
class ERBNoUnsafeScriptInterpolationVisitor extends BaseRuleVisitor {
1845
visitHTMLElementNode(node: HTMLElementNode): void {
@@ -35,7 +62,6 @@ class ERBNoUnsafeScriptInterpolationVisitor extends BaseRuleVisitor {
3562
const typeValue = typeAttribute ? getStaticAttributeValue(typeAttribute) : null
3663

3764
if (typeValue === "text/html") return
38-
3965
if (!node.body || node.body.length === 0) return
4066

4167
this.checkNodesForUnsafeOutput(node.body)
@@ -46,9 +72,21 @@ class ERBNoUnsafeScriptInterpolationVisitor extends BaseRuleVisitor {
4672
if (!isERBNode(child)) continue
4773
if (!isERBOutputNode(child)) continue
4874

49-
const content = child.content?.value?.trim() || ""
75+
const erbContent = child as ERBContentNode
76+
const prismNode = erbContent.prismNode
77+
const detector = new SafeCallDetector()
78+
79+
if (prismNode) detector.visit(prismNode)
80+
if (detector.hasSafeCall) continue
81+
82+
if (detector.hasEscapeJavascriptCall) {
83+
this.addOffense(
84+
"Avoid `j()` / `escape_javascript()` in `<script>` tags. It is only safe inside quoted string literals. Use `.to_json` instead, which is safe in any position.",
85+
child.location,
86+
)
5087

51-
if (SAFE_PATTERN.test(content)) continue
88+
continue
89+
}
5290

5391
this.addOffense(
5492
"Unsafe ERB output in `<script>` tag. Use `.to_json` to safely serialize values into JavaScript.",
@@ -68,9 +106,17 @@ export class ERBNoUnsafeScriptInterpolationRule extends ParserRule {
68106
}
69107
}
70108

109+
get parserOptions(): Partial<ParserOptions> {
110+
return {
111+
prism_nodes: true,
112+
}
113+
}
114+
71115
check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
72116
const visitor = new ERBNoUnsafeScriptInterpolationVisitor(this.ruleName, context)
117+
73118
visitor.visit(result.value)
119+
74120
return visitor.offenses
75121
}
76122
}

javascript/packages/linter/test/rules/erb-no-unsafe-script-interpolation.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createLinterTest } from "../helpers/linter-test-helper.js"
88
const { expectNoOffenses, expectError, assertOffenses } = createLinterTest(ERBNoUnsafeScriptInterpolationRule)
99

1010
const MESSAGE = "Unsafe ERB output in `<script>` tag. Use `.to_json` to safely serialize values into JavaScript."
11+
const ESCAPE_JS_MESSAGE = "Avoid `j()` / `escape_javascript()` in `<script>` tags. It is only safe inside quoted string literals. Use `.to_json` instead, which is safe in any position."
1112

1213
describe("ERBNoUnsafeScriptInterpolationRule", () => {
1314
describe("unsafe", () => {
@@ -50,6 +51,40 @@ describe("ERBNoUnsafeScriptInterpolationRule", () => {
5051
})
5152
})
5253

54+
describe("escape_javascript", () => {
55+
test("j() in script tag is flagged with specific message", () => {
56+
expectError(ESCAPE_JS_MESSAGE)
57+
58+
assertOffenses(dedent`
59+
<script>const url = '<%= j @poll_path %>';</script>
60+
`)
61+
})
62+
63+
test("j() with parentheses in script tag is flagged with specific message", () => {
64+
expectError(ESCAPE_JS_MESSAGE)
65+
66+
assertOffenses(dedent`
67+
<script>const url = '<%= j(@poll_path) %>';</script>
68+
`)
69+
})
70+
71+
test("escape_javascript() in script tag is flagged with specific message", () => {
72+
expectError(ESCAPE_JS_MESSAGE)
73+
74+
assertOffenses(dedent`
75+
<script>const url = '<%= escape_javascript(@poll_path) %>';</script>
76+
`)
77+
})
78+
79+
test("escape_javascript without parentheses in script tag is flagged with specific message", () => {
80+
expectError(ESCAPE_JS_MESSAGE)
81+
82+
assertOffenses(dedent`
83+
<script>const url = '<%= escape_javascript @poll_path %>';</script>
84+
`)
85+
})
86+
})
87+
5388
describe("safe", () => {
5489
test("ERB output in script tag with .to_json is allowed", () => {
5590
expectNoOffenses(dedent`
@@ -85,6 +120,12 @@ describe("ERBNoUnsafeScriptInterpolationRule", () => {
85120
`)
86121
})
87122

123+
test("json_escape() in script tag is allowed", () => {
124+
expectNoOffenses(dedent`
125+
<script>const data = '<%= json_escape(@data) %>';</script>
126+
`)
127+
})
128+
88129
test("script tag with non-executable content type is ignored", () => {
89130
expectNoOffenses(dedent`
90131
<script type="text/html">

0 commit comments

Comments
 (0)