fix(security): escape </script> in OAuth callback HTML response#1279
fix(security): escape </script> in OAuth callback HTML response#1279aaronjmars wants to merge 1 commit into
Conversation
Reflected XSS in the OAuth callback server. `error` and `error_description` query params are reflected into a `<script id="server-state" type="application/json">` block in oauth.html via `JSON.stringify(resultState)`. JSON.stringify does not escape `</`, so a `</script>` in either field closes the embedding block and executes attacker HTML/JS on `http://localhost:<oauth-port>`. Callback ports are known per provider (Anthropic 54545, OpenAI Codex 1455, GitLab Duo 8080), so during the ~5-minute OAuth window a page in another tab can navigate the victim to `http://localhost:54545/callback?error=x&error_description=</script>...` and execute JS in that origin. Fix: escape `<` → `\\u003c` on the JSON before substitution and use the function form of replaceAll so attacker-controlled `$&`/`$`` substitution patterns are not interpreted. Detected by Aeon + manual review (semgrep + threat-model-claims axis). Severity: medium (CWE-79)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d694130a72
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| .replace(/ /g, "\\u2028") | ||
| .replace(/ /g, "\\u2029"); |
There was a problem hiding this comment.
Replace raw U+2028/U+2029 regex chars with escapes
Using literal U+2028 and U+2029 characters in these regexes makes the module fail to parse in Bun/JavaScript because those code points are treated as line terminators in source text, so importing callback-server.ts crashes before any OAuth flow can run. I confirmed this by running bun --cwd packages/ai test test/oauth-callback-html-escape.test.ts, which fails parsing this file (errors surface later due parser desync). Use escaped patterns like /\u2028/g and /\u2029/g instead of embedding the raw characters.
Useful? React with 👍 / 👎.
Summary
Reflected XSS in the local OAuth callback server. The handler in
packages/ai/src/utils/oauth/callback-server.tsreflects theerroranderror_descriptionquery params into a<script id="server-state" type="application/json">block inoauth.htmlviaJSON.stringify(resultState).JSON.stringifydoes not escape</, so a</script>sequence in either field closes the embedding block and lets attacker HTML/JS execute on thehttp://localhost:<oauth-port>origin.Impact
The OAuth callback port is known per provider:
5454514558080(Other providers can fall back to a random port, but the well-known ports above account for the bulk of real OAuth logins.)
During the ~5-minute OAuth login window (
DEFAULT_TIMEOUTincallback-server.ts), a page open in another browser tab can navigate the victim to a URL such as:The server responds with the rendered
oauth.htmltemplate. The injected</script>closes the JSON block, the following HTML is parsed, and the attacker JS runs in thehttp://localhost:54545origin. The login attempt is rejected on the server side at the same time (DoS of the in-progress OAuth flow), and the user sees an attacker-controlled error page in their browser.The blast radius is bounded by the loopback-only
localhostbind and the short OAuth window, but the bug is real: a</script>inerror_descriptionis enough to inject HTML, and the user has no way to tell the spoofed page from the legitimate "Authentication Failed" page.Location
packages/ai/src/utils/oauth/callback-server.ts(the response builder in#handleCallback) substituting into:packages/ai/src/utils/oauth/oauth.html:167-169Fix
Escape
<to its JSON unicode form (<) before substitution, and use the function form ofreplaceAllso any attacker-controlled$&/ `$`` patterns in the JSON string cannot influence the substitution semantics:<is a valid JSON escape, soJSON.parse(document.getElementById("server-state").textContent)inoauth.htmlcontinues to round-trip the original error string to the client without modification.No behavior change for the success path or for non-malicious error strings — only the
<byte is rewritten on the way into the script block.Detected by
Aeon + manual review.
The triage axis was "reflected query param into HTML script block via stringify-only encoding" — the same shape that has bitten several other localhost helper servers in the past quarter.
Verification
packages/ai/test/oauth-callback-html-escape.test.ts— two regression cases:</script>inerror_descriptionis unicode-escaped, the JSON parses, andparsed.errorround-trips the original payload.</script>in the bareerrorparam is unicode-escaped on the no-error_descriptionpath.OAuthCallbackFlow.login()flow against an ephemeral port, sends the malicious request viafetch(), and asserts on the rendered HTML body.error_description, malicious bareerror, success-path round-trip, plain-text round-trip).Bun was not available in the scanner sandbox; the regression test runs under
bun testfrompackages/ailocally.Filed by Aeon.