Skip to content

Restrict Markdown HTML sanitizer to an explicit attribute allowlist - #485

Merged
jeremy merged 2 commits into
mainfrom
security/markdown-attribute-allowlist
Aug 30, 2026
Merged

Restrict Markdown HTML sanitizer to an explicit attribute allowlist#485
jeremy merged 2 commits into
mainfrom
security/markdown-attribute-allowlist

Conversation

@jeremy

@jeremy jeremy commented Aug 30, 2026

Copy link
Copy Markdown
Member

Problem

HtmlScrubber (the sanitizer applied to rendered Markdown page content) sets an allowed-tag list but never sets an allowed-attribute list. When Rails::Html::PermitScrubber has no attribute list, it falls back to Loofah's default attribute handling, which permits all data-* attributes.

That fallback lets editor-authored page content persist a self-firing Stimulus controller — e.g. data-controller + data-action wired to a lifecycle event — and execute arbitrary same-origin JavaScript on page view, with no user interaction. Because the rendered content runs in the app origin, this escalates to acting as the viewer (including an admin viewer).

Inline event handlers (onload, onerror) and iframe srcdoc happen to be stripped by the current Loofah default, but only implicitly — nothing in this app pins that behavior, so a dependency default shift would silently reopen the hole.

Fix

Set an explicit attribute allowlist on HtmlScrubber, containing only the attributes the permitted tags legitimately need (media playback controls, table/iframe layout attributes, and the previously-relied-on id/style).

With an explicit allowlist, PermitScrubber is deny-by-default:

  • inline event handlers (on*) — dropped
  • data-controller / data-action / data-turbo-* and all other data-* — dropped
  • iframe srcdoc — dropped
  • URL-valued attributes (href, src) keep their javascript:-scheme scrubbing
  • style values continue to be CSS-scrubbed

This turns the sanitizer's safety from an implicit dependency default into an explicit, tested contract, and closes the data-* script-execution vector.

Tests

  • New test/models/html_scrubber_test.rb asserts the scrubber strips event handlers, data-* gadget attributes, iframe srcdoc, and javascript: URLs, while preserving legitimate formatting, media, and table markup.
  • Existing page/sanitization tests continue to pass (full suite green; Brakeman clean; RuboCop clean).

Copilot AI balanced review requested due to automatic review settings August 30, 2026 03:35
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-30T04:27:35.004743Z 88e95ff New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b93b2261f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/models/html_scrubber.rb Outdated
Comment thread app/models/html_scrubber.rb Outdated
Comment thread app/models/html_scrubber.rb Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Hardens rendered Markdown sanitization by explicitly allowlisting HTML attributes and blocking script-capable attributes.

Changes:

  • Adds an explicit attribute allowlist.
  • Adds sanitizer security and preservation tests.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
app/models/html_scrubber.rb Defines permitted Markdown HTML attributes.
test/models/html_scrubber_test.rb Tests dangerous-attribute removal and valid markup preservation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread app/models/html_scrubber.rb Outdated
Comment thread app/models/html_scrubber.rb Outdated
@jeremy
jeremy force-pushed the security/markdown-attribute-allowlist branch from 4b93b22 to ef9d038 Compare August 30, 2026 03:49

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ef9d0387de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/models/html_scrubber.rb Outdated
@jeremy
jeremy force-pushed the security/markdown-attribute-allowlist branch from ef9d038 to 350164a Compare August 30, 2026 03:55

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 350164a582

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/models/html_scrubber.rb Outdated
@jeremy
jeremy force-pushed the security/markdown-attribute-allowlist branch 2 times, most recently from fd4f637 to 756a1f2 Compare August 30, 2026 04:16
@jeremy

jeremy commented Aug 30, 2026

Copy link
Copy Markdown
Member Author

Thanks — the automated reviews (Codex connector + Copilot) caught a real regression and a residual, both now addressed. Summary of where this landed:

Feature regressions fixed. The first cut rebuilt the allowlist from Rails' 13-attribute default, which silently dropped attributes the app and authors rely on — most importantly the MarkdownRenderer lightbox wiring on every image, plus aria-*, target/rel, dir, tabindex, list numbering, etc. The allowlist is now based on Loofah's own vetted safe-attribute set (ACCEPTABLE_ATTRIBUTES), which restores every safe attribute that was effectively permitted before, while omitting the data-* wildcard that is the actual bug. So this is deny-by-default for data-* with no authoring regression.

