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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue