From a6a882a4f9bad37ee4d0eaf7d9d899df3f5d13d2 Mon Sep 17 00:00:00 2001 From: Omar Ramadan Date: Tue, 27 Oct 2026 21:23:42 +0000 Subject: [PATCH 1/2] feat(mabr-route-proxy): discover ROUTE manifests from public DS SLS --- packages/mabr-route-proxy/README.md | 34 +++++--- packages/mabr-route-proxy/src/route.test.ts | 93 ++++++++++++++++++--- packages/mabr-route-proxy/src/route.ts | 75 ++++++++++++++--- 3 files changed, 165 insertions(+), 37 deletions(-) diff --git a/packages/mabr-route-proxy/README.md b/packages/mabr-route-proxy/README.md index 1acd996..9a429f4 100644 --- a/packages/mabr-route-proxy/README.md +++ b/packages/mabr-route-proxy/README.md @@ -7,16 +7,20 @@ ROUTE/FDT `contentLocation` as the cache key, and stores DASH/HLS manifests and CMAF objects. Shaka keeps its normal manifest and segment URLs: ```ts -import { createRouteMabrSession } from '@blockcast/mabr-route-proxy'; +import { createRouteMabrSession, discoverSlsEndpoint } from '@blockcast/mabr-route-proxy'; // ROUTE objects are mounted under baseUrl: an FDT Content-Location of // `live/manifest.mpd` is served at `${baseUrl}live/manifest.mpd`. const baseUrl = 'https://player.example/mabr/'; -const manifestUrl = new URL('live/manifest.mpd', baseUrl).href; +const toSls = await discoverSlsEndpoint({ + sltUrl: 'https://live.example', + serviceId: 'generic-service', +}); +if (!toSls.manifestUrl) throw new Error('DS SLS did not advertise a DASH/HLS manifest'); const proxy = createRouteMabrSession({ gateway: window.multicast, wasm: routeWasm, - // `toSls` is the service SLS endpoint selected by the sender/TO. Use + // `toSls` is the service SLS endpoint selected by the public DS. Use // ingress: 'amt'; with an SSM source the gateway discovers the relay via // RFC 8777 DNS (relay/relayPort are only an explicit override). ingress: 'native', @@ -28,7 +32,7 @@ const proxy = createRouteMabrSession({ const restoreFetch = proxy.installFetch(); await proxy.start(); -await shakaPlayer.load(manifestUrl); // DASH MPD or HLS .m3u8 +await shakaPlayer.load(toSls.manifestUrl); // DASH MPD or HLS .m3u8 ``` `baseUrl` is also the cache scope. Only Content-Location URLs under its @@ -39,26 +43,30 @@ own, never the origin root. A root `baseUrl` lets an on-path injector shadow any same-origin URL. When a network does not carry LLS on the ATSC 3 ASM group, bootstrap the -service SLS over HTTPS. `sltUrl` is the HTTP LLS/SLT authority exposed by the -TO-selected delivery service; it is used only to select the first service SLS -leg. Its hostname is never used as the AMT relay. The browser still receives -ROUTE bytes through DIMT or AMT, and the S-TSID on that -leg supplies the rendition `(S,G,port)` values: +service SLS over HTTPS. `sltUrl` may be the public delivery-service origin or +an explicit `/slt/` URL. For an origin, the library reads the public +`/slt/` listing and fetches the advertised groups; it never assumes a group +number. The selected service's `SvcInetUrl` supplies the DASH or HLS manifest +URL, while its SLS leg supplies the first multicast join. Its hostname is +never used as the AMT relay. The browser still receives ROUTE bytes through +DIMT or AMT, and the S-TSID on that leg supplies the rendition `(S,G,port)` +values: ```ts const proxy = createRouteMabrSession({ gateway: window.multicast, wasm: routeWasm, ingress: 'native', // use 'amt' for AMT/DRIAD when SLS has an SSM source - sltUrl: 'https://live.example/slt/', - sltGroup: 7, // optional when /slt/ lists one group - serviceId: 'nasa-iss-a', // globalServiceID or numeric serviceId + sltUrl: 'https://live.example', // public DS authority; /slt/ is discovered + serviceId: 'generic-service', // globalServiceID or numeric serviceId discoverEndpoints: true, baseUrl, }); const restoreFetch = proxy.installFetch(); await proxy.start(); -await shakaPlayer.load(manifestUrl); // .mpd or .m3u8 +const sls = await discoverSlsEndpoint({sltUrl: 'https://live.example', serviceId: 'generic-service'}); +if (!sls.manifestUrl) throw new Error('DS SLS did not advertise a DASH/HLS manifest'); +await shakaPlayer.load(sls.manifestUrl); // .mpd or .m3u8 ``` The client fetches `/slt/` to list access groups, then `/slt/` to read diff --git a/packages/mabr-route-proxy/src/route.test.ts b/packages/mabr-route-proxy/src/route.test.ts index 460fc4e..abc6735 100644 --- a/packages/mabr-route-proxy/src/route.test.ts +++ b/packages/mabr-route-proxy/src/route.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createRouteMabrSession, type RouteObject } from './route.ts'; +import { createRouteMabrSession, discoverSlsEndpoint, type RouteObject } from './route.ts'; function fakeWasm(mpd = '#EXTM3U\n# generated by SLS') { const memory = new WebAssembly.Memory({ initial: 18 }); @@ -308,11 +308,14 @@ test('discovers the service SLS leg from HTTP LLS/SLT', async () => { return new Response('
'); } if (url === 'https://relay.test/slt/7') { - return new Response(``); + return new Response(`/live/manifest.mpd`); } return new Response('missing', { status: 404 }); }) as typeof fetch; try { + const discovered = await discoverSlsEndpoint({sltUrl: 'https://relay.test', serviceId: 'nasa-iss'}); + assert.equal(discovered.manifestUrl, 'https://relay.test/live/manifest.mpd'); + urls.length = 0; const session = createRouteMabrSession({ gateway: { async subscribe(value: object) { config = value as Record; @@ -334,6 +337,72 @@ test('discovers the service SLS leg from HTTP LLS/SLT', async () => { } }); +test('discovers an HLS manifest from a DS authority without a fixed SLT group', async () => { + const originalFetch = globalThis.fetch; + const urls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + urls.push(url); + if (url === 'https://ds.example.test/slt/') { + return new Response('7/'); + } + if (url === 'https://ds.example.test/slt/7') { + return new Response(''); + } + return new Response('missing', {status: 404}); + }) as typeof fetch; + try { + assert.deepEqual(await discoverSlsEndpoint({ + sltUrl: 'https://ds.example.test', + serviceId: 'generic-service', + }), { + group: '232.1.1.66', + port: 8000, + manifestUrl: 'https://ds.example.test/live/master.m3u8', + }); + assert.deepEqual(urls, [ + 'https://ds.example.test/slt/', + 'https://ds.example.test/slt/7', + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('accepts dsUrl as the browser discovery bootstrap', async () => { + const originalFetch = globalThis.fetch; + const urls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + urls.push(url); + if (url === 'https://ds.example.test/slt/') return new Response('7'); + return new Response('/live/manifest.mpd'); + }) as typeof fetch; + try { + const endpoint = await discoverSlsEndpoint({dsUrl: 'https://ds.example.test', serviceId: 'generic-service'}); + assert.equal(endpoint.manifestUrl, 'https://ds.example.test/live/manifest.mpd'); + assert.deepEqual(urls, ['https://ds.example.test/slt/', 'https://ds.example.test/slt/7']); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('does not return a cross-origin SvcInetUrl as the DS manifest', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response( + 'https://other.example/live/manifest.mpd', + )) as typeof fetch; + try { + const endpoint = await discoverSlsEndpoint({ + sltUrl: 'https://ds.example.test/slt/7', + serviceId: 'generic-service', + }); + assert.equal(endpoint.manifestUrl, undefined); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('subscribes to AMT relay bytes', async () => { let config: Record | undefined; const gateway = { @@ -421,7 +490,7 @@ test('rejects a sourceless AMT endpoint instead of emitting a DRIAD join', async assert.equal(subscriptions, 0); }); -test('discovers AMT service bytes through a delivery-service hostname', async () => { +test('discovers AMT service bytes through a public delivery-service authority', async () => { const originalFetch = globalThis.fetch; const urls: string[] = []; const fetchOptions: RequestInit[] = []; @@ -430,11 +499,11 @@ test('discovers AMT service bytes through a delivery-service hostname', async () const url = String(input); urls.push(url); fetchOptions.push(init ?? {}); - if (url === 'https://cdn.onprem-live-multicast.public-st.cdn.blockcast.net/slt/') { + if (url === 'https://ds.example.test/slt/') { return new Response('
'); } - if (url === 'https://cdn.onprem-live-multicast.public-st.cdn.blockcast.net/slt/7') { - return new Response(''); + if (url === 'https://ds.example.test/slt/7') { + return new Response(''); } return new Response('missing', { status: 404 }); }) as typeof fetch; @@ -446,21 +515,21 @@ test('discovers AMT service bytes through a delivery-service hostname', async () } }, wasm: fakeWasm(), ingress: 'amt', - sltUrl: 'https://cdn.onprem-live-multicast.public-st.cdn.blockcast.net', - serviceId: 'nasa-iss-a', + sltUrl: 'https://ds.example.test', + serviceId: 'generic-service', }); await session.start(); assert.deepEqual(urls, [ - 'https://cdn.onprem-live-multicast.public-st.cdn.blockcast.net/slt/', - 'https://cdn.onprem-live-multicast.public-st.cdn.blockcast.net/slt/7', + 'https://ds.example.test/slt/', + 'https://ds.example.test/slt/7', ]); assert.deepEqual(fetchOptions, [ { credentials: 'omit', mode: 'cors' }, { credentials: 'omit', mode: 'cors' }, ]); assert.deepEqual(config, { - origin: 'amt', group: '232.0.1.3', port: 1024, - source: '69.25.95.198', consume: 'bytes', discovery: 'driad', + origin: 'amt', group: '232.1.1.66', port: 8000, + source: '192.0.2.10', consume: 'bytes', discovery: 'driad', }); await session.stop(); } finally { diff --git a/packages/mabr-route-proxy/src/route.ts b/packages/mabr-route-proxy/src/route.ts index 73069fc..e6126f7 100644 --- a/packages/mabr-route-proxy/src/route.ts +++ b/packages/mabr-route-proxy/src/route.ts @@ -140,6 +140,8 @@ export interface RouteEndpoint { group: string; port: number; source?: string; + /** Optional HTTP manifest advertised by the selected SLT service. */ + manifestUrl?: string; } export interface RouteGateway { @@ -165,6 +167,8 @@ export interface RouteMabrOptions { slsEndpoint?: RouteEndpoint; /** HTTP LLS/SLT endpoint used to discover the service SLS leg. */ sltUrl?: string; + /** Public delivery-service origin; `/slt/` is discovered from this origin. */ + dsUrl?: string; /** Optional LLS group id when `sltUrl` exposes more than one SLT. */ sltGroup?: number; /** Optional numeric A/331 service id or global service id to select from SLT. */ @@ -202,7 +206,30 @@ function normalizeRouteSource(value?: string): string | undefined { return source; } -function parseSltEndpoints(xml: string, serviceId?: string | number): RouteEndpoint[] { +function advertisedManifestUrl(body: string, authority: URL): string | undefined { + const candidates: string[] = []; + const pattern = /<((?:[A-Za-z_][\w.-]*:)?SvcInetUrl)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1\s*>)/gi; + for (const match of body.matchAll(pattern)) { + const text = xmlUnescape(match[3] ?? '').trim(); + const attribute = xmlUnescape(xmlAttribute(match[2]!, 'url') || xmlAttribute(match[2]!, 'href')).trim(); + for (const value of [text, attribute]) { + if (/\.(?:mpd|m3u8)(?:[?#].*)?$/i.test(value)) candidates.push(value); + } + } + for (const candidate of candidates) { + try { + const parsed = new URL(candidate, authority); + // SvcInetUrl is consumed as the DS's public media URL. Do not allow an + // untrusted SLS document to redirect the page to another origin. + if (parsed.protocol === 'https:' && parsed.origin === authority.origin) return parsed.href; + } catch { + // An optional malformed service URL must not hide a valid SLS leg. + } + } + return undefined; +} + +function parseSltEndpoints(xml: string, serviceId?: string | number, authority?: URL): RouteEndpoint[] { const endpoints: RouteEndpoint[] = []; const servicePattern = /<((?:[A-Za-z_][\w.-]*:)?(?:Service|Server))\b([^>]*)>([\s\S]*?)<\/\1\s*>/gi; let serviceMatch: RegExpExecArray | null; @@ -219,20 +246,44 @@ function parseSltEndpoints(xml: string, serviceId?: string | number): RouteEndpo const source = normalizeRouteSource(xmlUnescape(xmlAttribute(signal[2]!, 'slsSourceIpAddress'))); const port = Number(xmlAttribute(signal[2]!, 'slsDestinationUdpPort')); if (group && Number.isInteger(port) && port > 0 && port <= 65535) { - endpoints.push({ group, port, ...(source ? { source } : {}) }); + const manifestUrl = authority ? advertisedManifestUrl(body, authority) : undefined; + endpoints.push({ group, port, ...(source ? { source } : {}), ...(manifestUrl ? { manifestUrl } : {}) }); } } return endpoints; } +function sltGroups(index: string, sltDirectory: string): number[] { + const directory = new URL(sltDirectory); + const groups: number[] = []; + const links = /]*\bhref\s*=\s*(["'])(.*?)\1/gi; + for (const match of index.matchAll(links)) { + try { + const link = new URL(xmlUnescape(match[2]!), directory); + if (link.origin !== directory.origin) continue; + const prefix = directory.pathname.endsWith('/') ? directory.pathname : `${directory.pathname}/`; + const relative = link.pathname.slice(prefix.length).replace(/\/+$/, ''); + if (/^\d+$/.test(relative) && link.search === '' && link.hash === '') groups.push(Number(relative)); + } catch { + // Ignore non-URL links in a directory listing. + } + } + return [...new Set(groups)]; +} + /** * Resolve the one service SLS bootstrap endpoint advertised by a public DS * SLT/LLS URL. The returned source is the authority for AMT DRIAD; rendition * groups are still learned later from the service S-TSID. */ -export async function discoverSlsEndpoint(options: Pick): Promise { - const raw = options.sltUrl?.trim(); - if (!raw) throw new RangeError('sltUrl is required for HTTP LLS discovery'); +export async function discoverSlsEndpoint(options: Pick): Promise { + const sltRaw = options.sltUrl?.trim(); + const dsRaw = options.dsUrl?.trim(); + if (!sltRaw && !dsRaw) throw new RangeError('sltUrl or dsUrl is required for HTTP LLS discovery'); + if (sltRaw && dsRaw && new URL(sltRaw).origin !== new URL(dsRaw).origin) { + throw new RangeError('sltUrl and dsUrl must use the same public DS origin'); + } + const raw = sltRaw ?? dsRaw!; const base = new URL(raw, globalThis.location?.href ?? 'http://localhost/'); const basePath = base.pathname.replace(/\/+$/, ''); const get = async (url: string): Promise => { @@ -244,7 +295,7 @@ export async function discoverSlsEndpoint(options: Pick Number(match[1])) + ? sltGroups(index, sltDirectory) : [options.sltGroup]; const documents = groups.length > 0 ? await Promise.all([...new Set(groups)].map((group) => get(new URL(String(group), sltDirectory).href))) : [index]; - const endpoints = documents.flatMap((document) => parseSltEndpoints(document, options.serviceId)); + const endpoints = documents.flatMap((document) => parseSltEndpoints(document, options.serviceId, new URL('/', base.origin))); const unique = [...new Map(endpoints.map((endpoint) => [`${endpoint.source ?? ''}\0${endpoint.group}\0${endpoint.port}`, endpoint])).values()]; if (unique.length !== 1) { const selector = options.serviceId === undefined ? 'serviceId' : `serviceId ${String(options.serviceId)}`; @@ -721,7 +772,7 @@ export class RouteMabrSession { const ingress = options.ingress ?? 'tuner'; const endpoints = options.slsEndpoint ? [options.slsEndpoint] : options.endpoints ?? (options.group && options.port != null ? [{group: options.group, port: options.port, ...(options.source ? {source: options.source} : {})}] : []); - if ((options.standard ?? 'atsc3') === 'atsc3' && endpoints.length === 0 && !options.sltUrl) { + if ((options.standard ?? 'atsc3') === 'atsc3' && endpoints.length === 0 && !options.sltUrl && !options.dsUrl) { throw new RangeError('group and port (or endpoints or sltUrl) are required for ATSC 3'); } if (endpoints.some((endpoint) => typeof endpoint.group !== 'string' || endpoint.group.trim() === '' || @@ -744,7 +795,7 @@ export class RouteMabrSession { if (ingress === 'amt' && ((!options.relay && options.relayPort !== undefined) || (options.relayPort !== undefined && (!Number.isInteger(options.relayPort) || options.relayPort < 1 || options.relayPort > 65535)) || - (!options.relay && !options.source && !options.sltUrl && + (!options.relay && !options.source && !options.sltUrl && !options.dsUrl && !endpoints.some((endpoint) => normalizeRouteSource(endpoint.source))))) { throw new RangeError('AMT ingress requires relay+relayPort, or an SSM source for RFC 8777 DRIAD'); } @@ -763,7 +814,7 @@ export class RouteMabrSession { const ingress = this.#options.ingress ?? 'tuner'; let endpoints = this.#options.slsEndpoint ? [this.#options.slsEndpoint] : this.#options.endpoints ?? (this.#options.group && this.#options.port != null ? [{group: this.#options.group, port: this.#options.port, ...(this.#options.source ? {source: this.#options.source} : {})}] : []); - if (endpoints.length === 0 && this.#options.sltUrl) { + if (endpoints.length === 0 && (this.#options.sltUrl || this.#options.dsUrl)) { endpoints = [await discoverSlsEndpoint(this.#options)]; } if (endpoints.length === 0) throw new RangeError('group and port (or endpoints or sltUrl) are required for ATSC 3'); @@ -772,7 +823,7 @@ export class RouteMabrSession { throw new RangeError('AMT DRIAD requires an SSM source in the discovered SLT or SLS endpoint'); } const exports = this.#options.wasm.exports; - const discoverEndpoints = this.#options.discoverEndpoints ?? Boolean(this.#options.slsEndpoint || this.#options.sltUrl); + const discoverEndpoints = this.#options.discoverEndpoints ?? Boolean(this.#options.slsEndpoint || this.#options.sltUrl || this.#options.dsUrl); const slsGroup = (this.#options.slsEndpoint?.group ?? endpoints[0]!.group).trim(); // Some senders omit sIpAddr on rendition RS entries even though the SLS // leg has an SSM source. AMT DRIAD is source-based, so carry that source From cf5677bffc58aa2c9fda8a73cb8397143d507cd9 Mon Sep 17 00:00:00 2001 From: Omar Ramadan Date: Wed, 28 Oct 2026 23:44:43 +0000 Subject: [PATCH 2/2] fix(mabr): validate DS manifest cache scope --- packages/mabr-route-proxy/README.md | 18 ++--- packages/mabr-route-proxy/src/route.test.ts | 77 +++++++++++++++++++++ packages/mabr-route-proxy/src/route.ts | 33 +++++++-- 3 files changed, 112 insertions(+), 16 deletions(-) diff --git a/packages/mabr-route-proxy/README.md b/packages/mabr-route-proxy/README.md index 9a429f4..8c2d5a2 100644 --- a/packages/mabr-route-proxy/README.md +++ b/packages/mabr-route-proxy/README.md @@ -9,14 +9,14 @@ CMAF objects. Shaka keeps its normal manifest and segment URLs: ```ts import { createRouteMabrSession, discoverSlsEndpoint } from '@blockcast/mabr-route-proxy'; -// ROUTE objects are mounted under baseUrl: an FDT Content-Location of -// `live/manifest.mpd` is served at `${baseUrl}live/manifest.mpd`. -const baseUrl = 'https://player.example/mabr/'; const toSls = await discoverSlsEndpoint({ sltUrl: 'https://live.example', serviceId: 'generic-service', }); if (!toSls.manifestUrl) throw new Error('DS SLS did not advertise a DASH/HLS manifest'); +// ROUTE Content-Location and the public DS manifest must share this cache +// scope; derive it from the URL signalled by the DS. +const baseUrl = new URL('.', toSls.manifestUrl).href; const proxy = createRouteMabrSession({ gateway: window.multicast, wasm: routeWasm, @@ -50,22 +50,22 @@ number. The selected service's `SvcInetUrl` supplies the DASH or HLS manifest URL, while its SLS leg supplies the first multicast join. Its hostname is never used as the AMT relay. The browser still receives ROUTE bytes through DIMT or AMT, and the S-TSID on that leg supplies the rendition `(S,G,port)` -values: +values. Manifest discovery is HTTPS-only; an HTTP discovery origin can still +be used for local testing, but it does not produce a `manifestUrl`. ```ts +const sls = await discoverSlsEndpoint({sltUrl: 'https://live.example', serviceId: 'generic-service'}); +if (!sls.manifestUrl) throw new Error('DS SLS did not advertise a DASH/HLS manifest'); const proxy = createRouteMabrSession({ gateway: window.multicast, wasm: routeWasm, ingress: 'native', // use 'amt' for AMT/DRIAD when SLS has an SSM source - sltUrl: 'https://live.example', // public DS authority; /slt/ is discovered - serviceId: 'generic-service', // globalServiceID or numeric serviceId + slsEndpoint: sls, discoverEndpoints: true, - baseUrl, + baseUrl: new URL('.', sls.manifestUrl).href, }); const restoreFetch = proxy.installFetch(); await proxy.start(); -const sls = await discoverSlsEndpoint({sltUrl: 'https://live.example', serviceId: 'generic-service'}); -if (!sls.manifestUrl) throw new Error('DS SLS did not advertise a DASH/HLS manifest'); await shakaPlayer.load(sls.manifestUrl); // .mpd or .m3u8 ``` diff --git a/packages/mabr-route-proxy/src/route.test.ts b/packages/mabr-route-proxy/src/route.test.ts index abc6735..b8a14eb 100644 --- a/packages/mabr-route-proxy/src/route.test.ts +++ b/packages/mabr-route-proxy/src/route.test.ts @@ -88,6 +88,17 @@ test('feeds ROUTE objects to DASH and HLS URLs and detaches cleanly', async () = assert.equal(listener, undefined); }); +test('fails loudly when the discovered manifest is outside the cache scope', async () => { + const session = createRouteMabrSession({ + gateway: { async subscribe() { throw new Error('must not subscribe'); } }, + wasm: fakeWasm(), + ingress: 'native', + slsEndpoint: { group: '232.1.1.66', port: 8000, manifestUrl: 'https://ds.example/live/manifest.mpd' }, + baseUrl: 'https://player.example/mabr/', + }); + await assert.rejects(session.start(), /outside baseUrl scope/); +}); + test('preserves completed ROUTE destination metadata for SLS/FDT routing', async () => { let listener: ((event: CustomEvent) => void) | undefined; let index = 0; @@ -325,6 +336,7 @@ test('discovers the service SLS leg from HTTP LLS/SLT', async () => { ingress: 'native', sltUrl: 'https://relay.test', serviceId: 'nasa-iss', + baseUrl: 'https://relay.test/live/', }); await session.start(); assert.deepEqual(urls, ['https://relay.test/slt/', 'https://relay.test/slt/7']); @@ -369,6 +381,25 @@ test('discovers an HLS manifest from a DS authority without a fixed SLT group', } }); +test('discovers a TO delivery path from the Blockcast SLT extension', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response( + '/live/manifest.mpd', + )) as typeof fetch; + try { + assert.deepEqual(await discoverSlsEndpoint({ + sltUrl: 'https://ds.example.test/slt/7', + serviceId: 'generic-service', + }), { + group: '232.1.1.66', + port: 8000, + manifestUrl: 'https://ds.example.test/live/manifest.mpd', + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('accepts dsUrl as the browser discovery bootstrap', async () => { const originalFetch = globalThis.fetch; const urls: string[] = []; @@ -387,6 +418,52 @@ test('accepts dsUrl as the browser discovery bootstrap', async () => { } }); +test('resolves relative discovery URLs before comparing their origins', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url === 'http://localhost/slt/7') { + return new Response(''); + } + return new Response('missing', {status: 404}); + }) as typeof fetch; + try { + assert.deepEqual(await discoverSlsEndpoint({ + sltUrl: '/slt/7', + dsUrl: 'http://localhost/', + serviceId: 'generic-service', + }), { + group: '232.1.1.66', + port: 8000, + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('rejects duplicate SLS legs with conflicting manifest URLs', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url === 'https://ds.example.test/slt/') return new Response('78'); + if (url === 'https://ds.example.test/slt/7') { + return new Response('/live/a.mpd'); + } + if (url === 'https://ds.example.test/slt/8') { + return new Response('/live/b.mpd'); + } + return new Response('missing', {status: 404}); + }) as typeof fetch; + try { + await assert.rejects( + discoverSlsEndpoint({sltUrl: 'https://ds.example.test', serviceId: 'generic-service'}), + /HTTP LLS advertised 2 ROUTE SLS endpoints/, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('does not return a cross-origin SvcInetUrl as the DS manifest', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = (async () => new Response( diff --git a/packages/mabr-route-proxy/src/route.ts b/packages/mabr-route-proxy/src/route.ts index e6126f7..d302cd6 100644 --- a/packages/mabr-route-proxy/src/route.ts +++ b/packages/mabr-route-proxy/src/route.ts @@ -208,7 +208,7 @@ function normalizeRouteSource(value?: string): string | undefined { function advertisedManifestUrl(body: string, authority: URL): string | undefined { const candidates: string[] = []; - const pattern = /<((?:[A-Za-z_][\w.-]*:)?SvcInetUrl)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1\s*>)/gi; + const pattern = /<((?:[A-Za-z_][\w.-]*:)?(?:SvcInetUrl|PresentationManifest))\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1\s*>)/gi; for (const match of body.matchAll(pattern)) { const text = xmlUnescape(match[3] ?? '').trim(); const attribute = xmlUnescape(xmlAttribute(match[2]!, 'url') || xmlAttribute(match[2]!, 'href')).trim(); @@ -219,8 +219,9 @@ function advertisedManifestUrl(body: string, authority: URL): string | undefined for (const candidate of candidates) { try { const parsed = new URL(candidate, authority); - // SvcInetUrl is consumed as the DS's public media URL. Do not allow an - // untrusted SLS document to redirect the page to another origin. + // The sender's SvcInetUrl/Blockcast PresentationManifest is consumed as + // the DS's public media URL. Do not allow an untrusted SLS document to + // redirect the page to another origin. if (parsed.protocol === 'https:' && parsed.origin === authority.origin) return parsed.href; } catch { // An optional malformed service URL must not hide a valid SLS leg. @@ -280,11 +281,13 @@ export async function discoverSlsEndpoint(options: Pick => { // The DS is public HTTP discovery, not a TO control-plane endpoint. Keep @@ -311,7 +314,10 @@ export async function discoverSlsEndpoint(options: Pick get(new URL(String(group), sltDirectory).href))) : [index]; const endpoints = documents.flatMap((document) => parseSltEndpoints(document, options.serviceId, new URL('/', base.origin))); - const unique = [...new Map(endpoints.map((endpoint) => [`${endpoint.source ?? ''}\0${endpoint.group}\0${endpoint.port}`, endpoint])).values()]; + const unique = [...new Map(endpoints.map((endpoint) => [ + `${endpoint.source ?? ''}\0${endpoint.group}\0${endpoint.port}\0${endpoint.manifestUrl ?? ''}`, + endpoint, + ])).values()]; if (unique.length !== 1) { const selector = options.serviceId === undefined ? 'serviceId' : `serviceId ${String(options.serviceId)}`; throw new Error(`HTTP LLS advertised ${unique.length} ROUTE SLS endpoints for ${selector}; select exactly one`); @@ -818,6 +824,7 @@ export class RouteMabrSession { endpoints = [await discoverSlsEndpoint(this.#options)]; } if (endpoints.length === 0) throw new RangeError('group and port (or endpoints or sltUrl) are required for ATSC 3'); + for (const endpoint of endpoints) this.#assertManifestScope(endpoint); if (ingress === 'amt' && !this.#options.relay && !endpoints.some((endpoint) => normalizeRouteSource(endpoint.source))) { throw new RangeError('AMT DRIAD requires an SSM source in the discovered SLT or SLS endpoint'); @@ -906,6 +913,18 @@ export class RouteMabrSession { } } + #assertManifestScope(endpoint: RouteEndpoint): void { + if (!endpoint.manifestUrl) return; + const baseUrl = this.#options.baseUrl ?? globalThis.location?.href ?? 'http://localhost/'; + const scope = new URL('.', baseUrl).href; + const manifestUrl = new URL(endpoint.manifestUrl, baseUrl).href; + if (!manifestUrl.startsWith(scope)) { + throw new RangeError( + `ROUTE manifest ${manifestUrl} is outside baseUrl scope ${scope}; set baseUrl to the DS manifest directory`, + ); + } + } + async stop(): Promise { this.#active = false; this.#sessionGeneration++;