data-action gadget closed (Codex). Globally allowlisting data-action would have kept it on authored HTML too, letting content bind auto-firing events (turbo:load@window->…) to page-scoped controllers. The lightbox controller only needs data-action="lightbox#open:prevent" (it reads the anchor's already-scrubbed href), so the scrubber now permits only that one benign value and strips every other data-action. The inert data-lightbox-target/data-lightbox-url-value are dropped entirely (also prevents target-shadowing).

Value-sensitive iframe attributes dropped (Copilot). allow, referrerpolicy, and sandbox are no longer allowed — they can delegate capabilities or leak referrers and weren't in the prior safe set. allowfullscreen/frameborder (inert embed attrs) remain.

Net: data-controller, data-turbo-*, all other data-*, on* handlers, and srcdoc are removed; href/src keep javascript:-scheme scrubbing; style keeps CSS scrubbing. Regression test covers each vector plus the preserved lightbox/header/ARIA/authoring markup. Full suite + Brakeman + RuboCop green.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 756a1f2882

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/models/html_scrubber.rb Outdated
HtmlScrubber set an allowed-tag list but left the attribute list unset,
so sanitized Markdown fell back to Loofah's default attribute handling.
That default permits every data-* attribute, which lets stored page
content carry data-controller + data-action — a self-firing Stimulus
controller — and execute arbitrary same-origin JavaScript on page view
with no user interaction.

Set the attribute list explicitly to Loofah's vetted safe-attribute set
plus the media/embed attributes the extra allowed tags need. This keeps
every safe attribute that was effectively allowed before (target, rel,
dir, tabindex, aria-*, table/list layout, ...) while dropping the data-*
wildcard, so data-controller, data-turbo-*, any other data-*, inline
event handlers, and srcdoc are removed. URL-valued attributes keep their
javascript:-scheme scrubbing; style keeps its CSS scrubbing. The
value-sensitive iframe attributes allow/referrerpolicy/sandbox are not
allowed.

The MarkdownRenderer wires generated image anchors to the lightbox via
data-action; only that one benign action value survives, so authored
HTML can't bind arbitrary or auto-firing Stimulus/Turbo actions to the
page's controllers. The lightbox reads the anchor's scrubbed href.

Add a regression test asserting the scrubber strips event handlers,
data-controller/data-turbo-* and foreign data-action, iframe srcdoc,
value-sensitive iframe attributes, and javascript: URLs, while
preserving the renderer's lightbox/header markup, ARIA, safe authored
attributes, and formatting/media/table markup.
@jeremy
jeremy force-pushed the security/markdown-attribute-allowlist branch from 756a1f2 to 88e95ff Compare August 30, 2026 04:23
@jeremy

jeremy commented Aug 30, 2026

Copy link
Copy Markdown
Member Author

Independent adversarial pass (Codex, xhigh, read the pinned Loofah 2.25.2 / rails-html-sanitizer 1.7.1 / Nokogiri source): verdict is closes the data-* auto-execution class, no blocking finding — parent scrub runs before remove_foreign_actions, no on*/data-controller/data-turbo-*/srcdoc/javascript: survives, and the <a>+exact-value gate holds against non-click default-event elements (4,774 adversarial checks passed across every allowed tag and URI-valued attribute).

One in-scope test gap it flagged, now fixed in f15c09f: the foreign-data-action assertions ran only on <div>, so they locked the tag half of the lightbox restriction but not the value half — a future loosening of remove_foreign_actions to allow any action on an anchor would have passed. The regression now asserts a foreign data-action is stripped on <a> too. Full html_scrubber_test green (11 runs, 112 assertions).

Two non-blocking notes left as-is (pre-existing/intentional, out of scope for this XSS-class fix): allowfullscreen is a user-gesture-gated fullscreen capability, and arbitrary iframe src + rel=opener are existing embed behaviors covered by pages_controller_test. Worth a separate decision if untrusted authorship hardening is in scope, but not part of closing this class.

@jeremy
jeremy merged commit 7c2b0b3 into main Aug 30, 2026
7 checks passed
@jeremy
jeremy deleted the security/markdown-attribute-allowlist branch August 30, 2026 04:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants