Skip to content

Quality sweep: fix stored XSS, close authorization gaps, and add the missing test harness - #31

Open
mdon wants to merge 17 commits into
BeamLabEU:mainfrom
mdon:main
Open

Quality sweep: fix stored XSS, close authorization gaps, and add the missing test harness#31
mdon wants to merge 17 commits into
BeamLabEU:mainfrom
mdon:main

Conversation

@mdon

@mdon mdon commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

A quality sweep over phoenix_kit_comments following the workspace
playbook (dev_docs/quality_sweep.md), Phase 1 and Phase 2.

Rebased onto upstream/main; the only conflict was the phoenix_kit floor,
where upstream's 1.7.214 is kept.

The one that matters

Stored XSS in every comment body. Bodies render with unsafe: true,
which disables MDEx's escaping and makes whatever runs next the security
boundary. That was six regexes over the rendered string — a blocklist over
HTML. Verified against the real renderer, two payloads went through
untouched:

<script>alert(document.domain)          # no closing tag: the pattern
                                        # required a matching </script>
<a href=javascript:alert(1)>click</a>   # unquoted: the pattern required
                                        # the value to be quoted

Either executes for every reader of a thread and again in the admin
moderation list, which renders the same component — so an unprivileged
commenter runs script in an owner's authenticated session.

MDEx ships an ammonia allow-list and it simply was not enabled. Its default
permits style on div (it strips it from p), which lets a comment lay
an overlay over the moderation UI's own buttons, so that comes off too. The
sanitize flag is gone rather than defaulted safely — one sanitize={false}
anywhere was stored XSS on every reader. Ten payloads are pinned by tests.

Authorization

  • save_decoration had no check at all. A logged-out visitor could push
    the event and rename any host record backed by a visible comment; the
    send_update the host receives is byte-identical to a legitimate one.
  • Admin reads were unguarded while every write was checked. Core's admin
    routes pipeline through ensure_admin only, and permission: "comments"
    on the tab controls sidebar visibility, not access. An admin without the
    permission got every comment on the platform, commenter emails, and the
    Giphy API key as a form value.
  • @enabled gated only the template, so writes went through on a
    disabled thread.
  • save_edit forwarded the decoration before checking permission — half
    an edit, from a refused request.

