Skip to content

Commit 9adb339

Browse files
authored
Linter: Implement erb-safety family of linter rules (#1330)
This pull request implements the full set of ERB safety linter rules, covering all checks from [better-html](https://github.com/Shopify/better-html) `SafeErb` test helpers and [erb_lint](https://github.com/Shopify/erb_lint) `ErbSafety` linter. This now also brings the linter in-sync with the `SecurityValidator` the `Herb::Engine` has been enforcing. Resolves #546 Resolves #1058 Resolves #1251 #### New Rules | Rule | Description | |----------------------------------------|----------------------------------------------------------------------------------------------------| | `erb-no-statement-in-script` | Avoid `<% %>` tags inside `<script>`. Use `<%= %>` to interpolate values into JavaScript. | | `erb-no-javascript-tag-helper` | Avoid `javascript_tag`. Use inline `<script>` tags instead. | | `erb-no-unsafe-script-interpolation` | ERB output in `<script>` tags must use `.to_json` to safely serialize values. | | `erb-no-raw-output-in-attribute-value` | Avoid `<%==` in attribute values. Use `<%= %>` instead. | | `erb-no-unsafe-raw` | Avoid `raw()` and `.html_safe` in ERB output (skips raw-text elements like `<script>`, `<style>`). | | `erb-no-unsafe-js-attribute` | ERB output in `on*` attributes must use `.to_json`, `j()`, or `escape_javascript()`. | | `erb-no-output-in-attribute-position` | Avoid `<%= %>` in attribute position. Use `<% if ... %>` with static attributes instead. | | `erb-no-output-in-attribute-name` | Avoid ERB output in attribute names. Use static attribute names with dynamic values instead. | #### Mapping to better-html / erb_lint | better-html checker | Our rule(s) | |-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `AllowedScriptType` | `html-allowed-script-type` (pre-existing) | | `NoStatements` | `erb-no-statement-in-script` | | `NoJavascriptTagHelper` | `erb-no-javascript-tag-helper` | | `ScriptInterpolation` | `erb-no-unsafe-script-interpolation` | | `TagInterpolation` | `erb-no-raw-output-in-attribute-value`, `erb-no-unsafe-raw`, `erb-no-unsafe-js-attribute`, `erb-no-output-in-attribute-position`, `erb-no-output-in-attribute-name` |
1 parent 301f934 commit 9adb339

28 files changed

+1564
-8
lines changed

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,28 @@ This page contains documentation for all Herb Linter rules.
66

77
- [`erb-comment-syntax`](./erb-comment-syntax.md) - Disallow Ruby comments immediately after ERB tags
88
- [`erb-no-case-node-children`](./erb-no-case-node-children.md) - Don't use `children` for `case/when` and `case/in` nodes
9-
- [`erb-no-inline-case-conditions`](./erb-no-inline-case-conditions.md) - Disallow inline `case`/`when` and `case`/`in` conditions in a single ERB tag
109
- [`erb-no-conditional-html-element`](./erb-no-conditional-html-element.md) - Disallow conditional HTML elements
1110
- [`erb-no-duplicate-branch-elements`](./erb-no-duplicate-branch-elements.md) - Disallow duplicate elements across conditional branches
1211
- [`erb-no-empty-tags`](./erb-no-empty-tags.md) - Disallow empty ERB tags
13-
- [`erb-no-interpolated-class-names`](./erb-no-interpolated-class-names.md) - Disallow ERB interpolation inside CSS class names
1412
- [`erb-no-extra-newline`](./erb-no-extra-newline.md) - Disallow extra newlines.
1513
- [`erb-no-extra-whitespace-inside-tags`](./erb-no-extra-whitespace-inside-tags.md) - Disallow multiple consecutive spaces inside ERB tags
14+
- [`erb-no-inline-case-conditions`](./erb-no-inline-case-conditions.md) - Disallow inline `case`/`when` and `case`/`in` conditions in a single ERB tag
15+
- [`erb-no-interpolated-class-names`](./erb-no-interpolated-class-names.md) - Disallow ERB interpolation inside CSS class names
16+
- [`erb-no-javascript-tag-helper`](./erb-no-javascript-tag-helper.md) - Disallow `javascript_tag` helper
1617
- [`erb-no-output-control-flow`](./erb-no-output-control-flow.md) - Prevents outputting control flow blocks
17-
- [`erb-no-then-in-control-flow`](./erb-no-then-in-control-flow.md) - Disallow `then` in ERB control flow expressions
18+
- [`erb-no-output-in-attribute-name`](./erb-no-output-in-attribute-name.md) - Disallow ERB output in attribute names
19+
- [`erb-no-output-in-attribute-position`](./erb-no-output-in-attribute-position.md) - Disallow ERB output in attribute position
20+
- [`erb-no-raw-output-in-attribute-value`](./erb-no-raw-output-in-attribute-value.md) - Disallow `<%==` in attribute values
1821
- [`erb-no-silent-tag-in-attribute-name`](./erb-no-silent-tag-in-attribute-name.md) - Disallow ERB silent tags in HTML attribute names
22+
- [`erb-no-statement-in-script`](./erb-no-statement-in-script.md) - Disallow ERB statements inside `<script>` tags
23+
- [`erb-no-then-in-control-flow`](./erb-no-then-in-control-flow.md) - Disallow `then` in ERB control flow expressions
1924
- [`erb-no-trailing-whitespace`](./erb-no-trailing-whitespace.md) - Disallow trailing whitespace at end of lines.
25+
- [`erb-no-unsafe-js-attribute`](./erb-no-unsafe-js-attribute.md) - Disallow unsafe ERB output in JavaScript attributes
26+
- [`erb-no-unsafe-raw`](./erb-no-unsafe-raw.md) - Disallow `raw()` and `.html_safe` in ERB output
27+
- [`erb-no-unsafe-script-interpolation`](./erb-no-unsafe-script-interpolation.md) - Disallow unsafe ERB output inside `<script>` tags
2028
- [`erb-prefer-image-tag-helper`](./erb-prefer-image-tag-helper.md) - Prefer `image_tag` helper over `<img>` with ERB expressions
21-
- [`erb-require-whitespace-inside-tags`](./erb-require-whitespace-inside-tags.md) - Requires whitespace around ERB tags
2229
- [`erb-require-trailing-newline`](./erb-require-trailing-newline.md) - Enforces that all HTML+ERB template files end with exactly one trailing newline character.
30+
- [`erb-require-whitespace-inside-tags`](./erb-require-whitespace-inside-tags.md) - Requires whitespace around ERB tags
2331
- [`erb-right-trim`](./erb-right-trim.md) - Enforce consistent right-trimming syntax.
2432
- [`erb-strict-locals-comment-syntax`](./erb-strict-locals-comment-syntax.md) - Enforce strict locals comment syntax.
2533
- [`herb-disable-comment-malformed`](./herb-disable-comment-malformed.md) - Detect malformed `herb:disable` comments.
@@ -43,8 +51,8 @@ This page contains documentation for all Herb Linter rules.
4351
- [`html-boolean-attributes-no-value`](./html-boolean-attributes-no-value.md) - Prevents values on boolean attributes
4452
- [`html-head-only-elements`](./html-head-only-elements.md) - Require head-scoped elements inside `<head>`.
4553
- [`html-iframe-has-title`](./html-iframe-has-title.md) - `iframe` elements must have a `title` attribute
46-
- [`html-input-require-autocomplete`](./html-input-require-autocomplete.md) - Require `autocomplete` attributes on `<input>` tags.
4754
- [`html-img-require-alt`](./html-img-require-alt.md) - Requires `alt` attributes on `<img>` tags
55+
- [`html-input-require-autocomplete`](./html-input-require-autocomplete.md) - Require `autocomplete` attributes on `<input>` tags.
4856
- [`html-navigation-has-label`](./html-navigation-has-label.md) - Navigation landmarks must have accessible labels
4957
- [`html-no-abstract-roles`](./html-no-abstract-roles.md) - No abstract ARIA roles
5058
- [`html-no-aria-hidden-on-body`](./html-no-aria-hidden-on-body.md) - No `aria-hidden` on `<body>`
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Linter Rule: Disallow `javascript_tag` helper
2+
3+
**Rule:** `erb-no-javascript-tag-helper`
4+
5+
## Description
6+
7+
The `javascript_tag do` helper syntax is deprecated. Use inline `<script>` tags instead, which allows the linter to properly analyze ERB output within JavaScript.
8+
9+
## Rationale
10+
11+
The `javascript_tag` helper renders its block as raw text, which means unsafe ERB interpolation inside it cannot be detected by other safety rules like `erb-no-unsafe-script-interpolation` or `erb-no-statement-in-script`. By using inline `<script>` tags instead, the linter can properly parse and validate that Ruby data is safely serialized with `.to_json` before being interpolated into JavaScript.
12+
13+
## Examples
14+
15+
### ✅ Good
16+
17+
```erb
18+
<script>
19+
if (a < 1) { alert("hello") }
20+
</script>
21+
```
22+
23+
### 🚫 Bad
24+
25+
```erb
26+
<%= javascript_tag do %>
27+
if (a < 1) { <%= unsafe %> }
28+
<% end %>
29+
```
30+
31+
## References
32+
33+
- [Shopify/better-html — `NoJavascriptTagHelper`](https://github.com/Shopify/better-html/blob/main/lib/better_html/test_helper/safe_erb/no_javascript_tag_helper.rb)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Linter Rule: Disallow ERB output in attribute names
2+
3+
**Rule:** `erb-no-output-in-attribute-name`
4+
5+
## Description
6+
7+
ERB output tags (`<%= %>`) are not allowed in HTML attribute names. Use static attribute names with dynamic values instead.
8+
9+
## Rationale
10+
11+
ERB output in attribute names (e.g., `<div data-<%= key %>="value">`) allows dynamic control over which attributes are rendered. When such a value is user-controlled, an attacker can inject arbitrary attributes including JavaScript event handlers, achieving cross-site scripting (XSS).
12+
13+
## Examples
14+
15+
### Good
16+
17+
```erb
18+
<div class="<%= css_class %>"></div>
19+
```
20+
21+
```erb
22+
<input type="text" data-target="value">
23+
```
24+
25+
### Bad
26+
27+
```erb
28+
<div data-<%= key %>="value"></div>
29+
```
30+
31+
```erb
32+
<div data-<%= key1 %>="value1" data-<%= key2 %>="value2"></div>
33+
```
34+
35+
## References
36+
37+
- [Shopify/erb_lint: `ErbSafety`](https://github.com/Shopify/erb_lint/tree/main?tab=readme-ov-file#erbsafety)
38+
- [Shopify/better_html](https://github.com/Shopify/better_html)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Linter Rule: Disallow ERB output in attribute position
2+
3+
**Rule:** `erb-no-output-in-attribute-position`
4+
5+
## Description
6+
7+
ERB output tags (`<%= %>` or `<%== %>`) are not allowed in attribute position. Use ERB control flow (`<% %>`) with static attribute names instead.
8+
9+
## Rationale
10+
11+
ERB output tags in attribute positions (e.g., `<div <%= attributes %>>`) allow arbitrary attribute injection at runtime. An attacker could inject event handler attributes like `onmouseover` or `onfocus` to execute JavaScript.
12+
13+
For example, a common pattern like:
14+
15+
```erb
16+
<div <%= "hidden" if index != 0 %>>...</div>
17+
```
18+
19+
should be rewritten to use control flow with static attributes:
20+
21+
```erb
22+
<div <% if index != 0 %> hidden <% end %>>...</div>
23+
```
24+
25+
This ensures attribute names are always statically defined and prevents arbitrary attribute injection.
26+
27+
## Examples
28+
29+
### Good
30+
31+
```erb
32+
<div class="<%= css_class %>"></div>
33+
```
34+
35+
```erb
36+
<input value="<%= user.name %>">
37+
```
38+
39+
```erb
40+
<div <% if active? %> class="active" <% end %>></div>
41+
```
42+
43+
### Bad
44+
45+
```erb
46+
<div <%= data_attributes %>></div>
47+
```
48+
49+
```erb
50+
<div <%== raw_attributes %>></div>
51+
```
52+
53+
```erb
54+
<div <%= first_attrs %> <%= second_attrs %>></div>
55+
```
56+
57+
## References
58+
59+
- [Shopify/erb_lint: `ErbSafety`](https://github.com/Shopify/erb_lint/tree/main?tab=readme-ov-file#erbsafety)
60+
- [Shopify/better_html](https://github.com/Shopify/better_html)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Linter Rule: Disallow `<%==` in attribute values
2+
3+
**Rule:** `erb-no-raw-output-in-attribute-value`
4+
5+
## Description
6+
7+
ERB interpolation with `<%==` inside HTML attribute values is never safe. The `<%==` syntax bypasses HTML escaping entirely, allowing arbitrary attribute injection and XSS attacks. Use `<%=` instead to ensure proper escaping.
8+
9+
## Rationale
10+
11+
The `<%==` syntax outputs content without any HTML escaping. In an attribute value context, this means an attacker can inject a quote character to terminate the attribute, then inject arbitrary attributes including JavaScript event handlers. Even when combined with `.to_json`, using `<%==` in attributes is unsafe because it bypasses the template engine's built-in escaping that prevents attribute breakout.
12+
13+
## Examples
14+
15+
### ✅ Good
16+
17+
```erb
18+
<div class="<%= user_input %>"></div>
19+
```
20+
21+
### 🚫 Bad
22+
23+
```erb
24+
<div class="<%== user_input %>"></div>
25+
```
26+
27+
```erb
28+
<a href="<%== unsafe %>">Link</a>
29+
```
30+
31+
```erb
32+
<a onclick="method(<%== unsafe.to_json %>)"></a>
33+
```
34+
35+
## References
36+
37+
- [Shopify/better-html — `TagInterpolation`](https://github.com/Shopify/better-html/blob/main/lib/better_html/test_helper/safe_erb/tag_interpolation.rb)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Linter Rule: Disallow ERB statements inside `<script>` tags
2+
3+
**Rule:** `erb-no-statement-in-script`
4+
5+
## Description
6+
7+
Only insert expressions (`<%=` or `<%==`) inside `<script>` tags, never statements (`<% %>`). Statement tags inside `<script>` are likely a mistake, the author probably meant to use `<%= %>` to output a value.
8+
9+
## Rationale
10+
11+
ERB statement tags inside `<script>` tags execute Ruby code but produce no output into the JavaScript context, which is rarely intentional. If you need to interpolate a value into JavaScript, use expression tags (`<%= %>`) with `.to_json` for safe serialization. If you need conditional logic, restructure the template to keep control flow outside the `<script>` tag.
12+
13+
Exceptions: `<% end %>` is allowed (for closing blocks), ERB comments (`<%# %>`) are allowed, and `<script type="text/html">` allows statement tags since it contains HTML templates, not JavaScript.
14+
15+
## Examples
16+
17+
### ✅ Good
18+
19+
```erb
20+
<script>
21+
var myValue = <%== value.to_json %>;
22+
if (myValue) doSomething();
23+
</script>
24+
```
25+
26+
```erb
27+
<script type="text/template">
28+
<%= ui_form do %>
29+
<div></div>
30+
<% end %>
31+
</script>
32+
```
33+
34+
```erb
35+
<script type="text/javascript">
36+
<%# comment %>
37+
</script>
38+
```
39+
40+
```erb
41+
<script type="text/html">
42+
<% if condition %>
43+
<p>Content</p>
44+
<% end %>
45+
</script>
46+
```
47+
48+
### 🚫 Bad
49+
50+
```erb
51+
<script>
52+
<% if value %>
53+
doSomething();
54+
<% end %>
55+
</script>
56+
```
57+
58+
```erb
59+
<script type="text/javascript">
60+
<% if foo? %>
61+
bla
62+
<% end %>
63+
</script>
64+
```
65+
66+
## References
67+
68+
- [Shopify/better-html - `NoStatements`](https://github.com/Shopify/better-html/blob/main/lib/better_html/test_helper/safe_erb/no_statements.rb)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Linter Rule: Disallow unsafe ERB output in JavaScript attributes
2+
3+
**Rule:** `erb-no-unsafe-js-attribute`
4+
5+
## Description
6+
7+
ERB interpolation in JavaScript event handler attributes (`onclick`, `onmouseover`, etc.) must be wrapped in a safe helper such as `.to_json`, `j()`, or `escape_javascript()`. Without proper encoding, user-controlled values can break out of string literals and execute arbitrary JavaScript.
8+
9+
## Rationale
10+
11+
HTML attributes that start with `on` (like `onclick`, `onmouseover`, `onfocus`) are evaluated as JavaScript by the browser. When ERB output is interpolated into these attributes without proper encoding, it creates a JavaScript injection vector. The `.to_json` method properly serializes Ruby values into safe JavaScript literals, while `j()` and `escape_javascript()` escape values for safe embedding in JavaScript string contexts.
12+
13+
## Examples
14+
15+
### ✅ Good
16+
17+
```erb
18+
<a onclick="method(<%= unsafe.to_json %>)"></a>
19+
```
20+
21+
```erb
22+
<a onclick="method('<%= j(unsafe) %>')"></a>
23+
```
24+
25+
```erb
26+
<a onclick="method(<%= escape_javascript(unsafe) %>)"></a>
27+
```
28+
29+
### 🚫 Bad
30+
31+
```erb
32+
<a onclick="method(<%= unsafe %>)"></a>
33+
```
34+
35+
```erb
36+
<div onmouseover="highlight('<%= element_id %>')"></div>
37+
```
38+
39+
## References
40+
41+
- [Shopify/better-html — TagInterpolation](https://github.com/Shopify/better-html/blob/main/lib/better_html/test_helper/safe_erb/tag_interpolation.rb)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Linter Rule: Disallow `raw()` and `.html_safe` in ERB output
2+
3+
**Rule:** `erb-no-unsafe-raw`
4+
5+
## Description
6+
7+
Disallow the use of `raw()` and `.html_safe` in ERB output tags. These methods bypass Rails' automatic HTML escaping, which is the primary defense against cross-site scripting (XSS) vulnerabilities.
8+
9+
## Rationale
10+
11+
Rails automatically escapes ERB output to prevent XSS. Using `raw()` or `.html_safe` disables this protection, allowing arbitrary HTML and JavaScript injection. Even when combined with other safe methods like `.to_json`, using `raw()` or `.html_safe` is still unsafe because the escaping bypass applies to the final output.
12+
13+
For example, `<%= raw unsafe.to_json %>` is flagged because `raw()` disables escaping on the entire expression, even though `.to_json` serializes the value safely. The `raw()` wrapper means any future changes to the expression could silently introduce a vulnerability.
14+
15+
## Examples
16+
17+
### ✅ Good
18+
19+
```erb
20+
<div class="<%= user_input %>"></div>
21+
```
22+
23+
```erb
24+
<p><%= user_input %></p>
25+
```
26+
27+
### 🚫 Bad
28+
29+
```erb
30+
<div class="<%= raw(user_input) %>"></div>
31+
```
32+
33+
```erb
34+
<div class="<%= user_input.html_safe %>"></div>
35+
```
36+
37+
```erb
38+
<p><%= raw(user_input) %></p>
39+
```
40+
41+
```erb
42+
<p><%= user_input.html_safe %></p>
43+
```
44+
45+
## References
46+
47+
- [Shopify/better-html — TagInterpolation](https://github.com/Shopify/better-html/blob/main/lib/better_html/test_helper/safe_erb/tag_interpolation.rb)

0 commit comments

Comments
 (0)