feat: add Credo check flagging raw/1 in HEEx templates#179
Conversation
Sobelow's XSS.Raw check never fires on `raw/1` used inside `~H` sigils because a template's contents are an unparsed binary in the AST. This adds a custom Credo check, `Flick.Credo.Check.RawInHeex`, that scans `~H` sigil source for `raw/1` calls and flags them as a potential XSS risk. Legitimate uses opt out with a HEEx-native `credo:allow-raw` marker. Credo's own `# credo:disable-*` comments only work as real Elixir comments, so they cannot live inside a template. Rendered Markdown is the app's only sanctioned `raw/1` use, so it is now centralized in a single `<.markdown>` core component that carries the sole opt-out. - Wire the check into `.credo.exs` (loaded via `requires`). - Run `credo --strict` as part of `mix precommit`. - Start Credo's supervision tree in the test helper so the check's unit tests can use `Credo.Test.Case`.
raw/1 in HEEx templates (#131)raw/1 in HEEx templates
There was a problem hiding this comment.
Pull request overview
This PR closes #131 by adding a custom Credo check to detect raw/1 usage inside ~H (HEEx) sigils—covering a blind spot where template contents are stored as an unparsed binary in the AST—and by centralizing the app’s sanctioned Markdown-to-HTML rendering behind a single component-level raw/1 call.
Changes:
- Add
Flick.Credo.Check.RawInHeexand enable it via.credo.exsrequires+ check configuration. - Ensure the check is exercised in CI/dev workflow by running
credo --strictinmix precommit, and wiring Credo into the test environment for unit tests. - Replace direct
raw(Flick.Markdown.render_to_html(...))usage in LiveViews with a new<.markdown>core component that contains the sole opt-out marker.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
credo/checks/raw_in_heex.ex |
Implements the custom Credo check that scans ~H template text for raw/1 usage with an allow-marker escape hatch. |
.credo.exs |
Loads the custom check via requires and enables it in the configured checks list. |
mix.exs |
Updates mix precommit to run credo --strict so the new guardrail runs in the normal workflow. |
test/test_helper.exs |
Ensures the custom check module is loaded for tests and starts Credo when needed for Credo.Test.Case. |
test/flick/credo/check/raw_in_heex_test.exs |
Adds unit tests validating detection and allow-marker behavior for the new check. |
lib/flick_web/components/core_components.ex |
Adds a reusable <.markdown> component that sanitizes Markdown output and contains the single approved raw/1 use + marker. |
lib/flick_web/live/ballots/viewer_live.ex |
Switches ballot description rendering to use <.markdown> instead of calling raw/1 inline. |
lib/flick_web/live/vote/vote_capture_live.ex |
Switches ballot description rendering to use <.markdown> instead of calling raw/1 inline. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ] | ||
|
|
||
| @allow_marker "credo:allow-raw" | ||
| @raw_call ~r/\braw\s*\(/ |
There was a problem hiding this comment.
Good catch — tightened in 80265e4. The check now requires a HEEx interpolation opener before raw( on the line, so literal raw( in prose, HTML attributes, or <pre> code samples no longer produces a false positive.
One adjustment to the suggestion: requiring { alone would create a false negative for <%= raw(...) %> (valid HEEx body interpolation), which is worse for a security guardrail. So the regex matches either opener — (?:\{|<%=)[^{}]*\braw\s*\( — and I added regression tests for both the <%= %> form and for non-interpolated literal text like <div>raw(@x)</div>.
Require a HEEx interpolation opener (`{` or `<%= `) before `raw(` on
the line, so literal `raw(` in prose, HTML attributes, or `<pre>` code
samples is not flagged as a false positive (per PR review).
Requiring `{` alone would miss `<%= raw(...) %>`; matching either
opener keeps both interpolation forms covered. Adds regression tests
for the `<%= %>` form and for non-interpolated literal text.
Closes #131.
Problem
Sobelow's
XSS.Rawcheck never fires onraw/1used inside a~Htemplate, because a template's contents are an unparsed binary in the AST — the same reason a naive Credo/AST check would miss it too. Soraw/1in HEEx was invisible to our security tooling.Solution
A custom Credo check,
Flick.Credo.Check.RawInHeex, that walks the AST for~H(sigil_H) nodes and scans their template text forraw/1calls, flagging each as a potential XSS risk (with accurate line/column).Legitimate uses opt out with a HEEx-native marker:
A HEEx-native marker is required because Credo's built-in
# credo:disable-*comments only work as real Elixir comments and can't live inside a template sigil.Changes
credo/checks/raw_in_heex.ex— the check (kept out oflib/since it depends onCredo.Check, unavailable in:prod)..credo.exs— loads the check viarequiresand enables it.mix.exs—mix precommitnow runscredo --strict, so the guardrail fires in the normal workflow.test/test_helper.exs— loads the check and starts Credo's supervision tree (runtime: false) so its unit tests can useCredo.Test.Case.<.markdown>core component — rendered Markdown is the app's only sanctionedraw/1use, now centralized in one component that carries the sole opt-out. Both LiveViews (viewer_live,vote_capture_live) use it, reducing the auditedrawsurface from two sites to one.Verification
mix precommitgreen: format clean,credo --strict0 issues (95 checks), 106 tests pass.Credo.Test.Case).raw/1 in HEExwarning; restoring it returns to green.Known limitations (deliberate)
raw(split across multiple lines could slip through. Real call sites are single-line{raw(...)}; this is defense-in-depth, not a proof.Phoenix.HTML.raw(— appropriate for the security intent.