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
3 changes: 2 additions & 1 deletion javascript/packages/linter/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ This page contains documentation for all Herb Linter rules.
- [`html-attribute-values-require-quotes`](./html-attribute-values-require-quotes.md) - Requires quotes around attribute values
- [`html-avoid-both-disabled-and-aria-disabled`](./html-avoid-both-disabled-and-aria-disabled.md) - Avoid using both `disabled` and `aria-disabled` attributes
- [`html-body-only-elements`](./html-body-only-elements.md) - Require content elements inside `<body>`.
- [`html-details-has-summary`](./html-details-has-summary.md) - Require `<summary>` in `<details>` elements
- [`html-boolean-attributes-no-value`](./html-boolean-attributes-no-value.md) - Prevents values on boolean attributes
- [`html-details-has-summary`](./html-details-has-summary.md) - Require `<summary>` in `<details>` elements
- [`html-head-only-elements`](./html-head-only-elements.md) - Require head-scoped elements inside `<head>`.
- [`html-iframe-has-title`](./html-iframe-has-title.md) - `iframe` elements must have a `title` attribute
- [`html-img-require-alt`](./html-img-require-alt.md) - Requires `alt` attributes on `<img>` tags
Expand All @@ -74,6 +74,7 @@ This page contains documentation for all Herb Linter rules.
- [`html-no-space-in-tag`](./html-no-space-in-tag.md) - Disallow spaces in HTML tags
- [`html-no-title-attribute`](./html-no-title-attribute.md) - Avoid using the `title` attribute
- [`html-no-underscores-in-attribute-names`](./html-no-underscores-in-attribute-names.md) - Disallow underscores in HTML attribute names
- [`html-require-script-nonce`](./html-require-script-nonce.md) - Require `nonce` attribute on script tags and helpers
- [`html-tag-name-lowercase`](./html-tag-name-lowercase.md) - Enforces lowercase tag names in HTML
- [`parser-no-errors`](./parser-no-errors.md) - Disallow parser errors in HTML+ERB documents
- [`svg-tag-name-capitalization`](./svg-tag-name-capitalization.md) - Enforces proper camelCase capitalization for SVG elements
Expand Down
107 changes: 107 additions & 0 deletions javascript/packages/linter/docs/rules/html-require-script-nonce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Linter Rule: Require nonce attribute on script tags and helpers

**Rule:** `html-require-script-nonce`

## Description

Require a `nonce` attribute on inline `<script>` tags, Rails JavaScript helper methods (`javascript_tag`, `javascript_include_tag`) and tag helpers (`tag.script`). This helps enforce a Content Security Policy (CSP) that mitigates cross-site scripting (XSS) attacks.

## Rationale

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.

Adding `nonce` attributes to all script tags and helpers ensures:

- Scripts are allowed by the CSP without weakening it
- Protection against XSS attacks that attempt to inject unauthorized scripts
- Consistent security practices across the codebase

## Examples

### ✅ Good

HTML script tags with a nonce:

```erb
<script nonce="<%= request.content_security_policy_nonce %>">
alert("Hello, world!")
</script>
```

```erb
<script type="text/javascript" nonce="<%= request.content_security_policy_nonce %>">
console.log("Hello")
</script>
```

Rails helpers with `nonce: true`:

```erb
<%= javascript_tag nonce: true do %>
alert("Hello, world!")
<% end %>
```

```erb
<%= javascript_include_tag "application", nonce: true %>
```

```erb
<%= tag.script nonce: true do %>
alert("Hello, world!")
<% end %>
```

Non-JavaScript script types (not flagged):

```erb
<script type="application/json">
{"key": "value"}
</script>
```

```erb
<script type="application/ld+json">
{"@context": "https://schema.org"}
</script>
```

### 🚫 Bad

HTML script tags without a nonce:

```erb
<script>
alert("Hello, world!")
</script>
```

```erb
<script type="text/javascript">
console.log("Hello")
</script>
```

Rails helpers without `nonce: true`:

```erb
<%= javascript_tag do %>
alert("Hello, world!")
<% end %>
```

```erb
<%= javascript_include_tag "application" %>
```

```erb
<%= tag.script do %>
alert("Hello, world!")
<% end %>
```

## References

- [Inspiration: ERB Lint `RequireScriptNonce` rule](https://github.com/Shopify/erb_lint/blob/main/lib/erb_lint/linters/require_script_nonce.rb)
- [MDN: Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
- [Rails: `content_security_policy_nonce`](https://api.rubyonrails.org/classes/ActionDispatch/ContentSecurityPolicy/Request.html)
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import { HTMLNoSpaceInTagRule } from "./rules/html-no-space-in-tag.js"
import { HTMLNoTitleAttributeRule } from "./rules/html-no-title-attribute.js"
import { HTMLNoUnderscoresInAttributeNamesRule } from "./rules/html-no-underscores-in-attribute-names.js"
import { HTMLRequireClosingTagsRule } from "./rules/html-require-closing-tags.js"
import { HTMLRequireScriptNonceRule } from "./rules/html-require-script-nonce.js"
import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"

import { ParserNoErrorsRule } from "./rules/parser-no-errors.js"
Expand Down Expand Up @@ -163,6 +164,7 @@ export const rules: RuleClass[] = [
HTMLNoTitleAttributeRule,
HTMLNoUnderscoresInAttributeNamesRule,
HTMLRequireClosingTagsRule,
HTMLRequireScriptNonceRule,
HTMLTagNameLowercaseRule,

ParserNoErrorsRule,
Expand Down
72 changes: 72 additions & 0 deletions javascript/packages/linter/src/rules/html-require-script-nonce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { ParserRule } from "../types.js"
import { BaseRuleVisitor } from "./rule-utils.js"
import { getTagLocalName, getAttribute, getStaticAttributeValue, hasAttributeValue, findAttributeByName, isERBOpenTagNode } from "@herb-tools/core"

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

class RequireScriptNonceVisitor extends BaseRuleVisitor {
visitHTMLElementNode(node: HTMLElementNode): void {
if (getTagLocalName(node) === "script") {
this.checkScriptNonce(node)
}

super.visitHTMLElementNode(node)
}

private checkScriptNonce(node: HTMLElementNode): void {
if (!this.isJavaScriptTag(node)) return

const nonceAttribute = this.findAttribute(node, "nonce")

if (!nonceAttribute || !hasAttributeValue(nonceAttribute)) {
this.addOffense(
"Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.",
node.tag_name!.location,
)
}
}

private isJavaScriptTag(node: HTMLElementNode): boolean {
const typeAttribute = this.findAttribute(node, "type")
if (!typeAttribute) return true

const typeValue = getStaticAttributeValue(typeAttribute)
if (typeValue === null) return true

return typeValue === "text/javascript" || typeValue === "application/javascript"
}

private findAttribute(node: HTMLElementNode, name: string) {
if (isERBOpenTagNode(node.open_tag)) {
return findAttributeByName(node.open_tag.children, name)
}

return getAttribute(node, name)
}
}

export class HTMLRequireScriptNonceRule extends ParserRule {
static ruleName = "html-require-script-nonce"

get defaultConfig(): FullRuleConfig {
return {
enabled: true,
Copy link
Contributor Author

@markokajzer markokajzer Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just noticed it's not enabled by default in erb_lint. do we want the same here?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think its fine to keep this one enabled for now 🙌🏼

severity: "error"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the correct severity?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still haven't really balanced all the severities yet. So I think we can keep this one as error for now, even though it might be a bit harsh/aggressive.

}
}

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

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

visitor.visit(result.value)

return visitor.offenses
}
}
3 changes: 2 additions & 1 deletion javascript/packages/linter/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ export * from "./actionview-no-silent-render.js"

export * from "./erb-comment-syntax.js"
export * from "./erb-no-case-node-children.js"
export * from "./erb-no-empty-control-flow.js"
export * from "./erb-no-conditional-open-tag.js"
export * from "./erb-no-duplicate-branch-elements.js"
export * from "./erb-no-empty-control-flow.js"
export * from "./erb-no-empty-tags.js"
export * from "./erb-no-extra-newline.js"
export * from "./erb-no-extra-whitespace-inside-tags.js"
Expand Down Expand Up @@ -77,6 +77,7 @@ export * from "./html-no-space-in-tag.js"
export * from "./html-no-title-attribute.js"
export * from "./html-no-underscores-in-attribute-names.js"
export * from "./html-require-closing-tags.js"
export * from "./html-require-script-nonce.js"
export * from "./html-tag-name-lowercase.js"

export * from "./svg-tag-name-capitalization.js"

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import dedent from "dedent"
import { describe, test } from "vitest"
import { HTMLRequireScriptNonceRule } from "../../src/rules/html-require-script-nonce.js"
import { createLinterTest } from "../helpers/linter-test-helper.js"

const { expectNoOffenses, expectError, assertOffenses } = createLinterTest(HTMLRequireScriptNonceRule)

describe("html-require-script-nonce", () => {
describe("HTML script tags", () => {
test("passes when nonce attribute is present with a value", () => {
expectNoOffenses('<script nonce="abc123"></script>')
})

test("passes when nonce attribute is present with ERB value", () => {
expectNoOffenses('<script nonce="<%= request.content_security_policy_nonce %>"></script>')
})

test("fails when nonce attribute is missing", () => {
expectError("Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.")

assertOffenses("<script></script>")
})

test("fails when nonce attribute has no value", () => {
expectError("Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.")

assertOffenses("<script nonce></script>")
})

test("fails when type is text/javascript and nonce is missing", () => {
expectError("Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.")

assertOffenses('<script type="text/javascript"></script>')
})

test("fails when type is application/javascript and nonce is missing", () => {
expectError("Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.")

assertOffenses('<script type="application/javascript"></script>')
})

test("passes when type is text/javascript and nonce is present", () => {
expectNoOffenses('<script type="text/javascript" nonce="abc123"></script>')
})

test("passes when type is application/javascript and nonce is present", () => {
expectNoOffenses('<script type="application/javascript" nonce="abc123"></script>')
})

test("passes when type is not JavaScript", () => {
expectNoOffenses('<script type="application/json"></script>')
})

test("passes when type is application/ld+json", () => {
expectNoOffenses('<script type="application/ld+json">{"@context": "https://schema.org"}</script>')
})

test("ignores non-script tags", () => {
expectNoOffenses('<div nonce="abc123"></div>')
})
})

describe("ERB javascript helpers", () => {
test("fails when javascript_tag is used without nonce", () => {
expectError("Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.")

assertOffenses(dedent`
<%= javascript_tag %>
`)
})

test("fails when javascript_include_tag is used without nonce", () => {
expectError("Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.")

assertOffenses(dedent`
<%= javascript_include_tag "script" %>
`)
})

test("passes when javascript_tag is used with nonce", () => {
expectNoOffenses(dedent`
<%= javascript_tag nonce: true %>
`)
})

test("passes when javascript_include_tag is used with nonce", () => {
expectNoOffenses(dedent`
<%= javascript_include_tag "script", nonce: true %>
`)
})

})

describe("tag.script helper", () => {
test("fails when tag.script is used without nonce", () => {
expectError("Missing a `nonce` attribute on `<script>` tag. Use `request.content_security_policy_nonce`.")

assertOffenses(dedent`
<%= tag.script %>
`)
})

test("passes when tag.script is used with nonce", () => {
expectNoOffenses(dedent`
<%= tag.script nonce: true %>
`)
})
})

test("passes using unrelated content_tag", () => {
expectNoOffenses(dedent`
<%= content_tag :div, "hello" %>
`)
})
})
Loading