From c00585101be4f5693d426a8fedce0c0dde39a843 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 15 Sep 2026 20:27:51 +0200 Subject: [PATCH] Add CORS to the streaming routes - the real reason cast never played Reported again after the castToken-propagation fix: "still doesn't work, the stream doesn't play on the TV side." That fix was real, but addressed what would break after the first segment - this was more fundamental, blocking things from the very first request. The Default Media Receiver plays this app's HLS through an MSE-based player (Chromecast's Chromium build has no native HLS support), which fetches every segment via fetch/XHR from whatever origin Google hosts the receiver at - cross-origin from this server. This app never sent a single CORS header anywhere; every consumer until now was its own same-origin page. Without one, the browser blocks every one of those reads outright, before this app's own cookie/castToken check even runs - the master playlist request itself included. Fixed with a small allowCastCors middleware on the same 4 streaming routes sessionForStream already covers - Access-Control-Allow-Origin: *, plus handling OPTIONS for the same 4 paths (Range, needed for segment fetches, isn't CORS-safelisted, so a real cross-origin request carrying one triggers a preflight this app had no answer for at all). `*` is safe here specifically because these routes never need credentialed cross-origin access: a real browser already reaches them same-origin, and the one genuine cross-origin caller authenticates via the castToken query param, never a cookie. Verified: full typecheck/test/build (122/122). Confirmed against the real running server that an OPTIONS preflight now answers 204 with the right headers, a real GET still carries Access-Control-Allow-Origin regardless of outcome, and an unrelated route carries no such header - scoped to exactly the 4 routes a Chromecast needs. Not verified: whether this is sufficient for a real receiver to actually play the stream end to end - no device reachable from this sandbox. Co-Authored-By: Claude Sonnet 5 --- docs/architecture.md | 47 ++++++++++++++++++++++++++++++++++++++++++++ server/src/index.ts | 46 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 93471b4..2af3d11 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2928,6 +2928,53 @@ heartbeat ticks call the hide-timer's reset function zero times, while one simulated discrete event still calls it exactly once - the precise behavioral difference the fix is supposed to produce. +## Cast still didn't play at all: no CORS on the streaming routes + +Reported again after the castToken-propagation fix above: "still +doesn't work, the stream doesn't play on the TV side." That fix was +real, but not the whole story - it addressed what would break *after* +the first segment; this was more fundamental, blocking things from the +very first request. + +The Default Media Receiver plays this app's HLS through an MSE-based +player (Chromecast's own Chromium build has no native HLS support to +fall back on) - which fetches every segment via `fetch`/XHR from +whatever origin Google hosts that receiver at. Cross-origin, from this +app's own server. This app never sent a single CORS header anywhere - +never had to, every consumer until now was this app's own same-origin +page. Without one, the browser blocks every one of those cross-origin +reads outright, before any of this app's own cookie/castToken check +even gets a chance to run - the master playlist request itself included. + +Fixed with a small `allowCastCors` middleware on the same 4 streaming +routes `sessionForStream` already covers (`party-stream/:partyId/ +master.m3u8`/`raw`, `burn/:partyId/master.m3u8`/`:segment`) - +`Access-Control-Allow-Origin: *`, plus handling `OPTIONS` for the same +4 paths (a real cross-origin `Range` header - needed for byte-range/ +segment fetches - isn't itself CORS-safelisted, so it triggers a real +preflight, which this app previously had no answer for at all). `*` is +safe specifically because these routes never need to support +*credentialed* cross-origin access: a real browser already reaches them +same-origin (no CORS involved), and the one genuine cross-origin caller +authenticates via the castToken query param, never a cookie - +`Access-Control-Allow-Origin: *` combined with +`Access-Control-Allow-Credentials` would be required before a cookie +could ever ride along cross-origin, and this deliberately never sends +that second header at all. + +Verified: full `typecheck`/`test`/`build`. Confirmed against the real +running server that an `OPTIONS` preflight on these routes now answers +204 with the right three headers, that a real (still-401, no token +given) `GET` still carries `Access-Control-Allow-Origin` regardless of +outcome (so a cross-origin caller's own JS can actually read that +response rather than seeing an opaque failure), and that an unrelated +route (`/api/auth/me`) carries no such header at all - this stays +scoped to exactly the 4 routes a Chromecast needs. **Not verified**: +whether this is now sufficient for a real receiver to actually decode +and play the stream end to end - no device reachable from this +sandbox, same standing limitation as the rest of "Casting to a +Chromecast". + ## Known limitations - Playback always goes through `hls.js` against whatever Emby's diff --git a/server/src/index.ts b/server/src/index.ts index dc9f6d4..1e8087e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -77,6 +77,44 @@ function sessionForStream(req: Request, partyId: string): EmbySession | null { return AuthService.sessionFromRequest(req) || CastTokenService.sessionFromToken(String(req.query.castToken || ''), partyId); } +/** + * The same 4 streaming routes `sessionForStream` covers also need CORS - + * a Chromecast's Default Media Receiver plays this app's HLS through an + * MSE-based player (Chromecast's own Chromium build has no native HLS + * support), which fetches every segment via `fetch`/XHR from whatever + * origin Google hosts that receiver at - cross-origin from us. Without + * this, the browser blocks every one of those reads outright, before + * any of this app's own cookie/castToken check even runs - confirmed as + * the real cause of "casts, but nothing ever plays" after the castToken + * propagation fix above turned out not to be the whole story. + * + * `*` is safe here specifically because these routes never need to + * support *credentialed* cross-origin access - a real browser watching + * this app already does so same-origin (no CORS involved at all), and + * the one real cross-origin caller (the receiver) authenticates via the + * castToken query param, not a cookie; `Access-Control-Allow-Origin: *` + * combined with `Access-Control-Allow-Credentials` would be required + * before a cookie could ever ride along on a cross-origin request, and + * this deliberately never sends that second header. `Range` (needed + * for byte-range/segment fetches) isn't itself CORS-safelisted, so a + * real cross-origin GET carrying one triggers a preflight - handled + * below by answering `OPTIONS` for the same 4 paths. + */ +function allowCastCors(_req: Request, res: Response, next: NextFunction): void { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Range, If-None-Match, If-Modified-Since'); + next(); +} + +const CAST_CORS_PATHS = [ + '/api/catalog/party-stream/:partyId/master.m3u8', + '/api/catalog/party-stream/:partyId/raw', + '/api/catalog/burn/:partyId/master.m3u8', + '/api/catalog/burn/:partyId/:segment' +]; +app.options(CAST_CORS_PATHS, allowCastCors, (_req, res) => res.status(204).end()); + // --- Auth ---------------------------------------------------------------- // The browser only ever sends a username/password once, to us. From then on // it holds an opaque session cookie - never Emby's own access token. @@ -201,7 +239,7 @@ app.get('/api/catalog/image/:itemId', asyncRoute(async (req, res) => { // requireSession's JSON error doesn't fit here either, same reasoning // as the routes above (a