Data integrity

  • Reaction counters were inflatable. Dedup was a SELECT-then-INSERT with
    no unique index behind it — the index was dropped during the uuid-FK
    migration and never recreated, which also makes the schemas' own
    unique_constraint dead code (confirmed: that name is in no migration in
    core's chain). Reactions now serialise on the parent comment row.
  • The counter drifted permanently once duplicates existed
    delete_all removes N rows and the decrement was hardcoded to 1.
  • A repeat click committed a write and told nobody: both paths remove the
    opposing reaction before deciding, and after_reaction/3 skipped exactly
    those two atoms.
  • unlike/undislike ran outside a transaction while their
    counterparts wrapped theirs.

Crashes reachable by ordinary use

Comment uuids went unvalidated into Repo.get/2 (CastError, and
Repo.get(Comment, nil) when no edit was open); a map submitted where a
string belonged reached String.length/1 and wrote the user's draft into
the crash report; replies accepted a parent from another resource, storing a
comment that rendered in neither thread under a "Comment added" flash;
submitting mid-upload raised inside consume_uploaded_entries/3 and took
the host LiveView with it.

Everything else

Activity logging (there was none — eleven mutations including moderation);
Giphy moved off the LiveView process (it blocked every event on the page for
the request timeout) and its gate aligned with the picker's; phx-disable-with
(there was not one in the module, so a double-click posted twice); upload
metadata no longer taken from the client; the resource-handler contract
declared as a behaviour instead of duck-typed; i18n (five bare-English
flashes, four hardcoded JS strings, and one string that could never resolve
because the catalog held it as a plural entry while the code called
gettext/2).

Test infrastructure

mix.exs declared test/support and the directory did not exist;
config/test.exs configured no repo. ~40 of ~57 public functions were
untestable, and some tests were asserting the rescue path —
count_comments/3 returned 0 because there was no database, and deleting
the query body would not have failed the test. Now a sandboxed repo,
DataCase, and integration coverage for the reaction defects.

Phase 1

None of the 14 PR folders had a FOLLOW_UP.md. All 19 review files are
triaged, each finding verified against current code and recorded as fixed
pre-existing, fixed in this sweep, or N/A with the reason.

Verification

  • mix test — 68 tests, 0 failures.
  • mix precommitexits 0, which it did not before this sweep. It was
    failing on optional-core calls written as direct calls behind
    Code.ensure_loaded?: the guard is right, but the call still resolves at
    compile time and warns against exactly the version the guard exists for.

⚠️ One thing to know before release

Comment attribution needs core V166, which is unpublished (it is in
BeamLabEU/phoenix_kit#692). The declared floor predates it, so a host at the
floor gets a missing-column error from get_comment_tree/2. mix.exs says
so at the pin, display_name/1 is guarded at the call site, and the test
helper detects the missing column and skips the integration half with a
pointer rather than failing. Bump the floor when that core release ships.

mdon added 17 commits August 9, 2026 19:35
Comments are where people actually write prose, so this is the surface
that matters. The composer and the edit box both offer the typeahead on
the plain textarea — not the rich editor, which owns its own key handling
and would fight a second listener for the caret.

Rendering resolves per reader before markdown runs: a link if they may
open it, the author's words if it's gone, "no access" if it isn't theirs.
The viewer's scope is built ONCE per render rather than per comment —
resolving needs permissions, not just a uuid, and Scope.for_user/1 reads
the database.

Notify asks about the comment's PARENT resource, not the comment row: the
question that matters is whether this person can open the thing being
discussed. Editing a comment to add a mention pings; reshuffling text
that already mentioned someone pings nobody.
composer_form/1 and render_comment/1 are function components, so `@x`
resolves against their OWN assigns — and neither declares mentions_on or
id. Opening the "Write comment" box raised KeyError and took the whole
LiveView down; the same applied to editing a comment in place. Both read
through ctx, which is the parent's full assigns and carries them.

Missed because the only suite that renders this component runs against
the PUBLISHED comments pin, where none of the mentions wiring exists.
Reproduced and now guarded from phoenix_kit_projects' portal test with
PHOENIX_KIT_COMMENTS_PATH pointed at this checkout.
Comment bodies go through the same mention resolver as everything else,
which with the site-wide redaction setting off looks up the CURRENT title
of records the reader cannot open. On a public board a hand-typed
`#[project_task:...]` therefore published an internal name — the typeahead
never offers one, but nothing stops someone typing it and nothing validates
tokens on write. Off by default; the portal opts in.
The header rendered `user.email`, so every public board printed its
commenters' addresses. It now shows the frozen `author_display_name`,
falling back to the canonical chain for rows written before this.

A person answering on their employer's public board may be speaking for
themselves or for the project, and those are different acts. The composer
offers `Post as {project}` — but only when the host supplied a verifier AND
that verifier says this person qualifies right now. No verifier, no
control, and nobody can claim to speak for anything: hosts that never heard
of this are unaffected.

The checkbox is intent, not permission. `resolve_attribution/2` asks the
host's verifier again at submit, because the composer may have rendered
long before the send and membership can be revoked in between — otherwise a
removed member keeps speaking for the project by leaving a tab open. A
refused claim quietly becomes a personal comment: the comment is still
valid, and an error would make the control a membership oracle.

The attribution fields are absent from `cast/3` and applied by
`put_attribution/2` from server-computed values. A client that could set
them could sign a comment as anyone.

`user_uuid` is never cleared. Posting as the project changes what the
PUBLIC sees and nothing else — internally the author stays on the row, so
moderation and audit keep working. A shared voice with nobody accountable
behind it is how this feature goes wrong.
Comment markdown renders with `unsafe: true`, which disables MDEx's own
escaping and makes whatever runs afterwards the security boundary. That was
six regexes over the rendered string — a blocklist over HTML, which loses.
Verified against the real renderer, two payloads went through untouched:

  <script>alert(document.domain)          # no closing tag: the pattern
                                          # required a matching </script>
  <a href=javascript:alert(1)>click</a>   # unquoted: the pattern required
                                          # the value to be quoted

Either one executes for every reader of a thread and again in the admin
moderation list, which renders the same component — so an unprivileged
commenter runs script in an owner's authenticated session.

MDEx ships an ammonia allow-list and it was simply not enabled: unknown tags
and every attribute outside the allowed set are dropped rather than matched
against, and links get `rel` for free. Its default permits `style` on `div`
(it strips it from `p`), which lets a comment lay an overlay over the
moderation UI's own buttons, so that comes off too.

The `sanitize` flag is gone rather than defaulted safely. Comment bodies are
written by whoever can post, so no caller wants unsanitised output, and one
`sanitize={false}` anywhere was stored XSS on every reader.

Ten payloads are now pinned by tests against the real renderer, plus the
markdown that has to keep working.
`resolve_mentions/2` was called from `render_comment/1` — a FUNCTION
component, which sees only its declared attrs — and handed that component's
own `assigns`. Neither `pk_scope` nor `withhold_mention_titles` is declared
there, so both read as nil on every render. Because they are bracket
lookups, nothing crashed:

  * `withhold_mention_titles` was always false, so "Let a host withhold
    mention titles in comment bodies" shipped as a no-op;
  * a nil scope makes core's `Mentions.visible/3` fail closed, so every
    mention rendered locked AND still printed the record's live title —
    precisely the leak withholding exists to prevent.

Both values are already in `@ctx`, forwarded at both call sites. Same class
as the KeyError fixed in ff6c378, which missed this one because bracket
access fails silently where `@assign` raises.

Also: `User.display_name/1` was called unguarded although it landed in a
core release newer than this module's declared floor, so a host resolving
core from Hex got UndefinedFunctionError on the main render path. Guarded,
with the same chain reproduced locally for older cores — the point is that
it never falls back to printing the email address.

The attribution COLUMNS need core V166 and cannot be guarded that way. The
floor is left alone rather than pinned to an unreleased version; mix.exs now
says so, and it has to be bumped when that core release ships.
**Decoration edits had no authorization at all.** `save_decoration` and
`begin_decoration_edit` checked nothing — not the user, not ownership, not
admin. The only thing in front of them was a pencil icon rendered on hover,
and a hidden control is not a control. A logged-out visitor could push
`save_decoration` at the component and rename any host record backed by a
visible comment; the `send_update` the host then receives is byte-identical
to a legitimate one, so it has no way to tell. Both now resolve through
`decoration_if_permitted/2`, gated on the same rule as editing the comment
itself. The label is also capped server-side — `maxlength` on the input is a
courtesy to the typist, not a limit — and a non-binary label no longer
reaches `String.trim/1` and kills the LiveView.

**`save_edit` forwarded the decoration before checking permission.** A
caller whose edit rights had gone still landed the label on the host record
while their body edit was refused: half an edit, from a refused request.

**`@enabled` gated only the template.** The component still rendered its
outer div, so its cid stayed live: a page open when an admin switched
comments off — or a replayed push — could still create, delete and react.
Writes are now refused at the handler. Reads are deliberately untouched.

Caught while fixing this: the guard clause for a non-binary label had been
placed BEFORE the real clause and shadowed it completely, so the fix would
have shipped dead. Compiler flagged it; clause order corrected.
Three defects in one path.

**Counters could be inflated without bound.** `insert_reaction/4` deduped
with a SELECT followed by an INSERT, and the code comment explains why that
was the only dedup available: the UNIQUE(comment_id, user_id) index was
dropped when the integer `user_id` column went during the uuid-FK migration
and nothing recreated it on `user_uuid`. The schemas still declare
`unique_constraint(..., name: :uq_comments_likes_comment_user)`, which is
dead code — Ecto turns a DATABASE violation into a changeset error, and
there is no database constraint to violate. Confirmed: that name appears in
no migration in core's chain.

So two clicks in the same instant both saw "no reaction", both inserted, and
one user's like counted twice. Reactions now serialise on the parent comment
row (`SELECT ... FOR UPDATE`), which is exactly the scope that matters.
Restoring the index in core is still worth doing, and would also cover
writers that bypass this function.

**The counter drifted permanently once duplicates existed.**
`maybe_remove_reaction/4` deletes N rows and decremented by exactly 1, so
the count stayed above the truth with no user-reachable way to bring it
down. It now decrements by the number actually deleted, and the floor guard
became `>= by` so a decrement that would go negative is skipped rather than
clamping halfway. Four near-identical counter helpers collapsed to two.

**A repeat click committed a write and told nobody.** Both paths remove the
opposing reaction before deciding, so `:already_liked` still commits a
delete and a counter change — and `after_reaction/3` skipped exactly those
two atoms, leaving every subscriber stale. The broadcast now fires; the host
callback stays scoped to real state changes, since a second like is not a
new like.

Also: `unlike`/`undislike` ran their delete and decrement outside a
transaction while their counterparts wrapped them, so a crash in between
orphaned the count.
Every item here is reachable by an ordinary user, most by accident.

**Comment uuids were never validated.** Callers pass raw `phx-value-uuid`
into `Repo.get/2`, where a malformed value raises Ecto.Query.CastError and
kills the LiveView. `save_edit` reads `editing_uuid`, which is nil when no
edit is open, so `Repo.get(Comment, nil)` raised ArgumentError. User uuids
and file uuids on these same paths were already validated — comment uuids
were the gap. "Not a uuid" and "no such comment" are the same answer.
`bulk_update_status/2` filters the same way: one bad element in a
client-supplied list raised for the whole batch.

**A map where a string belonged.** Submitting `comment[x]=y` instead of
`comment=y` put a map into `String.length/1` — FunctionClauseError, and the
crash report carried the user's draft into the application log.

**Replies accepted any parent.** Delete and edit both verify the comment
belongs to the current resource; reply took any string. A uuid from another
resource passed `create_comment/4` (the FK is to comments, not to the
resource) and `get_comment_tree/2` only walks this resource's roots — so the
comment was stored, published, counted, and rendered in neither thread,
under a "Comment added" flash.

**Submitting mid-upload killed the page.** `consume_uploaded_entries/3`
raises while entries are in progress, and a raising LiveComponent takes the
host LiveView with it — draft, staged files, sibling state. Nothing disabled
the button while a file climbed.

**The configured length cap could exceed the schema's.** Settings clamp
`comments_max_length` to 100_000 while the changeset hardcoded 10_000, so an
admin raising it produced comments refused with "should be at most 10000
character(s)" — a limit they had just changed.

**`get_max_depth/0` and `get_max_length/0`** lacked the `n > 0` guard and
rescue their sibling `get_max_attachments/0` has: a stored "0" rejected
every comment, one for nesting and one for length.

**Storage errors were `inspect`ed into a user-visible banner** — bucket
names, paths, changesets. Logged now, generic message shown.
`mix.exs` declared `test/support` in its elixirc paths and the directory did
not exist. `config/test.exs` configured no repo, so roughly 40 of the ~57
public functions could not be exercised at all — and every defect this sweep
found lived in that gap.

Worse than absent: some tests were asserting the ABSENCE. `count_comments/3`
wraps its query in `rescue _ -> 0`, and with no database configured the test
for it exercised the rescue and passed. Deleting the query body would not
have failed it.

Adds a sandboxed `Test.Repo` + `DataCase`, and a helper that builds the
schema from core's versioned chain — the comments tables live there, so
there is no module-owned DDL to run. Integration tests are tagged and
excluded when PostgreSQL is unreachable, so `mix test` still runs the unit
half on a machine without one.

Two things the harness has to get right, both learned the hard way here:

  * `start_link/0` connects lazily and succeeds against a database that does
    not exist, so the guard now asks a question before believing it;
  * the V166 column probe runs BEFORE sandbox mode goes manual, or it fails
    for want of connection ownership rather than for want of the column.

That probe is load-bearing: attribution needs core V166, `mix.exs` still
declares an older floor, and a plain `mix test` therefore builds a schema
without those columns. Rather than fail with "column does not exist" on
every query, the suite says so and skips, pointing at
PHOENIX_KIT_PATH=../phoenix_kit.

First tests through it cover the reaction defects fixed in 71f9418 —
including the duplicate-row case, which is what proves the counter now
decrements by rows actually deleted rather than by one.
…success

**Reads were unguarded while every write was checked.** Core's admin routes
pipeline through `:phoenix_kit_ensure_admin` only — the `permission:
"comments"` on the tab controls sidebar VISIBILITY, not access. So every
mutation called `check_authorization/1` and correctly answered "Not
authorized", while `mount/3`, `handle_params/3` and `view_comment` handed an
admin *without* the comments permission the whole platform's comments:
bodies, commenter email addresses, and on the settings page the Giphy API
key as a form value. `type="password"` masks that visually; it is not a
control. That every write was gated is what shows the read side was meant to
be too.

Both LiveViews now check on mount. `view_comment` keeps its own check
because it reads an ARBITRARY comment by uuid — the one handler where
"already on the page" is not the same as "may see this row".

**Bulk actions reported success unconditionally.** `bulk_update_status/2`
returns `{ok_count, error_count}` and all three call sites discarded it, so
a bulk approve where every row failed still flashed "Comments approved". The
count is now used, the flash is an error when anything failed, and the three
copy-pasted branches collapse to one.
The module wrote nothing to the audit trail. Eleven mutating functions —
including the moderation ones, `approve_comment`, `hide_comment` and
`bulk_update_status` — left no record, so "who hid this comment, and when"
had no answer anywhere in the system. Moderation is exactly the kind of
action whose value is knowing who took it.

`PhoenixKitComments.Activity` wraps core's logger with the shape the other
modules use: guarded by `Code.ensure_loaded?`, a missing activity table and
a dead pool both `:ok`, everything else a warning. An audit line is not
worth losing the write it describes.

Metadata carries the shape of the action and nothing a user typed: status,
resource type, depth, whether it was a reply. Never the body, never an
email.

Mutating context functions take `opts` so a LiveView can thread the acting
admin (`actor_opts/1`), and `log: false` lets a wrapper keep one line
instead of two — `delete_comment/2` goes through `update_comment/3`, and
"updated" followed by "deleted" reads like two separate acts.
**Giphy search ran inside `handle_event`.** A slow or unreachable Giphy
blocked every other event on the page — typing, likes, replies, navigation
— for the full request timeout, with the UI frozen and nothing to say why.
It runs through `start_async` now, which also supplies the in-flight guard
it never had: a keystroke supersedes the previous search instead of stacking
requests against the host's quota.

**The picker's gate and the search's gate disagreed.** `giphy_enabled?/0`
requires the toggle AND a key; `search_giphy/2` required only the key. With
the toggle off the picker was hidden and a crafted `giphy_search` event
still spent the host's quota. Hiding the control was never the control.

**The API key could reach the log.** It rides in the query string, so
`inspect(e)` on a Req exception writes it in plaintext. Only the exception
type is logged now.

**No `phx-disable-with` existed anywhere in the module.** Every submit and
destructive button was unprotected: a double-click on a slow link posted the
same comment twice — two events queue with the same text, the first consumes
the attachments and the second lands as a text-only twin. Composer, edit,
settings saves, per-row moderation and the bulk actions all carry one now.
**Uploads took the browser's word for everything.** `filename`,
`content_type` and `size_bytes` all came from `entry.client_*`, and core's
storage does `Keyword.fetch!` on them — nothing in the chain looks at the
bytes. The size is now measured with `File.stat/1` rather than declared, and
the filename is stripped of path separators and control characters before it
reaches storage and, from there, download headers. Content sniffing belongs
next to the other `fetch!`s in core's storage and is recorded in the sweep
rather than reached around from here.

`.zip .rar .7z` also came off the accept list. It is a client-side hint on
any public comment thread, and archives are not what a comment box is for.

**The audio hook pushed English prose into a flash.** Four hardcoded
sentences landed untranslated in every locale — and because the handler put
the client's own string straight into `put_flash/3`, any client could paint
arbitrary text in the app's error chrome. The hook sends a reason CODE now
and the server maps it; a non-binary value no longer raises.

**Five settings validation messages** were bare English on a page that was
otherwise fully translated.

**One string could never resolve at all.** `"%{count} selected"` was called
through `gettext/2` while the catalog held it as a PLURAL entry left over
from an `ngettext` call, so the lookup missed and Russian fell back to
English with a perfectly good translation sitting unused. The count is
substituted client-side by the bulk hook, so no locale's plural rules can
apply — the wording now reads correctly for any number and has its own
singular entry.

ru and et are back to zero untranslated. The msgfmt charset warning on both
catalogs is pre-existing and untouched by this pass.
**The six-callback host contract was duck-typed.** It lived in a moduledoc
and was invoked through `function_exported?/3`, so a typo in a callback name
or drift in its arity produced silence — the callback simply never fired and
nothing said why. `PhoenixKitComments.ResourceHandler` declares it with
`@optional_callbacks`, which buys compile-time checking of the callbacks a
host does write. Adopting it is opt-in; handlers registered today keep
working untouched.

**Docs said things that were not true.** README named `:admin_comments` and
`:admin_settings_comments` as permissions — those are tab ids, and the only
permission this module declares is `"comments"`. AGENTS.md pointed three
times at `lib/phoenix_kit_comments/phoenix_kit_comments.ex`, a path that
does not exist, and its settings table omitted `comments_rich_text`
entirely while `comments_max_depth` did not mention that depths are 0-based
(10 yields 0–9).

**Two host-facing surfaces were documented nowhere.** `PhoenixKitComments.Embed`
is required for the rich-text composer to post at all — without it "Post
comment" silently no-ops — and the README's JavaScript section, the only
place a host would look, stopped at LiveSocket hooks. The `handle_info`
catch-all requirement is now spelled out too: the component sends
`{:comments_updated, _}` to the host, and LiveView only tolerates unmatched
messages while a view exports NO `handle_info/2`, so the documented wiring
was one clause away from killing the host LiveView.

Also: `render_comment/1` was public by accident while its siblings are
private; `humanize_resource_type/1` lowercased whole strings ("GitHubRepo" →
"Githubrepo"); the same field rendered raw in one place and humanized in
another; a redundant self-alias; and `1024 * 1024` got a name.
None of the 14 PR folders had a FOLLOW_UP.md, so Phase 1 had never been run
here. All 19 review files across those PRs are now triaged: each finding
verified against current code and recorded as fixed pre-existing, fixed in
this sweep, or N/A with the reason.

Three had been written up as "left as-is". The playbook is explicit that
deferrals are not mine to declare, so they are fixed instead:

**Restoring a comment always published it.** `restore` called
`approve_comment/2` unconditionally, so undoing a delete on a comment that
had never been approved published it as a side effect. `restore_comment/2`
returns it to `pending` when the site moderates.

**Admin previews rendered every comment body in full** — parsed and
sanitised, per row — only to clamp the result to one line with CSS. The
source is truncated first.

**A settings sentence was five gettext fragments** assembled around `<code>`
tokens ("Use" / "for resource ID," / "or"), which freezes word order to
English and hands a translator "for resource ID," with no context. One msgid
per sentence now, tokens as placeholders so a locale can position them; ru
and et translated.

Both catalogs are at zero untranslated.
`mix precommit` was already failing before this sweep began — recorded in
the C0 baseline — because every call into optional core code
(`PhoenixKit.Mentions`, and later `User.display_name/1`) was written as a
direct call guarded by `Code.ensure_loaded?`. The guard is right; the direct
call still resolves at compile time, so it warns against exactly the core
version the guard exists for, and `--warnings-as-errors` turns that into a
failure.

Those calls go through `apply/3` now, which is the point rather than an
oversight — annotated where credo's "known arity" rule fires, and pulled
into `mentions_available?/0` so the annotation survives the formatter
re-joining the lines.

Two genuine style findings fixed rather than annotated: two negated
if-else conditions, and `save_edit`'s three-deep nesting, which became a
flat `cond` in `save_edit_for/4` — the permission branch reads as a rule
now instead of an else inside an else.

`mix precommit` exits 0. `mix test` is 65/0 against published core (8
integration tests excluded for want of V166) and 65/0 against local core
with all 8 running.
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.

1 participant