Repository navigation
fix(security): HTML-escape OAuth callback error param (GHSA-6v2r-2m4r-768m) - #160
Merged
Merged
Conversation
…-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).
Merged
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the reflected XSS reported in GHSA-6v2r-2m4r-768m.
The bug
_handle_oauth_callbackinterpolated theerrorquery parameter straight into an HTML error page via an f-string. Any page in the user's browser could navigate tohttp://localhost:8080-8089/callback?error=<script>...during the 180-second authorization window and execute script on the localhost origin.Changes
html.escape()theerrorvalue (capped at 200 chars) before reflecting it. The message is still shown to the user, just entity-encoded.Content-Security-Policy: default-src 'none',X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrerandCache-Control: no-store. The success page's inlinewindow.close()script runs under a per-response nonce rather than loosening the policy./tokenendpoint. It was unreferenced anywhere in the codebase (auth.pyreadstoken_containerdirectly) and handed the stored OAuthauth_codeto any same-origin page — i.e. the escalation target named in the advisory's attack chain.quoteandjsonimports.Drive-by fix
The success branch always returned a 500:
asyncio.get_event_loop()raisesRuntimeErrorin the callback server's worker thread because no event loop is ever set there, so the authorization code was never stored. Swapped totime.monotonic()(thetimestampfield is not read anywhere). This surfaced while testing the success-page CSP.Tests
New
tests/test_callback_server_xss.pyruns a realCallbackHandleron 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/tokenreturns 404 without the auth code.Full suite: 546 passed, 9 skipped.
Reported by zx (@manus-pi).