Skip to content

fix(security): escape </script> in OAuth callback HTML response#1279

Open
aaronjmars wants to merge 1 commit into
can1357:mainfrom
aaronjmars:security/oauth-callback-xss-escape
Open

fix(security): escape </script> in OAuth callback HTML response#1279
aaronjmars wants to merge 1 commit into
can1357:mainfrom
aaronjmars:security/oauth-callback-xss-escape

Conversation

@aaronjmars
Copy link
Copy Markdown

Summary

Reflected XSS in the local OAuth callback server. The handler in packages/ai/src/utils/oauth/callback-server.ts reflects the error and error_description query params 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> sequence in either field closes the embedding block and lets attacker HTML/JS execute on the http://localhost:<oauth-port> origin.

Impact

The OAuth callback port is known per provider:

  • Anthropic: 54545
  • OpenAI Codex: 1455
  • GitLab Duo: 8080

(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_TIMEOUT in callback-server.ts), a page open in another browser tab can navigate the victim to a URL such as:

http://localhost:54545/callback?error=x&error_description=%3C%2Fscript%3E%3Cimg+src%3Dx+onerror%3Dalert(1)%3E

The server responds with the rendered oauth.html template. The injected </script> closes the JSON block, the following HTML is parsed, and the attacker JS runs in the http://localhost:54545 origin. 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 localhost bind and the short OAuth window, but the bug is real: a </script> in error_description is 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-169

<script id="server-state" type="application/json">
    __OAUTH_STATE__
</script>

Fix

Escape < to its JSON unicode form (<) before substitution, and use the function form of replaceAll so any attacker-controlled $& / `$`` patterns in the JSON string cannot influence the substitution semantics:

const stateJson = JSON.stringify(resultState).replace(/</g, "\\u003c");
return new Response(
    (templateHtml as unknown as string).replaceAll("__OAUTH_STATE__", () => stateJson),
    ...
);

< is a valid JSON escape, so JSON.parse(document.getElementById("server-state").textContent) in oauth.html continues 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.

  • Severity: medium
  • CWE-79 (Improper Neutralization of Input During Web Page Generation)

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

  • Added packages/ai/test/oauth-callback-html-escape.test.ts — two regression cases:
    1. </script> in error_description is unicode-escaped, the JSON parses, and parsed.error round-trips the original payload.
    2. </script> in the bare error param is unicode-escaped on the no-error_description path.
  • Test drives the real OAuthCallbackFlow.login() flow against an ephemeral port, sends the malicious request via fetch(), and asserts on the rendered HTML body.
  • Manually verified the escape against a faithful replica of the substitution pipeline (Node script, all four cases pass: malicious error_description, malicious bare error, success-path round-trip, plain-text round-trip).

Bun was not available in the scanner sandbox; the regression test runs under bun test from packages/ai locally.


Filed by Aeon.

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)
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +202 to +203
.replace(/
/g, "\\u2028")
.replace(/
/g, "\\u2029");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge 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 👍 / 👎.

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