From a57b51601136edc72a2413780513db8fdec513df Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 6 Sep 2026 08:25:12 +0200 Subject: [PATCH] fix(web-backend): separate provider metadata from connection authority --- apps/web-backend/src/app/host-guard.ts | 6 +- .../src/app/provider-axios-transport.ts | 108 ++++++++++++++++++ .../validated-http-client.integration.spec.ts | 35 +++++- .../src/app/validated-http-client.ts | 3 +- .../app/web-backend-app.host-guard.spec.ts | 79 ++++++------- apps/web-e2e/project.json | 1 + docs/architecture/host-connectivity-guard.md | 4 +- docs/architecture/nx-workspace-boundaries.md | 6 + docs/architecture/pwa-self-hosted.md | 10 +- 9 files changed, 200 insertions(+), 52 deletions(-) create mode 100644 apps/web-backend/src/app/provider-axios-transport.ts diff --git a/apps/web-backend/src/app/host-guard.ts b/apps/web-backend/src/app/host-guard.ts index e47f8c571..78ea157b1 100644 --- a/apps/web-backend/src/app/host-guard.ts +++ b/apps/web-backend/src/app/host-guard.ts @@ -191,6 +191,7 @@ export function reportProviderRequestFailure( return; } + const manualChain = error instanceof ProviderRequestError; if (error instanceof ProviderRequestError) { if (error.initialResponded) { guard.reportSuccess(token); @@ -207,7 +208,10 @@ export function reportProviderRequestFailure( // other response — otherwise an ordinary timeout, a probe that was // redirected to a dead destination, and another ordinary timeout // still read as two consecutive failures. - if (failedAfterRedirect(error, token, options.requestUrl)) { + if ( + !manualChain && + failedAfterRedirect(error, token, options.requestUrl) + ) { guard.reportSuccess(token); break; } diff --git a/apps/web-backend/src/app/provider-axios-transport.ts b/apps/web-backend/src/app/provider-axios-transport.ts new file mode 100644 index 000000000..99b6aaae0 --- /dev/null +++ b/apps/web-backend/src/app/provider-axios-transport.ts @@ -0,0 +1,108 @@ +import axios from 'axios'; +import { + request as httpRequest, + ClientRequest, + IncomingMessage, + RequestOptions, +} from 'node:http'; +import { + request as httpsRequest, + Agent as HttpsAgent, + AgentOptions, +} from 'node:https'; +import { isIP } from 'node:net'; +import { checkServerIdentity } from 'node:tls'; +import type { + ProviderTransportOptions, + WebBackendHttpClient, + WebBackendHttpResponse, +} from './validated-http-client'; + +/** + * Keep HTTP authority metadata separate from socket destination selection. + * Only the hop's pinned agent can select an address; axios receives a fixed + * logical hostname, never a user-selected connection authority. Host and TLS + * identity still describe the validated provider, including IP certificates. + */ +export class ProviderAxiosTransport implements WebBackendHttpClient { + async get( + url: string, + options: ProviderTransportOptions = {} + ): Promise> { + const target = new URL(axios.getUri({ url, params: options.params })); + const secure = target.protocol === 'https:'; + const hostname = target.hostname.replace(/^\[|\]$/g, ''); + const port = Number(target.port || (secure ? 443 : 80)); + const root = secure + ? 'https://provider.invalid/' + : 'http://provider.invalid/'; + if (!options.httpAgent || !options.httpsAgent) { + throw new Error('Provider transport requires pinned agents'); + } + const agent = options.httpsAgent as HttpsAgent & { + options: AgentOptions; + }; + agent.options.servername = isIP(hostname) ? '' : hostname; + agent.options.checkServerIdentity = (_name, certificate) => + checkServerIdentity(hostname, certificate); + const response = await axios.get(root, { + ...options, + params: undefined, + transport: { + request: ( + requestOptions: RequestOptions, + callback: (response: IncomingMessage) => void + ) => { + // Path is HTTP metadata, never parsed as an authority. + // Even //host/path remains a path on the pinned connection. + const request = (secure ? httpsRequest : httpRequest)( + { + ...requestOptions, + hostname: 'provider.invalid', + port, + path: target.pathname + target.search, + }, + callback + ); + retainHeaderTimeout(request, options.timeout); + return request; + }, + }, + headers: { ...options.headers, Host: target.host }, + }); + return { + ...response, + headers: { + location: + typeof response.headers['location'] === 'string' + ? response.headers['location'] + : undefined, + }, + }; + } +} + +/** Axios identifies native transports by identity; a custom one needs this timer. */ +function retainHeaderTimeout(request: ClientRequest, timeout?: number): void { + if (!timeout) return; + const timer = setTimeout( + () => + request.destroy( + Object.assign( + new Error('Provider response headers timed out'), + { code: 'ECONNABORTED' } + ) + ), + timeout + ); + timer.unref(); + const clear = () => { + clearTimeout(timer); + request.removeListener('response', clear); + request.removeListener('error', clear); + request.removeListener('close', clear); + }; + request.once('response', clear); + request.once('error', clear); + request.once('close', clear); +} diff --git a/apps/web-backend/src/app/validated-http-client.integration.spec.ts b/apps/web-backend/src/app/validated-http-client.integration.spec.ts index e3867f198..142257752 100644 --- a/apps/web-backend/src/app/validated-http-client.integration.spec.ts +++ b/apps/web-backend/src/app/validated-http-client.integration.spec.ts @@ -1,6 +1,6 @@ import { classifyHostRequestFailure } from '@iptvnator/shared/host-health'; import { ProviderRequestError } from './provider-request-error'; -import axios from 'axios'; +import { ProviderAxiosTransport } from './provider-axios-transport'; import express from 'express'; import { readFileSync } from 'node:fs'; import { createServer, Agent as HttpsAgent } from 'node:https'; @@ -116,6 +116,24 @@ describe('real axios validated transport', () => { }); } ); + it('preserves double-slash paths and escaped segments without changing connection authority', async () => { + const provider = express().use((req, res) => + res.json({ path: req.url, host: req.headers.host }) + ); + await withServer(provider, async (url) => { + const target = new URL(url); + await expect( + new ValidatedHttpClient(lan).get( + `${url}//other.example/a%2Fb?token=a%2Bb` + ) + ).resolves.toMatchObject({ + data: { + path: '//other.example/a%2Fb?token=a%2Bb', + host: target.host, + }, + }); + }); + }); it('keeps query serialization and sends only Location query on a real redirect', async () => { const requests: string[] = []; const provider = express().use((req, res) => { @@ -202,6 +220,19 @@ describe('real axios validated transport', () => { }); } ); + it('times out before headers on the custom native transport', async () => { + const provider = express().use(() => { + /* Deliberately silent synthetic provider. */ + }); + await withServer(provider, async (url) => { + await expect( + new ValidatedHttpClient(lan).get(url, { timeout: 100 }) + ).rejects.toMatchObject({ + initialResponded: false, + cause: { code: 'ECONNABORTED' }, + }); + }); + }); it('does not cap a healthy trickling body at the inactivity timeout', async () => { const provider = express().use((_req, res) => { let count = 0; @@ -245,7 +276,7 @@ describe('real axios validated transport', () => { // Supply the local test CA, retaining the production lookup, // SNI and rejectUnauthorized defaults on the actual agent. agent.options.ca = cert; - return axios.get(url, options); + return new ProviderAxiosTransport().get(url, options); }, }; await withListeningServer(server, '127.0.0.1', async (port) => { diff --git a/apps/web-backend/src/app/validated-http-client.ts b/apps/web-backend/src/app/validated-http-client.ts index 979632b3c..7fc4f4de2 100644 --- a/apps/web-backend/src/app/validated-http-client.ts +++ b/apps/web-backend/src/app/validated-http-client.ts @@ -1,3 +1,4 @@ +import { ProviderAxiosTransport } from './provider-axios-transport'; import { discardProviderBody, discardProviderErrorBody, @@ -60,7 +61,7 @@ const SENSITIVE_HEADERS = new Set([ export class ValidatedHttpClient implements WebBackendHttpClient { constructor( private readonly policy: ProviderUrlPolicy, - private readonly transport: WebBackendHttpClient = axios + private readonly transport: WebBackendHttpClient = new ProviderAxiosTransport() ) {} async get( diff --git a/apps/web-backend/src/app/web-backend-app.host-guard.spec.ts b/apps/web-backend/src/app/web-backend-app.host-guard.spec.ts index a03e69aee..91b9743ca 100644 --- a/apps/web-backend/src/app/web-backend-app.host-guard.spec.ts +++ b/apps/web-backend/src/app/web-backend-app.host-guard.spec.ts @@ -492,21 +492,11 @@ describe('web backend host connectivity guard', () => { }); it('does not fast-fail a provider whose redirect destination is dead', async () => { - // The shape this route actually produces, verified against axios - // 1.19.0: follow-redirects walks the chain inside one `get()`, so - // `config` still holds the URL we asked for and only - // `request._currentUrl` names the hop that failed. Reading `config.url` - // alone would compare the original URL with itself, find no redirect, - // and charge the dead destination to the provider that answered. - const redirectedFailure = () => - Object.assign(new Error('connect ECONNREFUSED'), { - code: 'ECONNREFUSED', - config: { url: 'http://xtream.example/player_api.php' }, - request: { _currentUrl: 'http://cdn.dead.example/stream' }, - }); const httpClient = new StubHttpClient(); - httpClient.queueNetworkError(redirectedFailure()); - httpClient.queueNetworkError(redirectedFailure()); + for (let i = 0; i < 2; i++) { + httpClient.queueRedirect('http://cdn.dead.example/stream'); + httpClient.queueNetworkError(hostLevelFailure()); + } httpClient.queueResponse({ user_info: { username: 'demo' } }); const { guard } = createTestGuard(); @@ -534,7 +524,7 @@ describe('web backend host connectivity guard', () => { action: 'get_account_info', payload: { user_info: { username: 'demo' } }, }); - expect(httpClient.requests).toHaveLength(3); + expect(httpClient.requests).toHaveLength(5); } ); }); @@ -689,35 +679,32 @@ describe('web backend host connectivity guard', () => { const started = new Promise((resolve) => { arrived = resolve; }); - const transport = jest - .spyOn(httpClient, 'get') - .mockImplementationOnce(async () => { - arrived(); - await pending; - if (outcome !== 'success') { - throw Object.assign( - hostLevelFailure( - outcome === 'cancel' - ? 'ERR_CANCELED' - : 'ETIMEDOUT' - ), - { - request: { - _currentUrl: - outcome === - 'redirect-failure' - ? 'http://cdn.example/slow' - : `http://portal.example/${route === 'xtream' ? 'player_api.php' : ''}`, - }, - } - ); - } - return { - data: [] as never, - status: 200, - headers: {}, - }; - }); + const transport = jest.spyOn(httpClient, 'get'); + if (outcome === 'redirect-failure') { + transport.mockImplementationOnce(async () => ({ + data: '' as never, + status: 302, + headers: { + location: 'http://cdn.example/slow', + }, + })); + } + transport.mockImplementationOnce(async () => { + arrived(); + await pending; + if (outcome !== 'success') { + throw hostLevelFailure( + outcome === 'cancel' + ? 'ERR_CANCELED' + : 'ETIMEDOUT' + ); + } + return { + data: [] as never, + status: 200, + headers: {}, + }; + }); const trial = call(); try { await started; @@ -733,7 +720,9 @@ describe('web backend host connectivity guard', () => { ).message ) ).toBe(true); - expect(transport).toHaveBeenCalledTimes(1); + expect(transport).toHaveBeenCalledTimes( + outcome === 'redirect-failure' ? 2 : 1 + ); } finally { settle(); await trial; diff --git a/apps/web-e2e/project.json b/apps/web-e2e/project.json index bba3cea4c..367cf8fb8 100644 --- a/apps/web-e2e/project.json +++ b/apps/web-e2e/project.json @@ -5,6 +5,7 @@ "sourceRoot": "apps/web-e2e/src", "implicitDependencies": [ "web", + "web-backend", "stalker-mock-server", "xtream-mock-server" ], diff --git a/docs/architecture/host-connectivity-guard.md b/docs/architecture/host-connectivity-guard.md index 0dceafbb4..956a8c5ce 100644 --- a/docs/architecture/host-connectivity-guard.md +++ b/docs/architecture/host-connectivity-guard.md @@ -146,7 +146,9 @@ policy refusals remain inconclusive. Error bodies never serialize that cause. path, not the query: axios `params` can append credentials absent from the caller's baseline. Unknown/unparseable URLs conservatively count as ordinary failures, and query-only redirects cannot be distinguished by this fallback. -The shared helper and Electron behavior are unchanged by the web-backend fix. +Wrapped web-backend requests use their explicit chain evidence and never infer +redirects from the transport's fixed logical hostname. The shared helper and +Electron behavior are unchanged by the web-backend fix. Known gap: the failing hop is not guarded either (it has no token of its own), so a permanently broken redirect chain keeps costing a full timeout. diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index d55cd9356..5dbb8d670 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -27,6 +27,12 @@ Do not invent a `test`, `build`, or `e2e` target because a similarly named project has one. Run affected lint/test/build targets that exist and the closest available E2E target for the changed behavior. +E2E applications must declare runtime dependencies even when they use HTTP +instead of TypeScript imports. `web-e2e` includes `web-backend` in +`implicitDependencies` so provider-proxy changes invalidate cached self-hosted +PWA tests; starting the backend through a `serve` dependency alone does not +make its source files inputs to the test hash. + ## Nx Dependency Updates Keep `nx` and every official `@nx/*` package on the same exact version. Run diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 808cddb8a..eed5d6c0b 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -166,8 +166,14 @@ chain; it still requires HTTP(S), no URL userinfo and concrete DNS addresses. Fresh HTTP/HTTPS agents pin socket lookup to the exact validated IPv4/IPv6 answer set for that hop. No second DNS query or pooled connection may substitute -an unvalidated address. The original hostname remains in the URL for Host, TLS -SNI and certificate hostname checks. Axios proxies and Node environment proxies +an unvalidated address. The default axios transport uses a fixed logical hostname (`provider.invalid`) +so its connection authority cannot come from user-controlled URL metadata; +the pinned lookup alone selects an IP. Its native request-options adapter sets +`path` separately, so even `//host/path` cannot replace the connection authority. +It also owns the deadline from dispatch through response headers, since axios's +built-in connection timer only covers its own native transport objects. The original provider host and port are +explicitly preserved in Host, TLS SNI and certificate identity checks (literal +IPs omit SNI but still verify the original IP). Axios proxies and Node environment proxies are disabled for these requests so a proxy cannot independently resolve the origin. Deployments that require an outbound HTTP proxy must use a different network arrangement; setting `HTTP_PROXY`/`HTTPS_PROXY` does not route provider