diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index defb790c4..3961241ca 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -1,16 +1,20 @@ name: 'IPTVnator CodeQL config' -# The mock servers are development/E2E fixtures. They bind to localhost, serve -# fabricated data, ship in no released artifact, and deliberately imitate the -# quirks of the upstream IPTV protocols — including reading a session token -# from a GET query string, which is what the real Stalker backend proxy does -# and therefore what the app must be tested against. +# The mock servers are development/E2E fixtures. They bind to loopback by +# default (HOST=0.0.0.0 is an explicit opt-in for pointing a phone/STB at +# them), serve fabricated data, ship in no released artifact, and deliberately +# imitate the quirks of the upstream IPTV protocols — including reading a +# session token from a GET query string, which is what the real Stalker +# backend proxy does and therefore what the app must be tested against. # # CodeQL's web-service hygiene rules (missing rate limiting, sensitive data in # GET requests) assume an internet-facing service and produce only false # positives here; a rate limiter on a fixture that the E2E suite hammers would -# actively break the tests. Injection, path-traversal and similar rules still -# apply to everything the app itself ships. +# actively break the tests. paths-ignore is all-or-nothing per path — CodeQL +# has no per-path rule filter — so this deliberately trades away injection/ +# path-traversal coverage for the two fixture apps, which parse no input +# beyond the local test driver. Everything the app itself ships keeps full +# coverage. paths-ignore: - apps/stalker-mock-server - apps/xtream-mock-server diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index 0b4d8f6ef..e78cf3e34 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -88,7 +88,7 @@ actually get wrong: | `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow | | `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list | | `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing | -| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials | +| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app cannot finish this flow yet (its `do_auth` path is dormant and sends empty credentials), so the scenario is exercised at the HTTP level only — it exists to receive the upcoming client-side `do_auth` work | | `` | **auto** | MAC bytes used as seed → deterministic unique dataset | ## Configuration diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts index e5b5c9a81..93b2876dc 100644 --- a/apps/stalker-mock-server/src/main.ts +++ b/apps/stalker-mock-server/src/main.ts @@ -14,6 +14,10 @@ import { } from './app/marketing-poster-url.js'; const PORT = parseInt(process.env['PORT'] ?? '3210', 10); +// Loopback by default: the fixture serves fabricated but unauthenticated +// content, so it should not be reachable from other hosts unless a dev +// explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at it). +const HOST = process.env['HOST'] ?? '127.0.0.1'; const app = express(); const MARKETING_POSTER_DIRECTORY = join( process.cwd(), @@ -228,7 +232,7 @@ process.on('unhandledRejection', (reason) => { process.stdin.resume(); process.stdin.on('end', () => { /* ignore stdin close */ }); -server.listen(PORT, () => { +server.listen(PORT, HOST, () => { const divider = '─'.repeat(62); console.log(`\n${divider}`); console.log(` 🎬 Stalker Mock Server → http://localhost:${PORT}`); diff --git a/apps/web-e2e/src/stalker-auth.e2e.ts b/apps/web-e2e/src/stalker-auth.e2e.ts deleted file mode 100644 index 4c82413aa..000000000 --- a/apps/web-e2e/src/stalker-auth.e2e.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { type APIRequestContext, type Page } from '@playwright/test'; -import { setInputValue } from './e2e-helpers'; -import { expect, test } from './fixtures'; -import { - getRegisteredProviderUrl, - interceptProviderTargetRegistration, -} from './provider-target-route'; - -/** - * Stalker full-portal authentication E2E. - * - * `stalker.e2e.ts` imports the portal through the tolerant `/portal.php` alias, - * which the app classifies as a "simple" portal: no handshake, no token, no - * watchdog. This file covers the other half — the canonical Ministra endpoint - * (`/stalker_portal/server/load.php`), which the mock server guards exactly - * like the real middleware: - * - * - every action except handshake/get_profile/get_localization/do_auth needs - * `Authorization: Bearer ` - * - a token only counts once `get_profile` has adopted it - * - auth failures come back as HTTP 200 with a plain-text body, never a 401 - * - * Tag: @stalker - */ - -const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; -const MOCK_SERVER = `http://localhost:${MOCK_PORT}`; -/** Canonical Ministra path — the app treats this as a full (authenticated) portal. */ -const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`; -const BACKEND_PROXY = `${MOCK_SERVER}/stalker`; - -const DEFAULT_MAC = '00:1A:79:00:00:01'; - -async function interceptStalkerRequests(page: Page): Promise { - const providerTargets = await interceptProviderTargetRegistration(page); - - await page.route('**/localhost:3000/stalker**', async (route) => { - const originalUrl = new URL(route.request().url()); - const mockUrl = new URL(BACKEND_PROXY); - const providerUrl = getRegisteredProviderUrl( - originalUrl, - providerTargets - ); - - if (providerUrl) { - mockUrl.searchParams.set('url', providerUrl); - } - - originalUrl.searchParams.forEach((value, key) => { - if (key === 'targetId') { - return; - } - mockUrl.searchParams.set(key, value); - }); - await route.continue({ url: mockUrl.toString() }); - }); -} - -async function resetMockServer(request: APIRequestContext): Promise { - for (let attempt = 0; attempt < 3; attempt += 1) { - const response = await request.post(`${MOCK_SERVER}/reset`); - if (response.ok()) { - return; - } - } - throw new Error('Could not reset the stalker mock server'); -} - -async function addFullStalkerPortal( - page: Page, - options: { name?: string; mac?: string } = {} -): Promise { - const { name = 'Full Stalker Portal', mac = DEFAULT_MAC } = options; - - await page.getByRole('button', { name: 'Add playlist' }).click(); - const dialog = page.locator('mat-dialog-container'); - await expect(dialog).toBeVisible(); - await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); - - await setInputValue(dialog.locator('input#title'), name); - await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL); - await setInputValue(dialog.locator('input#macAddress'), mac); - - const addButton = dialog.getByRole('button', { name: 'Add', exact: true }); - await expect(addButton).toBeEnabled({ timeout: 10_000 }); - await addButton.click(); - await expect(dialog).toBeHidden(); - await page.waitForURL(/stalker.*vod/, { timeout: 30_000 }); -} - -/** Portal actions the app sent, in order, as seen on the proxy boundary. */ -function recordPortalActions(page: Page): { - actions: string[]; - tokensByAction: Map; -} { - const actions: string[] = []; - const tokensByAction = new Map(); - - page.on('request', (request) => { - const url = new URL(request.url()); - if (!url.pathname.endsWith('/stalker')) { - return; - } - const action = url.searchParams.get('action'); - if (!action) { - return; - } - actions.push(action); - if (!tokensByAction.has(action)) { - tokensByAction.set(action, url.searchParams.get('token')); - } - }); - - return { actions, tokensByAction }; -} - -const CONTENT_ACTIONS = [ - 'get_categories', - 'get_genres', - 'get_ordered_list', - 'get_all_channels', -]; - -/** Every portal request in order, with the token it carried. */ -function recordPortalRequests( - page: Page -): Array<{ action: string; token: string | null }> { - const requests: Array<{ action: string; token: string | null }> = []; - - page.on('request', (request) => { - const url = new URL(request.url()); - if (!url.pathname.endsWith('/stalker')) { - return; - } - const action = url.searchParams.get('action'); - if (!action) { - return; - } - requests.push({ action, token: url.searchParams.get('token') }); - }); - - return requests; -} - -test.beforeEach(async ({ page, request }) => { - // Importing a full portal costs a handshake, a profile call and the first - // content load — on a cold dev server that alone approaches Playwright's - // 30s default budget, so give these tests explicit headroom. - test.setTimeout(90_000); - - await resetMockServer(request); - await page.goto('/'); - await interceptStalkerRequests(page); -}); - -test.describe('@stalker full portal authentication', () => { - test('handshakes and authenticates before loading content', async ({ - page, - }) => { - const { actions, tokensByAction } = recordPortalActions(page); - - await addFullStalkerPortal(page); - - // The portal only answers content actions for an adopted token, so - // reaching the VOD categories at all proves the whole chain ran. - await expect(page.locator('.category-item').first()).toBeVisible({ - timeout: 30_000, - }); - - expect(actions).toContain('handshake'); - expect(actions).toContain('get_profile'); - expect(actions.indexOf('handshake')).toBeLessThan( - actions.indexOf('get_profile') - ); - - const contentAction = actions.find((action) => - ['get_categories', 'get_genres'].includes(action) - ); - expect(contentAction).toBeDefined(); - expect(actions.indexOf('get_profile')).toBeLessThan( - actions.indexOf(contentAction as string) - ); - - // Content requests must carry the token; the handshake must not. - expect(tokensByAction.get('handshake')).toBeFalsy(); - expect(tokensByAction.get(contentAction as string)).toBeTruthy(); - }); - - test('never surfaces the portal plain-text auth failure as content', async ({ - page, - }) => { - await addFullStalkerPortal(page); - - // A body of "Authorization failed." must never be rendered — if the - // token pipeline breaks, the app has to fail loudly instead. - await expect(page.locator('body')).not.toContainText( - 'Authorization failed.' - ); - await expect(page.locator('body')).not.toContainText( - 'Unauthorized request.' - ); - }); - - test('re-authenticates after the portal drops the session', async ({ - page, - request, - }) => { - const requests = recordPortalRequests(page); - - await addFullStalkerPortal(page); - - // The token the initial import authenticated with — recovery must end - // up on a DIFFERENT one, or nothing was actually re-negotiated. - const tokenBeforeInvalidation = requests.find( - (entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token - )?.token; - expect(tokenBeforeInvalidation).toBeTruthy(); - - // Server-side session loss is what a real expired/replaced token looks - // like: the next request gets "Authorization failed." with HTTP 200. - const invalidated = await request.post( - `${MOCK_SERVER}/invalidate-session?macAddress=${encodeURIComponent( - DEFAULT_MAC - )}` - ); - expect(invalidated.ok()).toBe(true); - - const requestCountBeforeNavigation = requests.length; - - // Navigating to another content type forces a fresh portal request. - await page.getByRole('link', { name: /live|itv/i }).click(); - - // Recovery is only proven end to end when a CONTENT request goes out - // under a freshly negotiated token — a re-handshake alone could still - // leave the original request unreplayed or unauthorized. The mock only - // answers content for an adopted token, so this doubles as proof the - // new token was adopted via get_profile. - await expect - .poll( - () => - requests - .slice(requestCountBeforeNavigation) - .filter( - (entry) => - CONTENT_ACTIONS.includes(entry.action) && - entry.token && - entry.token !== tokenBeforeInvalidation - ).length, - { timeout: 30_000 } - ) - .toBeGreaterThan(0); - - const recovered = requests.slice(requestCountBeforeNavigation); - expect( - recovered.filter((entry) => entry.action === 'handshake').length - ).toBeGreaterThan(0); - - // And the recovered session must actually render: the ITV categories - // can only come from an authorized request against the new token. - await expect(page.locator('.category-item').first()).toBeVisible({ - timeout: 15_000, - }); - - await expect(page.locator('body')).not.toContainText( - 'Authorization failed.' - ); - }); -}); diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 9129bc0ca..ad79ca98f 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -22,11 +22,27 @@ import { * - 3 seasons × 8 episodes per series item * * Tag: @stalker — run only stalker tests with: nx e2e web-e2e --grep "@stalker" + * + * SERIAL BY DESIGN: every test here shares one mock-server process whose state + * (generated data, favorites, portal sessions) is global, and `beforeEach` + * wipes it with `POST /reset`. Under the workspace-wide `fullyParallel` preset + * those resets would race each other — and any sibling spec file — so this file + * pins itself to a single worker. Keep the full-portal authentication tests + * below in THIS file for the same reason: split across files they would run + * concurrently again and reset each other's sessions mid-assertion. */ +test.describe.configure({ mode: 'serial' }); + const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; const MOCK_SERVER = `http://localhost:${MOCK_PORT}`; const PORTAL_URL = `${MOCK_SERVER}/portal.php`; +/** + * Canonical Ministra path. `PORTAL_URL` above is classified by the app as a + * "simple" portal (no handshake, no token, no watchdog); this shape is the + * authenticated branch, which the mock guards like the real middleware. + */ +const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`; const BACKEND_PROXY = `${MOCK_SERVER}/stalker`; /** Default scenario MAC — balanced catalog, 8 categories, 40 items */ @@ -41,6 +57,16 @@ const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05'; /** Legacy pagination MAC — portal without get_all_channels support */ const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06'; +/** + * Dedicated MACs for the full-portal authentication tests. Mock state is keyed + * by MAC, so keeping these distinct from the content scenarios above means an + * auth test can never consume or invalidate a session another test relies on. + * The Infomir OUI matters: the strict endpoint validates the MAC format. + */ +const AUTH_FLOW_MAC = '00:1A:79:AD:00:01'; +const AUTH_TEXT_MAC = '00:1A:79:AD:00:02'; +const AUTH_REAUTH_MAC = '00:1A:79:AD:00:03'; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -130,6 +156,86 @@ async function addStalkerPortal( await page.waitForURL(/stalker.*vod/); } +/** + * Add a Stalker portal through the canonical Ministra URL, which the app + * imports as a FULL portal: handshake, Bearer token and watchdog. + */ +async function addFullStalkerPortal( + page: Page, + options: { name?: string; mac: string } +): Promise { + const { name = 'Full Stalker Portal', mac } = options; + + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); + + await setInputValue(dialog.locator('input#title'), name); + await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL); + await setInputValue(dialog.locator('input#macAddress'), mac); + + const addButton = dialog.getByRole('button', { name: 'Add', exact: true }); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); + await expect(dialog).toBeHidden(); + await page.waitForURL(/stalker.*vod/, { timeout: 30_000 }); +} + +/** Portal actions the app sent, in order, with the token each carried. */ +function recordPortalActions(page: Page): { + actions: string[]; + tokensByAction: Map; +} { + const actions: string[] = []; + const tokensByAction = new Map(); + + page.on('request', (request) => { + const url = new URL(request.url()); + if (!url.pathname.endsWith('/stalker')) { + return; + } + const action = url.searchParams.get('action'); + if (!action) { + return; + } + actions.push(action); + if (!tokensByAction.has(action)) { + tokensByAction.set(action, url.searchParams.get('token')); + } + }); + + return { actions, tokensByAction }; +} + +const CONTENT_ACTIONS = [ + 'get_categories', + 'get_genres', + 'get_ordered_list', + 'get_all_channels', +]; + +/** Every portal request in order, with the token it carried. */ +function recordPortalRequests( + page: Page +): Array<{ action: string; token: string | null }> { + const requests: Array<{ action: string; token: string | null }> = []; + + page.on('request', (request) => { + const url = new URL(request.url()); + if (!url.pathname.endsWith('/stalker')) { + return; + } + const action = url.searchParams.get('action'); + if (!action) { + return; + } + requests.push({ action, token: url.searchParams.get('token') }); + }); + + return requests; +} + // --------------------------------------------------------------------------- // Test setup // --------------------------------------------------------------------------- @@ -702,3 +808,143 @@ test('@stalker series — seasons load for a series item', async ({ // Default scenario has 8 episodes per season expect(seasons[0].series.length).toBe(8); }); + +/** + * Full-portal authentication. The tests above import through the tolerant + * `/portal.php` alias (simple portal, no auth); these use the canonical + * Ministra endpoint, which the mock guards like the real middleware: + * + * - every action except handshake/get_profile/get_localization/do_auth needs + * `Authorization: Bearer ` + * - a token only counts once `get_profile` has adopted it + * - auth failures come back as HTTP 200 with a plain-text body, never a 401 + */ +test.describe('@stalker full portal authentication', () => { + // Importing a full portal costs a handshake, a profile call and the first + // content load — on a cold dev server that alone approaches Playwright's + // 30s default budget. + test.beforeEach(() => { + test.setTimeout(90_000); + }); + + test('handshakes and authenticates before loading content', async ({ + page, + }) => { + const { actions, tokensByAction } = recordPortalActions(page); + + await addFullStalkerPortal(page, { mac: AUTH_FLOW_MAC }); + + // The portal only answers content actions for an adopted token, so + // reaching the VOD categories at all proves the whole chain ran. + await expect(page.locator('.category-item').first()).toBeVisible({ + timeout: 30_000, + }); + + expect(actions).toContain('handshake'); + expect(actions).toContain('get_profile'); + expect(actions.indexOf('handshake')).toBeLessThan( + actions.indexOf('get_profile') + ); + + const contentAction = actions.find((action) => + ['get_categories', 'get_genres'].includes(action) + ); + expect(contentAction).toBeDefined(); + expect(actions.indexOf('get_profile')).toBeLessThan( + actions.indexOf(contentAction as string) + ); + + // Content requests must carry the token; the handshake must not. + expect(tokensByAction.get('handshake')).toBeFalsy(); + expect(tokensByAction.get(contentAction as string)).toBeTruthy(); + + // The full-portal workflow must also keep the watchdog alive — an + // authenticated get_events fires immediately (init=1) on activation. + // Without this assertion the suite would stay green if the watchdog + // wiring silently died, because its failures are swallowed by design. + await expect + .poll(() => actions.includes('get_events'), { timeout: 30_000 }) + .toBe(true); + expect(tokensByAction.get('get_events')).toBeTruthy(); + }); + + test('never surfaces the portal plain-text auth failure as content', async ({ + page, + }) => { + await addFullStalkerPortal(page, { mac: AUTH_TEXT_MAC }); + + // A body of "Authorization failed." must never be rendered — if the + // token pipeline breaks, the app has to fail loudly instead. + await expect(page.locator('body')).not.toContainText( + 'Authorization failed.' + ); + await expect(page.locator('body')).not.toContainText( + 'Unauthorized request.' + ); + }); + + test('re-authenticates after the portal drops the session', async ({ + page, + request, + }) => { + const requests = recordPortalRequests(page); + + await addFullStalkerPortal(page, { mac: AUTH_REAUTH_MAC }); + + // The token the initial import authenticated with — recovery must end + // up on a DIFFERENT one, or nothing was actually re-negotiated. + const tokenBeforeInvalidation = requests.find( + (entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token + )?.token; + expect(tokenBeforeInvalidation).toBeTruthy(); + + // Server-side session loss is what a real expired/replaced token looks + // like: the next request gets "Authorization failed." with HTTP 200. + const invalidated = await request.post( + `${MOCK_SERVER}/invalidate-session?macAddress=${encodeURIComponent( + AUTH_REAUTH_MAC + )}` + ); + expect(invalidated.ok()).toBe(true); + + const requestCountBeforeNavigation = requests.length; + + // Navigating to another content type forces a fresh portal request. + await page.getByRole('link', { name: /live|itv/i }).click(); + + // Recovery is only proven end to end when a CONTENT request goes out + // under a freshly negotiated token — a re-handshake alone could still + // leave the original request unreplayed or unauthorized. The mock only + // answers content for an adopted token, so this doubles as proof the + // new token was adopted via get_profile. + await expect + .poll( + () => + requests + .slice(requestCountBeforeNavigation) + .filter( + (entry) => + CONTENT_ACTIONS.includes(entry.action) && + entry.token && + entry.token !== tokenBeforeInvalidation + ).length, + { timeout: 30_000 } + ) + .toBeGreaterThan(0); + + const recovered = requests.slice(requestCountBeforeNavigation); + expect( + recovered.filter((entry) => entry.action === 'handshake').length + ).toBeGreaterThan(0); + + // And the recovered session must actually render: the ITV categories + // can only come from an authorized request against the new token. + await expect(page.locator('.category-item').first()).toBeVisible({ + timeout: 15_000, + }); + + await expect(page.locator('body')).not.toContainText( + 'Authorization failed.' + ); + }); +}); diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts index 9e26995b1..0080c5ec0 100644 --- a/apps/xtream-mock-server/src/app/server.spec.ts +++ b/apps/xtream-mock-server/src/app/server.spec.ts @@ -195,7 +195,7 @@ describe('Xtream mock server factory', () => { describe('Xtream mock environment parsing', () => { it('uses safe defaults and enables control only for the exact flag', () => { expect(parseXtreamMockServerEnvironment({})).toEqual({ - host: '0.0.0.0', + host: '127.0.0.1', port: 3211, }); expect( @@ -225,7 +225,7 @@ describe('Xtream mock environment parsing', () => { IPTVNATOR_XTREAM_MOCK_CONTROL: 'true', IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'ignored', }) - ).toEqual({ host: '0.0.0.0', port: 3211 }); + ).toEqual({ host: '127.0.0.1', port: 3211 }); }); it.each([ diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts index a5aebc464..576e805cb 100644 --- a/apps/xtream-mock-server/src/app/server.ts +++ b/apps/xtream-mock-server/src/app/server.ts @@ -42,7 +42,10 @@ https://example.channels/path-to-file/4.m3u8 `; const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; const DEFAULT_PORT = 3211; -const DEFAULT_NORMAL_HOST = '0.0.0.0'; +// Loopback by default: the fixtures serve fabricated but unauthenticated +// content, so they should not be reachable from other hosts unless a dev +// explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at them). +const DEFAULT_NORMAL_HOST = '127.0.0.1'; const DEFAULT_CONTROL_HOST = '127.0.0.1'; const PERFORMANCE_USERNAME = 'performance'; const PERFORMANCE_PASSWORD = 'performance';