Skip to content

fix(pwa): escape the CSRF token in the hidden form field - #75

Merged
ralyodio merged 1 commit into
moshcoder:mainfrom
clawedassistant26:fix/csrf-input-unescaped-token
Jul 28, 2026
Merged

fix(pwa): escape the CSRF token in the hidden form field#75
ralyodio merged 1 commit into
moshcoder:mainfrom
clawedassistant26:fix/csrf-input-unescaped-token

Conversation

@clawedassistant26

Copy link
Copy Markdown
Contributor

Every cookie-session form in the PWA renders its hidden _csrf field through csrfInput() in apps/pwa/src/lib/session.mjs, which interpolated the token straight into the value attribute:

export const csrfInput = (req) => `<input type="hidden" name="_csrf" value="${req.csrfToken}">`;

That token is req.cookies.mc_csrf. sessionMiddleware only mints a new one when the cookie is absent, and never checks what is in it, so whatever the client sends back is what gets rendered.

appBar() in lib/html.mjs already escapes this exact same token. csrfInput() did not.

Reproduction

Booted the real authRouter + pagesRouter against a throwaway libsql database on unmodified eceb4f0, requested /settings with an mc_csrf cookie, then submitted the "Save channels" form exactly as a browser would from the returned markup (decoding entities in the attribute first).

mc_csrf value csrfInput() renders browser submits POST
abc123def456 value="abc123def456" abc123def456 302
a"b value="a"b" a 403
"><script>alert(1)</script><b x=" value="" (empty) 403

Two things go wrong once the value contains a quote:

  1. Every form in the app stops working. The attribute closes early, so the browser submits a truncated token, csrfGuard sees a mismatch and returns 403. That hits sign-in, sign-out, settings, channels, API keys, approve, kill, device authorize and buy-credits alike, and it persists for the 30-day cookie lifetime with no way to recover from the UI.
  2. The rest of the token is parsed as markup. The third row injects a live <script> element into the page. The cookie is set httpOnly:false and is not origin-isolated, so any subdomain able to write a cookie for the site can plant it.

Fix

Escape it, the same way appBar() already does. One import plus one call, no behaviour change for a well-formed token.

With the fix all three rows above return 302 and the markup stays intact — the escaped token decodes back to the original on submit, so it still matches the cookie.

Tests

New apps/pwa/test/csrf-input-escaping.test.mjs (5 tests, same skip-guard and throwaway-database pattern as logout-csrf.test.mjs): the ordinary-token control, the two cases above driven through a real rendered form, a check that csrfInput() and appBar() agree, and a guard test asserting a genuinely mismatched token is still rejected with 403.

Verified with git stash push -- apps/pwa/src/: 2 pass / 3 fail unpatched, 5/5 patched. The control and the guard test pass either way, so they are not measuring the fix.

Full suites, 0 failures both before and after: apps/pwa 27 → 30, root npm test 201 → 204.

One note on the test harness, since it is easy to get wrong: it decodes HTML entities in the value attribute before submitting. Comparing the raw attribute text instead would measure the markup rather than what actually reaches the server, and would report the fixed code as still broken.

csrfInput() interpolated req.csrfToken straight into the value attribute.
That token is read from the mc_csrf cookie, which the app never validates,
so a quote in it closes the attribute early. The browser then submits a
truncated token, csrfGuard rejects the POST, and every cookie-session form
in the app 403s until the cookie is cleared by hand. A token carrying
markup escapes the attribute entirely and injects into the page.

appBar() already escapes this exact token, so this only brings csrfInput()
in line with it.

Adds apps/pwa/test/csrf-input-escaping.test.mjs covering the round trip
through a rendered form, plus a guard test so the escaping cannot loosen
csrfGuard.
@ralyodio
ralyodio merged commit df9d1e0 into moshcoder:main Jul 28, 2026
3 checks passed
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