fix: let named CORS origins send credentials (closes #155)

A bookmarklet could never reach an instance behind reverse-proxy auth.
on_prepare echoed Access-Control-Allow-Origin but never sent
Access-Control-Allow-Credentials, and hardcoded Allow-Headers to
Content-Type, so both approaches the reporter suggested in 2022 still
failed in a browser today: credentials:'include' was rejected for the
missing Allow-Credentials, and an explicit Authorization header was
rejected as not allowed by the preflight.

Naming an origin in CORS_ALLOWED_ORIGINS is a deliberate trust grant, so
a named origin may now send credentials and an Authorization header. The
'*' wildcard is not such a grant: it matches origins the operator never
enumerated, and since credentials require echoing the origin back rather
than sending '*', pairing the two would let any site the user visits
drive their instance with their own session. The wildcard therefore
keeps byte-for-byte the uncredentialed behaviour it has always had, and
a '*' anywhere in the list disables credentials for every origin in it,
with a startup warning so that combination is not silently confusing.

This matches the boundary socket.io already enforces: engineio defaults
cors_credentials to True, and its wildcard test is against the string
'*' while we pass a list, so it too grants credentials only to
explicitly listed origins.

Also sets Vary: Origin, appending rather than clobbering, so a shared
cache cannot hand one origin's Allow-Origin to another.

Verified in a real browser across the matrix: with the caller's origin
named, plain/credentialed/Authorization requests all succeed; under '*'
the credentialed and Authorization requests stay blocked; from an
unlisted origin everything stays blocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alex Shnitman
2026-08-20 09:39:26 +02:00
parent 86954784fd
commit c393e0195b
3 changed files with 173 additions and 5 deletions
+44 -3
View File
@@ -335,6 +335,12 @@ async def state_dir_guard(request, handler):
app = web.Application(middlewares=[state_dir_guard])
_cors_origins = [o.strip() for o in config.CORS_ALLOWED_ORIGINS.split(',') if o.strip()] if config.CORS_ALLOWED_ORIGINS else []
if '*' in _cors_origins and len(_cors_origins) > 1:
log.warning(
"CORS_ALLOWED_ORIGINS mixes '*' with named origins %s. '*' wins, and credentialed "
"cross-origin requests stay disabled for every origin in the list. Remove '*' if you "
"need a bookmarklet to reach an authenticated instance.",
[o for o in _cors_origins if o != '*'])
sio = socketio.AsyncServer(cors_allowed_origins=_cors_origins if _cors_origins else [])
sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io')
routes = web.RouteTableDef()
@@ -1286,9 +1292,44 @@ app.router.add_route('OPTIONS', config.URL_PREFIX + 'delete-cookies', add_cors)
async def on_prepare(request, response):
origin = request.headers.get('Origin')
if origin and _cors_origins and ('*' in _cors_origins or origin in _cors_origins):
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
if not origin or not _cors_origins:
return
# Naming an origin in CORS_ALLOWED_ORIGINS is a deliberate trust grant, so
# such an origin may send credentials: the cookie or Authorization header
# that a reverse proxy in front of MeTube checks. Without this a bookmarklet
# cannot reach an authenticated instance at all (issue #155).
#
# The '*' wildcard is emphatically not such a grant — it matches origins the
# operator never enumerated, including every site the user happens to visit.
# Echoing the origin back (which we must do, since '*' is illegal alongside
# credentials) and allowing credentials would let any page drive the user's
# instance with the user's own session. So the wildcard keeps exactly the
# uncredentialed behaviour it has always had, and a wildcard anywhere in the
# list disables credentials for every origin in it.
#
# Derived here rather than held in a second module global so the wildcard
# test and the membership test can never disagree about the same list.
wildcard = '*' in _cors_origins
trusted = not wildcard and origin in _cors_origins
if not (wildcard or trusted):
return
response.headers['Access-Control-Allow-Origin'] = origin
# Authorization rides on the same grant: allowing it under the wildcard
# would let an arbitrary page attempt credentials against an instance it
# can already reach, from inside the victim's network.
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' if trusted else 'Content-Type'
if trusted:
response.headers['Access-Control-Allow-Credentials'] = 'true'
# The response now differs per Origin, so a shared cache must not hand one
# origin's Allow-Origin to another.
vary = response.headers.get('Vary')
if not vary:
response.headers['Vary'] = 'Origin'
elif 'origin' not in (v.strip().lower() for v in vary.split(',')):
response.headers['Vary'] = f'{vary}, Origin'
app.on_response_prepare.append(on_prepare)