fix(web-backend): separate provider metadata from connection authority

This commit is contained in:
4gray committed 2026-09-06 08:25:12 +02:00
1 parent 2da2884ba2
commit a57b516011
9 files changed
+200 -52

No files matched your search

+5 -1
View File
@@ -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;
}
@@ -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<T>(
url: string,
options: ProviderTransportOptions = {}
): Promise<WebBackendHttpResponse<T>> {
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<T>(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);
}
@@ -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) => {
@@ -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<T>(
@@ -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<void>((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;
+1
View File
@@ -5,6 +5,7 @@
"sourceRoot": "apps/web-e2e/src",
"implicitDependencies": [
"web",
"web-backend",
"stalker-mock-server",
"xtream-mock-server"
],
+3 -1
View File
@@ -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.
@@ -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
+8 -2
View File
@@ -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