Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions javascript/packages/linter/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ This page contains documentation for all Herb Linter rules.
- [`actionview-no-helper-shadowing`](./actionview-no-helper-shadowing.md) - Disallow shadowing Action View helpers with block variables
- [`actionview-no-silent-helper`](./actionview-no-silent-helper.md) - Disallow silent ERB tags for Action View helpers
- [`actionview-no-silent-render`](./actionview-no-silent-render.md) - Disallow calling `render` without outputting the result
- [`actionview-no-unnecessary-html-safe`](./actionview-no-unnecessary-html-safe.md) - Disallow calling `.html_safe` on String literals
- [`actionview-no-unnecessary-tag-attributes`](./actionview-no-unnecessary-tag-attributes.md) - Disallow unnecessary attributes on Action View tag helpers
- [`actionview-no-void-element-content`](./actionview-no-void-element-content.md) - Disallow content arguments for void Action View elements
- [`actionview-prefer-collection-render`](./actionview-prefer-collection-render.md) - Prefer collection rendering over rendering a partial in a loop
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Linter Rule: Disallow calling `.html_safe` on String literals

**Rule:** `actionview-no-unnecessary-html-safe`

## Description

Disallow ERB output tags that consist of nothing but a String literal with `.html_safe` called on it, like `<%= "<strong>Sale</strong>".html_safe %>`.

## Rationale

Calling `.html_safe` on a String literal only tells Action View to skip escaping content that is already spelled out in the template. Writing that content directly produces byte-for-byte the same output, without the ERB tag, the String allocation and the `ActiveSupport::SafeBuffer` wrapper.

Because the content is static, `.html_safe` is not protecting anything either. There is no dynamic value involved that escaping could ever apply to, so the call is pure overhead. It does make the template look like it deliberately opts out of Rails' escaping, which makes the `.html_safe` calls that *are* worth reviewing harder to spot.

Note that dropping just the `.html_safe` call is not equivalent, since Action View escapes the remaining literal: `<%= "<strong>Sale</strong>" %>` renders as `&lt;strong&gt;Sale&lt;/strong&gt;`. The content has to move out of the ERB tag for the output to stay the same.

## Examples

### ✅ Good

```erb
<div style="display: none;"></div>
```

```erb
<p><strong>Sale</strong></p>
```

```erb
&copy; 2026
```

### 🚫 Bad

```erb
<div <%= 'style="display: none;"'.html_safe %>></div>
```

```erb
<p><%= "<strong>Sale</strong>".html_safe %></p>
```

```erb
<%= "&copy; 2026".html_safe %>
```

## References

