Skip to content

fix(security): HTML-escape OAuth callback error param (GHSA-6v2r-2m4r-768m) - #160

Merged
nictuku merged 1 commit into
mainfrom
nictuku/fix-ghsa-6v2r-2m4r-768m
Sep 22, 2026
Merged

nictuku merged 1 commit into
mainfrom
nictuku/fix-ghsa-6v2r-2m4r-768m

Conversation

@nictuku

@nictuku nictuku commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Fixes the reflected XSS reported in GHSA-6v2r-2m4r-768m.

The bug

_handle_oauth_callback interpolated the error query parameter straight into an HTML error page via an f-string. Any page in the user's browser could navigate to http://localhost:8080-8089/callback?error=<script>... during the 180-second authorization window and execute script on the localhost origin.

Changes

  • html.escape() the error value (capped at 200 chars) before reflecting it. The message is still shown to the user, just entity-encoded.
  • Every callback response now carries Content-Security-Policy: default-src 'none', X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer and Cache-Control: no-store. The success page's inline window.close() script runs under a per-response nonce rather than loosening the policy.
  • Removed the /token endpoint. It was unreferenced anywhere in the codebase (auth.py reads token_container directly) and handed the stored OAuth auth_code to any same-origin page — i.e. the escalation target named in the advisory's attack chain.
  • Dropped the now-unused quote and json imports.

Drive-by fix

The success branch always returned a 500: asyncio.get_event_loop() raises RuntimeError in the callback server's worker thread because no event loop is ever set there, so the authorization code was never stored. Swapped to time.monotonic() (the timestamp field is not read anywhere). This surfaced while testing the success-page CSP.

Tests

New tests/test_callback_server_xss.py runs a real CallbackHandler on an ephemeral port and asserts the advisory's PoC payload no longer reaches the response, attribute-breakout payloads are encoded, truncation applies, the headers are set, the success page's script is nonced, and /token returns 404 without the auth code.

Full suite: 546 passed, 9 skipped.

Reported by zx (@manus-pi).

…-768m)

The local OAuth callback server interpolated the `error` query parameter
straight into an HTML error page via an f-string. Any page in the browser
could navigate to http://localhost:8080-8089/callback?error=<script>...
during the 180s authorization window and execute script on the localhost
origin.

- html.escape() the error value and cap it at 200 chars before reflecting it
- Serve every callback response with Content-Security-Policy default-src
  'none', X-Content-Type-Options, X-Frame-Options, Referrer-Policy and
  Cache-Control; the success page's inline window.close() script runs under a
  per-response nonce rather than loosening the policy
- Drop the unreferenced /token endpoint, which handed the stored OAuth
  auth code to any same-origin page reachable via the XSS above
- Drop the now-unused `quote`/`json` imports

Also fixes the success branch, which always 500'd: asyncio.get_event_loop()
raises in the callback server's worker thread because no loop is ever set
there, so the auth code was never stored. Use time.monotonic() instead.

Reported by zx (@manus-pi).
@nictuku
nictuku merged commit 271ea4c into main Sep 22, 2026
4 checks passed
@nictuku nictuku mentioned this pull request Sep 22, 2026
nictuku added a commit that referenced this pull request Sep 22, 2026
Ships the GHSA-6v2r-2m4r-768m fix (#160) and documents the advisory in SECURITY.md.
PM-Labs pushed a commit to pmlabs-org/mcp-meta-ads that referenced this pull request Oct 11, 2026
…-768m) (pipeboard-co#160)

The local OAuth callback server interpolated the `error` query parameter
straight into an HTML error page via an f-string. Any page in the browser
could navigate to http://localhost:8080-8089/callback?error=<script>...
during the 180s authorization window and execute script on the localhost
origin.

- html.escape() the error value and cap it at 200 chars before reflecting it
- Serve every callback response with Content-Security-Policy default-src
  'none', X-Content-Type-Options, X-Frame-Options, Referrer-Policy and
  Cache-Control; the success page's inline window.close() script runs under a
  per-response nonce rather than loosening the policy
- Drop the unreferenced /token endpoint, which handed the stored OAuth
  auth code to any same-origin page reachable via the XSS above
- Drop the now-unused `quote`/`json` imports

Also fixes the success branch, which always 500'd: asyncio.get_event_loop()
raises in the callback server's worker thread because no loop is ever set
there, so the auth code was never stored. Use time.monotonic() instead.

Reported by zx (@manus-pi).
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