Add CORS to the streaming routes - the real reason cast never played
All checks were successful
Publier l'image Docker / test (push) Successful in 20s
Publier l'image Docker / publish (push) Successful in 12s

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 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-15 20:27:51 +02:00
parent 62b0abd572
commit c00585101b
2 changed files with 89 additions and 4 deletions

View file

@ -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

View file

@ -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 <video>/<audio> element can't carry a bearer
// header).
app.get('/api/catalog/party-stream/:partyId/master.m3u8', asyncRoute(async (req, res) => {
app.get('/api/catalog/party-stream/:partyId/master.m3u8', allowCastCors, asyncRoute(async (req, res) => {
const partyId = param(req, 'partyId');
const session = sessionForStream(req, partyId);
if (!session) { res.status(401).end(); return; }
@ -215,7 +253,7 @@ app.get('/api/catalog/party-stream/:partyId/master.m3u8', asyncRoute(async (req,
// Everything an HLS playlist references (variant playlists, segments)
// comes back through here - see EmbyClient.rewritePlaylist.
app.get('/api/catalog/party-stream/:partyId/raw', asyncRoute(async (req, res) => {
app.get('/api/catalog/party-stream/:partyId/raw', allowCastCors, asyncRoute(async (req, res) => {
const partyId = param(req, 'partyId');
const session = sessionForStream(req, partyId);
if (!session) { res.status(401).end(); return; }
@ -245,7 +283,7 @@ app.get('/api/catalog/subtitle/:itemId/:subtitleIndex', asyncRoute(async (req, r
// combination is currently burned in, so there's nothing else a client
// could usefully put in this URL. requireSession's JSON error doesn't
// fit here either, same reasoning as the routes above.
app.get('/api/catalog/burn/:partyId/master.m3u8', asyncRoute(async (req, res) => {
app.get('/api/catalog/burn/:partyId/master.m3u8', allowCastCors, asyncRoute(async (req, res) => {
const partyId = param(req, 'partyId');
const session = sessionForStream(req, partyId);
if (!session) { res.status(401).end(); return; }
@ -261,7 +299,7 @@ app.get('/api/catalog/burn/:partyId/master.m3u8', asyncRoute(async (req, res) =>
await TranscodeService.serveMasterPlaylist(req, res, party, session);
}));
app.get('/api/catalog/burn/:partyId/:segment', asyncRoute(async (req, res) => {
app.get('/api/catalog/burn/:partyId/:segment', allowCastCors, asyncRoute(async (req, res) => {
const partyId = param(req, 'partyId');
const session = sessionForStream(req, partyId);
if (!session) { res.status(401).end(); return; }