* [Rails `String#html_safe` API](https://api.rubyonrails.org/classes/String.html#method-i-html_safe)
* [Rails `ActiveSupport::SafeBuffer` API](https://api.rubyonrails.org/classes/ActiveSupport/SafeBuffer.html)
* [Rails Security Guide: Cross-Site Scripting (XSS)](https://guides.rubyonrails.org/security.html#cross-site-scripting-xss)
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { A11ySVGHasAccessibleTextRule } from "./rules/a11y-svg-has-accessible-te
import { ActionViewNoHelperShadowingRule } from "./rules/actionview-no-helper-shadowing.js"
import { ActionViewNoSilentHelperRule } from "./rules/actionview-no-silent-helper.js"
import { ActionViewNoSilentRenderRule } from "./rules/actionview-no-silent-render.js"
import { ActionViewNoUnnecessaryHTMLSafeRule } from "./rules/actionview-no-unnecessary-html-safe.js"
import { ActionViewNoUnnecessaryTagAttributesRule } from "./rules/actionview-no-unnecessary-tag-attributes.js"
import { ActionViewNoVoidElementContentRule } from "./rules/actionview-no-void-element-content.js"
import { ActionViewPreferCollectionRenderRule } from "./rules/actionview-prefer-collection-render.js"
Expand Down Expand Up @@ -132,6 +133,7 @@ export const rules: RuleClass[] = [
ActionViewNoHelperShadowingRule,
ActionViewNoSilentHelperRule,
ActionViewNoSilentRenderRule,
ActionViewNoUnnecessaryHTMLSafeRule,
ActionViewNoUnnecessaryTagAttributesRule,
ActionViewNoVoidElementContentRule,
ActionViewPreferCollectionRenderRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { ParserRule, BaseAutofixContext, Mutable } from "../types.js"
import { BaseRuleVisitor } from "./rule-utils.js"

import { ERBStringToDirectOutputRewriter } from "@herb-tools/rewriter"

import { isERBOutputNode, isPrismNodeType, createLiteral, findParentArray, locationFromByteOffset, substringFromByteOffset } from "@herb-tools/core"

import type { ParseResult, ERBContentNode, ParserOptions, PrismNode, Node } from "@herb-tools/core"
import type { UnboundLintOffense, LintOffense, LintContext, FullRuleConfig } from "../types.js"

interface UnnecessaryHTMLSafeAutofixContext extends BaseAutofixContext {
node: Mutable<ERBContentNode>
content: string
}

function stringLiteralReceiver(prismNode: PrismNode): PrismNode | null {
if (!isPrismNodeType(prismNode, "CallNode")) return null
if (prismNode.name !== "html_safe") return null
if (prismNode.block) return null
if (prismNode.arguments_) return null

const receiver = prismNode.receiver

if (!receiver) return null
if (!isPrismNodeType(receiver, "StringNode")) return null

return receiver
}

class ActionViewNoUnnecessaryHTMLSafeVisitor extends BaseRuleVisitor<UnnecessaryHTMLSafeAutofixContext> {
visitERBContentNode(node: ERBContentNode): void {
this.checkUnnecessaryHTMLSafe(node)

super.visitERBContentNode(node)
}

private checkUnnecessaryHTMLSafe(node: ERBContentNode): void {
if (!isERBOutputNode(node)) return

const prismNode = node.prismNode
if (!prismNode) return

const source = node.source
if (!source) return

const receiver = stringLiteralReceiver(prismNode)
if (!receiver) return

const { startOffset, length } = prismNode.location
const literal = substringFromByteOffset(source, receiver.location.startOffset, receiver.location.length)
const content = ERBStringToDirectOutputRewriter.extractStringContent(receiver, source)

const autofixContext = content.includes("<%")
? undefined
: { node: node as Mutable<ERBContentNode>, content }

this.addOffense(
`Avoid calling \`.html_safe\` on the String literal \`${literal}\`. Write the content directly in the template instead.`,
locationFromByteOffset(source, startOffset, length),
autofixContext,
undefined,
["unnecessary"],
)
}
}

export class ActionViewNoUnnecessaryHTMLSafeRule extends ParserRule<UnnecessaryHTMLSafeAutofixContext> {
static ruleName = "actionview-no-unnecessary-html-safe"
static introducedIn = this.version("unreleased")
static autocorrectable = true

get defaultConfig(): FullRuleConfig {
return {
enabled: true,
severity: "error",
}
}

get parserOptions(): Partial<ParserOptions> {
return {
prism_nodes: true,
}
}

check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense<UnnecessaryHTMLSafeAutofixContext>[] {
const visitor = new ActionViewNoUnnecessaryHTMLSafeVisitor(this.ruleName, context)

visitor.visit(result.value)

return visitor.offenses
}

autofix(offense: LintOffense<UnnecessaryHTMLSafeAutofixContext>, result: ParseResult): ParseResult | null {
if (!offense.autofixContext) return null

const { node, content } = offense.autofixContext
const erbNode = node as unknown as ERBContentNode
const parentInfo = findParentArray(result.value, erbNode)

if (!parentInfo) return null

const { array: parentArray, index: nodeIndex } = parentInfo
const replacementNodes: Node[] = [createLiteral(content)]

parentArray.splice(nodeIndex, 1, ...replacementNodes)

return result
}
}
1 change: 1 addition & 0 deletions javascript/packages/linter/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export * from "./herb-disable-comment-base.js"
export * from "./actionview-no-helper-shadowing.js"
export * from "./actionview-no-silent-helper.js"
export * from "./actionview-no-silent-render.js"
export * from "./actionview-no-unnecessary-html-safe.js"
export * from "./actionview-no-unnecessary-tag-attributes.js"
export * from "./actionview-no-void-element-content.js"
export * from "./actionview-prefer-collection-render.js"
Expand Down
Loading
Loading