Repository navigation
fix(passkey): escape mobile WebAuthn HTML templates against reflected XSS - #546
Conversation
… XSS getMobileRegistrationHTML/getMobileAuthenticationHTML/getMobileErrorHTML substituted every value - including sessionId and rpName, which reach /register/mobile and /auth/mobile as unconstrained query parameters with no character restriction - directly into HTML text and into single-quoted JavaScript string literals inside an inline <script> block, with no escaping at all. An attacker who knows a victim's email can call the public, unauthenticated challenge-issuing endpoint to obtain a real challenge and credentialId for that account, then send the victim a /auth/mobile (or /register/mobile) link with a crafted sessionId or rpName that breaks out of the JS string literal - e.g. closing the quote, running arbitrary script, and closing the surrounding </script> tag outright. The page is served from the real, trusted api.risaboss.com origin and also runs a real WebAuthn ceremony for the victim's own passkey, making this a convincing injection/phishing vector rather than a theoretical self-XSS. Added escapeHtml (HTML-text context: email, credential display name, error message) and escapeJsString (inline <script> string-literal context: challenge, userId, email, sessionId, rpId, rpName, credentialId - quote/backslash escaped, and "<"/">" neutralized so a value cannot spell out "</script>" and close the surrounding script element). Template placeholders are renamed per context (_HTML/_JS suffixes) since some values - email in particular - are substituted into both an HTML-text spot and a script spot in the same template and need different escaping at each. Added tests/html.test.ts: a same-payload regression proving a script-breakout string in sessionId/rpName cannot terminate its JS string literal or the surrounding <script> tag, an HTML-breakout payload in email/credentialDisplayName/message is entity-escaped, and benign values still round-trip unescaped-looking in the final page. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ements
String.prototype.replace applies $-pattern expansion ($$, $&, $ followed by backtick, $') to string replacement values, and that expansion runs after the escaping: a sessionId of '$&' echoed the raw {{SESSION_ID_JS}} placeholder back into the page, '$$' collapsed to '$', and a '$'+backtick payload spliced the whole pre-match document into the string literal, killing the inline script and breaking the WebAuthn ceremony.
All 18 placeholder substitutions now pass function replacements so the escaped value is inserted verbatim, and html.test.ts pins the behaviour for every template (registration, authentication, error).
Review follow-up on the escape fix by @Antriksh1984.
|
@Antriksh1984 Review follow-up - one gap found and fixed in the escaping mechanism itself. What was already right (yours, unchanged): the vulnerability analysis, The gap:
None of these reach arbitrary code execution (the spliced fragments are template text with no attacker-controlled quotes) - the impact is corrupted values plus a targeted page/ceremony DoS - but the fix's own guarantee that every value lands escaped and intact did not hold. The fix (commit Validation at the new head: One related hardening I deliberately left out of this PR's scope: the in-page deep link |
|
@claude Please review PR #546 at head 5f889c2 (diff against dev). Focus on the two changed files, |
|
Claude finished @kshivang's task in 5m 10s —— View job Review: PR #546 at
|
… in tests Claude review follow-up on the escape fix by @Antriksh1984: - renderTemplate() substitutes all {{PLACEHOLDER}} in one pass over the original template, so a value that literally contains a later placeholder name is never expanded (a sequential per-placeholder replace chain would, e.g. fill {{ANON_KEY_JS}} out of a sessionId). - regression test: a backslash before a quote survives intact, which fails if the backslash rule is removed or moved after the quote rule (mutation-verified both ways). - regression tests: CR/LF escaping to two-character sequences, the ampersand-first ordering in escapeHtml, the remaining $' dollar pattern, the exact rpName escaped literal, and the placeholder-in-value case. - tests/html.test.ts sets SUPABASE_ANON_KEY per test with try/finally (routes.test.ts convention) instead of a module-top-level set that leaks into every other test file in the same process.
|
@Antriksh1984 Claude review disposition (review comment 5632397837, head 5f889c2) - applied in Applied:
Deliberately not applied:
Local at |
… XSS (#546) Escape mobile WebAuthn template values in their HTML and inline JavaScript contexts to prevent reflected script injection. Original vulnerability analysis, contextual escapers and initial regression tests: Antriksh (@Antriksh1984). Prior agent follow-ups repair replacement-pattern expansion and add single-pass substitution plus additional regression coverage. Agent repairs are separate from the original submission score. Validation: all nine current-head CI checks pass; prior combined current-dev integration passed 167 Deno tests and 784 pgTAP assertions. Claude findings have been addressed with dispositions documented on PR #546. No production deployment is included. Co-authored-by: Antriksh <antrikshsingh850@gmail.com>
Summary
getMobileRegistrationHTML/getMobileAuthenticationHTML/getMobileErrorHTML(supabase/functions/passkey/utils/html.ts) substituted every value into the returned HTML page with plainString.replace()and no escaping at all - into both HTML text and into single-quoted JavaScript string literals inside an inline<script>block.challengeandemailare constrained upstream (a base64url challenge matched against a stored row,.email()format validation), butsessionId(both/register/mobileand/auth/mobile) andrpName(/register/mobile) reachroutes/mobile.tsas plain, unconstrainedz.string()query parameters and are echoed straight throughservices/mobile.tsinto the template with no character restriction anywhere on that path.Impact: an attacker who knows a victim's email can call the public, unauthenticated challenge-issuing endpoint to obtain a real challenge and
credentialIdfor that account, then send the victim a/auth/mobile(or/register/mobile) link with a craftedsessionIdorrpNamethat breaks out of its JS string literal - closing the quote, running arbitrary script, and closing the surrounding</script>tag outright. The page is served from the real, trustedapi.risaboss.comorigin and also runs a genuine WebAuthn ceremony for the victim's own passkey, which makes this a convincing injection/phishing vector rather than a theoretical self-XSS.Fix
escapeHtml(HTML-text context:email,credentialDisplayName, the errormessage) andescapeJsString(inline<script>string-literal context:challenge,userId,email,sessionId,rpId,rpName,credentialId) inutils/html.ts.escapeJsStringescapes backslash/quote (so a value can't terminate its own literal early) and also neutralizes</>so a value can't spell out</script>and close the surrounding script element - quote-escaping alone does not stop a<script>-context breakout._HTML/_JSsuffixes per context, sinceemailin particular is substituted into both an HTML-text spot and a script spot in the same template and needs different escaping at each - a single shared placeholder can't apply two different escaping functions.Tests
tests/html.test.ts(new): a same-payload regression proving a script-breakout string insessionId/rpNamecannot terminate its JS string literal or the surrounding<script>tag, an HTML-breakout payload inemail/credentialDisplayName/the error message is entity-escaped, and benign values still round-trip readably in the final page (no over-escaping).Validation:
deno checkacross the whole function (all files pass),deno test --allow-all- 153 passed, 0 failed (includes the 7 new tests plus the full existing suite, unaffected).Test plan
deno checkon every.tsfile insupabase/functions/passkeydeno test --allow-all- 153/153 passing/auth/mobile?...&sessionId=x'%3B alert(1)%3B//renders the literal escaped text rather than executing🤖 Generated with Claude Code
Review follow-up (2026-09-11, maintainer review)
Review found one remaining gap in the escaping mechanism itself:
String.prototype.replaceapplies$-pattern expansion ($$,$&,$+ backtick,$') to string replacement values, and that expansion runs after the escaping. Against the original head this meant:sessionIdof$&rendered the raw{{SESSION_ID_JS}}placeholder back into the page (and used it as the ceremony's sessionId, so the flow failed);$$collapsed to$, silently corrupting the value;$+ backticksessionIdspliced the entire pre-match document into the string literal, which kills the inline<script>(page JS SyntaxError, ceremony dead) for that crafted link.None of these reach arbitrary code execution - the spliced fragments are template text with no attacker-controlled quotes - but the fix's own guarantee ("every value lands escaped, intact") did not hold. The fix: all 18 placeholder substitutions now use function-form replacements (
() => escapedValue), which inserts the escaped value verbatim, with a comment pinning the reason.tests/html.test.tsgains a regression test covering every template.Original contribution: the vulnerability analysis, both escapers, the per-context placeholder split, and the breakout regression tests are @Antriksh1984's. Validation at the new head:
deno checkclean,deno test --allow-all154/154,deno lintunchanged from the PR head (the function's 21 pre-existing findings are untouched; the edge-functions workflow does not gatedev-targeted PRs).Claude review follow-up (2026-09-11, commit c434df6)
Claude Code Review (requested via @claude, run 34584248893) verdict at 5f889c2: "the escaping is correct and complete for the stated threat model. No security defect found at this head." Its findings, applied in c434df6:
renderTemplate): a value that literally contains a later placeholder name (e.g. asessionIdof{{ANON_KEY_JS}}) is no longer expanded by a subsequent substitution.x\'; alert(1); //payload test that fails if the backslash rule is removed or reordered after the quote rule (mutation-verified both ways).$'dollar pattern, exactrpNameliteral assertion,&-first ordering inescapeHtml, placeholder-in-value regression test.SUPABASE_ANON_KEYnow set per test with try/finally (theroutes.test.tspattern) instead of a leaking module-top-level set.Not applied: the semicolon point (function declarations take none -
deno lintclean) and the optional U+2028/2029 note (legal in JS string literals in the target environments; review marked it non-blocking). Validation at c434df6:deno checkclean,deno test --allow-all157/157,deno lintunchanged (23 pre-existing/convention findings).