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 <noreply@anthropic.com>
This commit is contained in:
parent
62b0abd572
commit
c00585101b
2 changed files with 89 additions and 4 deletions
|
|
@ -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
|
one simulated discrete event still calls it exactly once - the precise
|
||||||
behavioral difference the fix is supposed to produce.
|
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
|
## Known limitations
|
||||||
|
|
||||||
- Playback always goes through `hls.js` against whatever Emby's
|
- Playback always goes through `hls.js` against whatever Emby's
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,44 @@ function sessionForStream(req: Request, partyId: string): EmbySession | null {
|
||||||
return AuthService.sessionFromRequest(req) || CastTokenService.sessionFromToken(String(req.query.castToken || ''), partyId);
|
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 ----------------------------------------------------------------
|
// --- Auth ----------------------------------------------------------------
|
||||||
// The browser only ever sends a username/password once, to us. From then on
|
// 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.
|
// 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
|
// requireSession's JSON error doesn't fit here either, same reasoning
|
||||||
// as the routes above (a <video>/<audio> element can't carry a bearer
|
// as the routes above (a <video>/<audio> element can't carry a bearer
|
||||||
// header).
|
// 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 partyId = param(req, 'partyId');
|
||||||
const session = sessionForStream(req, partyId);
|
const session = sessionForStream(req, partyId);
|
||||||
if (!session) { res.status(401).end(); return; }
|
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)
|
// Everything an HLS playlist references (variant playlists, segments)
|
||||||
// comes back through here - see EmbyClient.rewritePlaylist.
|
// 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 partyId = param(req, 'partyId');
|
||||||
const session = sessionForStream(req, partyId);
|
const session = sessionForStream(req, partyId);
|
||||||
if (!session) { res.status(401).end(); return; }
|
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
|
// combination is currently burned in, so there's nothing else a client
|
||||||
// could usefully put in this URL. requireSession's JSON error doesn't
|
// could usefully put in this URL. requireSession's JSON error doesn't
|
||||||
// fit here either, same reasoning as the routes above.
|
// 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 partyId = param(req, 'partyId');
|
||||||
const session = sessionForStream(req, partyId);
|
const session = sessionForStream(req, partyId);
|
||||||
if (!session) { res.status(401).end(); return; }
|
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);
|
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 partyId = param(req, 'partyId');
|
||||||
const session = sessionForStream(req, partyId);
|
const session = sessionForStream(req, partyId);
|
||||||
if (!session) { res.status(401).end(); return; }
|
if (!session) { res.status(401).end(); return; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue