mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
fix(security): complete Electron hardening and review follow-ups
* fix(security): harden Electron IPC against MITM, SSRF, path and injection risks S1 TLS: validate certs by default on playlist/EPG fetches (opt-out via IPTVNATOR_ALLOW_INSECURE_TLS); new util/secure-https.ts. S2: write-file IPC restricted to save-dialog-authorized paths. S3: XTREAM_PROBE_URL guarded by assertRemoteUrlAllowed + maxRedirects:0; new events/url-safety.ts (+19 tests). S4: EPG titles rendered via interpolation, not [innerHTML]. S5: downloads reveal/play limited to recorded download paths. S6: Stalker cmd encoded (slash-preserving) to block query injection. EPG-worker and Stalker fetches reject file://-style/credentialed URLs; LAN/self-hosted targets remain allowed. * perf(player): lazy-load web video players via @defer Wrap Video.js/HTML5/ArtPlayer in @defer (on immediate) so video.js, hls.js, artplayer and mpegts.js split into a deferred chunk loaded on first playback instead of eagerly on the player route. Embedded MPV (native) stays eager. Spec uses DeferBlockBehavior.Playthrough. * fix(player): remove leaked HTML video listeners on destroy volumechange used a mismatched removeEventListener reference, while loadedmetadata and timeupdate were never removed at all. Bind all three to stable handler fields used for both add and remove, and add a teardown regression test asserting each listener is detached on destroy. * refactor(dashboard): extract pure navigation helpers from DashboardDataService Move the 8 stateless link/navigation-state/type-kind helpers into a new dashboard-navigation.util.ts so the routing logic is independently testable and the 1260-line god-service shrinks. DashboardDataService keeps the public methods as thin delegators (facade) so the public API and the single consumer (workspace-dashboard-rails) are unchanged. First slice of the DashboardDataService decomposition; verified by the existing service spec (33/33) and the app typecheck. * fix(review): address PR feedback (IPv6 link-local, write-path cap, @defer placeholder) - url-safety: broaden IPv6 link-local detection to the full fe80::/10 range (fe80:: through febf::), not just the fe80:: prefix (+ regression tests). - playlist.events: cap authorizedWritePaths (evict oldest past 32) so a save dialog opened without a following write cannot accumulate entries until restart. - web-player-view: add a @placeholder to each @defer (on immediate) player block to avoid the one-frame blank/layout-shift before the chunk resolves. * fix(security): close Electron network and download gaps * test(downloads): cover cancellation and restart cleanup * fix(downloads): address Greptile review gaps * test(security): reproduce remaining Greptile findings * fix(security): close remaining Greptile findings * test(downloads): reproduce early database queue stall * fix(downloads): release queue after setup failures * test(downloads): reproduce completion queue stall * fix(downloads): release queue after completion failures
This commit is contained in:
1 parent
7775553a6b
commit
2c032cd3c8
44 files changed
+3646
-1124
No files matched your search
@@ -0,0 +1,71 @@
|
||||
import { Agent } from 'node:https';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
import {
|
||||
createPlaylistAgentFactory,
|
||||
isInsecureTlsAllowed,
|
||||
} from './secure-https';
|
||||
|
||||
jest.mock('node:https', () => ({
|
||||
Agent: jest.fn(() => ({ protocol: 'https:' })),
|
||||
}));
|
||||
|
||||
describe('secure-https', () => {
|
||||
const originalValue = process.env.IPTVNATOR_ALLOW_INSECURE_TLS;
|
||||
const agentConstructorMock = Agent as unknown as jest.Mock;
|
||||
const lookup = jest.fn() as unknown as LookupFunction;
|
||||
|
||||
beforeEach(() => {
|
||||
agentConstructorMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalValue === undefined) {
|
||||
delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS;
|
||||
} else {
|
||||
process.env.IPTVNATOR_ALLOW_INSECURE_TLS = originalValue;
|
||||
}
|
||||
});
|
||||
|
||||
it('validates certificates by default', () => {
|
||||
delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS;
|
||||
|
||||
expect(isInsecureTlsAllowed()).toBe(false);
|
||||
createPlaylistAgentFactory().createHttpsAgent(lookup);
|
||||
|
||||
expect(agentConstructorMock).toHaveBeenCalledWith({
|
||||
lookup,
|
||||
rejectUnauthorized: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['1', 'true', ' TRUE '])(
|
||||
'allows an explicit insecure TLS opt-in via %s',
|
||||
(value) => {
|
||||
process.env.IPTVNATOR_ALLOW_INSECURE_TLS = value;
|
||||
|
||||
expect(isInsecureTlsAllowed()).toBe(true);
|
||||
createPlaylistAgentFactory().createHttpsAgent(lookup);
|
||||
|
||||
expect(agentConstructorMock).toHaveBeenCalledWith({
|
||||
lookup,
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('does not accept unrelated truthy values', () => {
|
||||
process.env.IPTVNATOR_ALLOW_INSECURE_TLS = 'yes';
|
||||
|
||||
expect(isInsecureTlsAllowed()).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves TLS policy when no pinned lookup is required', () => {
|
||||
delete process.env.IPTVNATOR_ALLOW_INSECURE_TLS;
|
||||
|
||||
createPlaylistAgentFactory().createHttpsAgent();
|
||||
|
||||
expect(agentConstructorMock).toHaveBeenCalledWith({
|
||||
rejectUnauthorized: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Agent } from 'node:https';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
import type { ValidatedRequestAgentFactory } from './validated-axios';
|
||||
|
||||
const INSECURE_TLS_ENV = 'IPTVNATOR_ALLOW_INSECURE_TLS';
|
||||
|
||||
/**
|
||||
* Whether TLS certificate validation is disabled for remote playlist fetches.
|
||||
*
|
||||
* Secure by default. Validation is only disabled when the operator explicitly
|
||||
* opts in via `IPTVNATOR_ALLOW_INSECURE_TLS=1` (or `=true`). Some IPTV
|
||||
* providers serve self-signed or otherwise invalid certificates, so the
|
||||
* opt-out exists — but it must never be the default, otherwise every playlist
|
||||
* fetch is silently exposed to network MITM.
|
||||
*/
|
||||
export function isInsecureTlsAllowed(): boolean {
|
||||
const value = process.env[INSECURE_TLS_ENV]?.trim().toLowerCase();
|
||||
return value === '1' || value === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the agent factory used for remote playlist fetches.
|
||||
*
|
||||
* Certificates are validated unless insecure TLS has been explicitly opted in
|
||||
* (see {@link isInsecureTlsAllowed}). The validated request layer supplies a
|
||||
* DNS lookup pinned to the addresses approved for each redirect hop.
|
||||
*/
|
||||
export function createPlaylistAgentFactory(): ValidatedRequestAgentFactory & {
|
||||
createHttpsAgent(lookup?: LookupFunction): Agent;
|
||||
} {
|
||||
const rejectUnauthorized = !isInsecureTlsAllowed();
|
||||
|
||||
return {
|
||||
createHttpsAgent: (lookup) => new Agent({ lookup, rejectUnauthorized }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import axios from 'axios';
|
||||
import type { LookupAddress, LookupOptions } from 'node:dns';
|
||||
import { Agent as HttpAgent } from 'node:http';
|
||||
import { Agent as HttpsAgent } from 'node:https';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
import { UnsafeUrlError } from '../events/url-safety';
|
||||
import {
|
||||
requestWithValidatedRedirects,
|
||||
type ValidatedRequestAgentFactory,
|
||||
} from './validated-axios';
|
||||
|
||||
jest.mock('axios', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const axiosMock = axios as unknown as jest.Mock;
|
||||
|
||||
describe('requestWithValidatedRedirects', () => {
|
||||
const publicResolver = async () => ['93.184.216.34'];
|
||||
|
||||
function createCapturingAgentFactory() {
|
||||
const lookups: LookupFunction[] = [];
|
||||
const factory: ValidatedRequestAgentFactory = {
|
||||
createHttpsAgent: jest.fn((lookup) => {
|
||||
if (!lookup) {
|
||||
throw new Error('Expected a pinned lookup');
|
||||
}
|
||||
lookups.push(lookup);
|
||||
return new HttpsAgent({ lookup });
|
||||
}),
|
||||
};
|
||||
|
||||
return { factory, lookups };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock.mockReset();
|
||||
});
|
||||
|
||||
it('rejects a redirect target that violates the URL policy', async () => {
|
||||
axiosMock.mockResolvedValueOnce({
|
||||
status: 302,
|
||||
headers: {
|
||||
location: 'http://169.254.169.254/latest/meta-data',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestWithValidatedRedirects(
|
||||
'https://example.com/player_api.php',
|
||||
{ method: 'GET' },
|
||||
{ resolveHostname: publicResolver }
|
||||
)
|
||||
).rejects.toBeInstanceOf(UnsafeUrlError);
|
||||
expect(axiosMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('pins the socket lookup to the address validated by the URL policy', async () => {
|
||||
axiosMock.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
headers: {},
|
||||
data: '#EXTM3U',
|
||||
});
|
||||
const { factory, lookups } = createCapturingAgentFactory();
|
||||
|
||||
await requestWithValidatedRedirects(
|
||||
'https://epg.example/guide.xml',
|
||||
{ agentFactory: factory, method: 'GET' },
|
||||
{
|
||||
resolveHostname: async () => ['93.184.216.34'],
|
||||
}
|
||||
);
|
||||
|
||||
const requestConfig = axiosMock.mock.calls[0][0];
|
||||
const resolvedAddress = await new Promise<{
|
||||
address: string | LookupAddress[];
|
||||
family?: number;
|
||||
}>((resolve, reject) => {
|
||||
lookups[0](
|
||||
'epg.example',
|
||||
{ all: false, family: 0 } as LookupOptions,
|
||||
(error, address, family) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ address, family });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
expect(factory.createHttpsAgent).toHaveBeenCalledWith(lookups[0]);
|
||||
expect(requestConfig.httpsAgent).toBeInstanceOf(HttpsAgent);
|
||||
expect(requestConfig).not.toHaveProperty('agentFactory');
|
||||
expect(resolvedAddress).toEqual({
|
||||
address: '93.184.216.34',
|
||||
family: 4,
|
||||
});
|
||||
expect(requestConfig.proxy).toBe(false);
|
||||
expect(requestConfig.url).toBe('https://epg.example/guide.xml');
|
||||
});
|
||||
|
||||
it('supplies the validated lookup to an explicit HTTP agent factory', async () => {
|
||||
axiosMock.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
headers: {},
|
||||
data: '#EXTM3U',
|
||||
});
|
||||
let pinnedLookup: LookupFunction | undefined;
|
||||
const factory: ValidatedRequestAgentFactory = {
|
||||
createHttpAgent: jest.fn((lookup) => {
|
||||
pinnedLookup = lookup;
|
||||
return new HttpAgent({ lookup });
|
||||
}),
|
||||
};
|
||||
|
||||
await requestWithValidatedRedirects(
|
||||
'http://playlist.example/list.m3u',
|
||||
{ agentFactory: factory, method: 'GET' },
|
||||
{ resolveHostname: publicResolver }
|
||||
);
|
||||
|
||||
const requestConfig = axiosMock.mock.calls[0][0];
|
||||
expect(factory.createHttpAgent).toHaveBeenCalledWith(pinnedLookup);
|
||||
expect(requestConfig.httpAgent).toBeInstanceOf(HttpAgent);
|
||||
expect(requestConfig).not.toHaveProperty('agentFactory');
|
||||
});
|
||||
|
||||
it('uses a custom HTTPS agent factory when private networks are allowed', async () => {
|
||||
axiosMock.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
headers: {},
|
||||
data: '#EXTM3U',
|
||||
});
|
||||
const factory: ValidatedRequestAgentFactory = {
|
||||
createHttpsAgent: jest.fn((lookup) => new HttpsAgent({ lookup })),
|
||||
};
|
||||
|
||||
await requestWithValidatedRedirects(
|
||||
'https://192.168.1.10/list.m3u',
|
||||
{ agentFactory: factory, method: 'GET' },
|
||||
{ allowPrivateNetworks: true }
|
||||
);
|
||||
|
||||
const requestConfig = axiosMock.mock.calls[0][0];
|
||||
expect(factory.createHttpsAgent).toHaveBeenCalledWith();
|
||||
expect(requestConfig.httpsAgent).toBeInstanceOf(HttpsAgent);
|
||||
expect(requestConfig).not.toHaveProperty('agentFactory');
|
||||
});
|
||||
|
||||
it('revalidates and repins every redirect hop', async () => {
|
||||
axiosMock
|
||||
.mockResolvedValueOnce({
|
||||
status: 302,
|
||||
headers: { location: 'https://cdn.example/guide.xml' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
headers: {},
|
||||
data: '<tv />',
|
||||
});
|
||||
const { factory, lookups } = createCapturingAgentFactory();
|
||||
const resolveHostname = jest.fn(async (hostname: string) =>
|
||||
hostname === 'epg.example' ? ['93.184.216.34'] : ['142.250.191.110']
|
||||
);
|
||||
|
||||
await requestWithValidatedRedirects(
|
||||
'https://epg.example/guide.xml',
|
||||
{ agentFactory: factory, method: 'GET' },
|
||||
{ resolveHostname }
|
||||
);
|
||||
|
||||
const resolvePinnedAddress = async (callIndex: number) => {
|
||||
return new Promise<string | LookupAddress[]>((resolve, reject) => {
|
||||
lookups[callIndex](
|
||||
'ignored.example',
|
||||
{ all: false, family: 0 } as LookupOptions,
|
||||
(error, address) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(address);
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
expect(resolveHostname).toHaveBeenNthCalledWith(1, 'epg.example');
|
||||
expect(resolveHostname).toHaveBeenNthCalledWith(2, 'cdn.example');
|
||||
expect(factory.createHttpsAgent).toHaveBeenCalledTimes(2);
|
||||
await expect(resolvePinnedAddress(0)).resolves.toBe('93.184.216.34');
|
||||
await expect(resolvePinnedAddress(1)).resolves.toBe('142.250.191.110');
|
||||
});
|
||||
|
||||
it('removes sensitive headers when a redirect changes origin', async () => {
|
||||
axiosMock
|
||||
.mockResolvedValueOnce({
|
||||
status: 302,
|
||||
headers: { location: 'https://cdn.example.net/playlist.m3u' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
headers: {},
|
||||
data: '#EXTM3U',
|
||||
});
|
||||
|
||||
await requestWithValidatedRedirects(
|
||||
'https://example.com/playlist.m3u',
|
||||
{
|
||||
headers: {
|
||||
Authorization: 'Bearer secret',
|
||||
Cookie: 'session=secret',
|
||||
Accept: 'text/plain',
|
||||
},
|
||||
method: 'GET',
|
||||
},
|
||||
{ resolveHostname: publicResolver }
|
||||
);
|
||||
|
||||
const redirectedConfig = axiosMock.mock.calls[1][0];
|
||||
expect(redirectedConfig.headers).toMatchObject({
|
||||
Accept: 'text/plain',
|
||||
});
|
||||
expect(redirectedConfig.headers).not.toHaveProperty('Authorization');
|
||||
expect(redirectedConfig.headers).not.toHaveProperty('Cookie');
|
||||
});
|
||||
|
||||
it('does not forward axios params to a cross-origin redirect', async () => {
|
||||
axiosMock
|
||||
.mockResolvedValueOnce({
|
||||
status: 302,
|
||||
headers: { location: 'https://cdn.example.net/playlist.m3u' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
headers: {},
|
||||
data: '#EXTM3U',
|
||||
});
|
||||
|
||||
await requestWithValidatedRedirects(
|
||||
'https://example.com/playlist.m3u',
|
||||
{
|
||||
method: 'GET',
|
||||
params: { token: 'secret' },
|
||||
},
|
||||
{ resolveHostname: publicResolver }
|
||||
);
|
||||
|
||||
expect(axiosMock.mock.calls[1][0].params).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not forward axios basic auth to a cross-origin redirect', async () => {
|
||||
axiosMock
|
||||
.mockResolvedValueOnce({
|
||||
status: 302,
|
||||
headers: { location: 'https://cdn.example.net/playlist.m3u' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
headers: {},
|
||||
data: '#EXTM3U',
|
||||
});
|
||||
|
||||
await requestWithValidatedRedirects(
|
||||
'https://example.com/playlist.m3u',
|
||||
{
|
||||
auth: { password: 'secret', username: 'provider' },
|
||||
method: 'GET',
|
||||
},
|
||||
{ resolveHostname: publicResolver }
|
||||
);
|
||||
|
||||
expect(axiosMock.mock.calls[1][0].auth).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a cross-origin redirect that would replay a request body', async () => {
|
||||
axiosMock.mockResolvedValueOnce({
|
||||
status: 307,
|
||||
headers: { location: 'https://other.example/submit' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestWithValidatedRedirects(
|
||||
'https://example.com/submit',
|
||||
{ data: { secret: true }, method: 'POST' },
|
||||
{ resolveHostname: publicResolver }
|
||||
)
|
||||
).rejects.toThrow(/request bodies/i);
|
||||
expect(axiosMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects redirects without a location header', async () => {
|
||||
axiosMock.mockResolvedValueOnce({
|
||||
status: 302,
|
||||
headers: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestWithValidatedRedirects(
|
||||
'https://example.com/start',
|
||||
{ method: 'GET' },
|
||||
{ resolveHostname: publicResolver }
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
message: expect.stringMatching(/location/i),
|
||||
status: 502,
|
||||
});
|
||||
});
|
||||
|
||||
it('stops after the configured redirect limit', async () => {
|
||||
axiosMock.mockResolvedValue({
|
||||
status: 302,
|
||||
headers: { location: '/again' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestWithValidatedRedirects(
|
||||
'https://example.com/start',
|
||||
{ method: 'GET' },
|
||||
{ resolveHostname: publicResolver },
|
||||
1
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
message: expect.stringMatching(/too many redirects/i),
|
||||
status: 502,
|
||||
});
|
||||
expect(axiosMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('converts a 303 redirect to GET and removes the request body', async () => {
|
||||
axiosMock
|
||||
.mockResolvedValueOnce({
|
||||
status: 303,
|
||||
headers: { location: '/result' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
headers: {},
|
||||
data: 'ok',
|
||||
});
|
||||
|
||||
await requestWithValidatedRedirects(
|
||||
'https://example.com/submit',
|
||||
{ data: { value: 1 }, method: 'POST' },
|
||||
{ resolveHostname: publicResolver }
|
||||
);
|
||||
|
||||
expect(axiosMock.mock.calls[1][0]).toMatchObject({
|
||||
data: undefined,
|
||||
method: 'GET',
|
||||
url: 'https://example.com/result',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([301, 302])(
|
||||
'converts a POST redirected with %i to GET and removes the request body',
|
||||
async (status) => {
|
||||
axiosMock
|
||||
.mockResolvedValueOnce({
|
||||
status,
|
||||
headers: { location: '/result' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
headers: {},
|
||||
data: 'ok',
|
||||
});
|
||||
|
||||
await requestWithValidatedRedirects(
|
||||
'https://example.com/submit',
|
||||
{ data: { value: 1 }, method: 'POST' },
|
||||
{ resolveHostname: publicResolver }
|
||||
);
|
||||
|
||||
expect(axiosMock.mock.calls[1][0]).toMatchObject({
|
||||
data: undefined,
|
||||
method: 'GET',
|
||||
url: 'https://example.com/result',
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import axios, {
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
RawAxiosRequestHeaders,
|
||||
} from 'axios';
|
||||
import type { LookupAddress } from 'node:dns';
|
||||
import { Agent as HttpAgent } from 'node:http';
|
||||
import { Agent as HttpsAgent } from 'node:https';
|
||||
import { isIP, LookupFunction } from 'node:net';
|
||||
import {
|
||||
RemoteUrlPolicy,
|
||||
UnsafeUrlError,
|
||||
validateRemoteUrl,
|
||||
} from '../events/url-safety';
|
||||
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
const SENSITIVE_HEADERS = new Set([
|
||||
'authorization',
|
||||
'cookie',
|
||||
'proxy-authorization',
|
||||
]);
|
||||
|
||||
export interface ValidatedRequestAgentFactory {
|
||||
createHttpAgent?(lookup?: LookupFunction): HttpAgent;
|
||||
createHttpsAgent?(lookup?: LookupFunction): HttpsAgent;
|
||||
}
|
||||
|
||||
export type ValidatedAxiosRequestConfig = Omit<
|
||||
AxiosRequestConfig,
|
||||
'httpAgent' | 'httpsAgent'
|
||||
> & {
|
||||
agentFactory?: ValidatedRequestAgentFactory;
|
||||
};
|
||||
|
||||
function copyHeadersWithoutSensitiveValues(
|
||||
headers: AxiosRequestConfig['headers']
|
||||
): AxiosRequestConfig['headers'] {
|
||||
if (!headers) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
const source =
|
||||
typeof (headers as { toJSON?: () => RawAxiosRequestHeaders }).toJSON ===
|
||||
'function'
|
||||
? (
|
||||
headers as {
|
||||
toJSON: () => RawAxiosRequestHeaders;
|
||||
}
|
||||
).toJSON()
|
||||
: headers;
|
||||
const sanitized: RawAxiosRequestHeaders = {};
|
||||
|
||||
for (const [name, value] of Object.entries(source)) {
|
||||
if (!SENSITIVE_HEADERS.has(name.toLowerCase())) {
|
||||
sanitized[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function createPinnedLookup(addresses: readonly string[]): LookupFunction {
|
||||
const records: LookupAddress[] = addresses.map((address) => ({
|
||||
address,
|
||||
family: isIP(address),
|
||||
}));
|
||||
|
||||
return (_hostname, options, callback) => {
|
||||
const requestedFamily = options.family;
|
||||
const eligibleRecords = requestedFamily
|
||||
? records.filter((record) => record.family === requestedFamily)
|
||||
: records;
|
||||
|
||||
if (eligibleRecords.length === 0) {
|
||||
const error = new Error(
|
||||
'Validated URL has no connectable address'
|
||||
) as NodeJS.ErrnoException;
|
||||
error.code = 'ENOTFOUND';
|
||||
callback(error, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.all) {
|
||||
callback(null, eligibleRecords);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = eligibleRecords[0];
|
||||
callback(null, selected.address, selected.family);
|
||||
};
|
||||
}
|
||||
|
||||
function pinRequestToValidatedAddresses(
|
||||
config: ValidatedAxiosRequestConfig,
|
||||
url: URL,
|
||||
addresses: readonly string[] | undefined
|
||||
): AxiosRequestConfig {
|
||||
const { agentFactory, ...axiosConfig } = config;
|
||||
if (!addresses) {
|
||||
if (url.protocol === 'https:' && agentFactory?.createHttpsAgent) {
|
||||
return {
|
||||
...axiosConfig,
|
||||
httpsAgent: agentFactory.createHttpsAgent(),
|
||||
};
|
||||
}
|
||||
if (url.protocol === 'http:' && agentFactory?.createHttpAgent) {
|
||||
return {
|
||||
...axiosConfig,
|
||||
httpAgent: agentFactory.createHttpAgent(),
|
||||
};
|
||||
}
|
||||
return axiosConfig;
|
||||
}
|
||||
|
||||
const lookup = createPinnedLookup(addresses);
|
||||
if (url.protocol === 'https:') {
|
||||
return {
|
||||
...axiosConfig,
|
||||
httpsAgent:
|
||||
agentFactory?.createHttpsAgent?.(lookup) ??
|
||||
new HttpsAgent({ lookup }),
|
||||
proxy: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...axiosConfig,
|
||||
httpAgent:
|
||||
agentFactory?.createHttpAgent?.(lookup) ??
|
||||
new HttpAgent({ lookup }),
|
||||
proxy: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an Axios request while validating the initial URL and every redirect.
|
||||
* Redirects are followed manually so each target passes through the same
|
||||
* private-network and protocol policy.
|
||||
*/
|
||||
export async function requestWithValidatedRedirects<T = unknown>(
|
||||
rawUrl: string,
|
||||
config: ValidatedAxiosRequestConfig = {},
|
||||
policy: RemoteUrlPolicy = {},
|
||||
maxRedirects = 5
|
||||
): Promise<AxiosResponse<T>> {
|
||||
const originalValidateStatus =
|
||||
config.validateStatus ??
|
||||
((status: number) => status >= 200 && status < 300);
|
||||
let currentUrl = rawUrl;
|
||||
let requestConfig = { ...config };
|
||||
|
||||
for (let redirectCount = 0; ; redirectCount += 1) {
|
||||
const validatedTarget = await validateRemoteUrl(currentUrl, policy);
|
||||
const validatedUrl = validatedTarget.url;
|
||||
const pinnedConfig = pinRequestToValidatedAddresses(
|
||||
requestConfig,
|
||||
validatedUrl,
|
||||
validatedTarget.addresses
|
||||
);
|
||||
const response = await axios<T>({
|
||||
...pinnedConfig,
|
||||
maxRedirects: 0,
|
||||
url: validatedUrl.toString(),
|
||||
validateStatus: (status) =>
|
||||
REDIRECT_STATUSES.has(status) || originalValidateStatus(status),
|
||||
});
|
||||
|
||||
if (!REDIRECT_STATUSES.has(response.status)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const location = response.headers?.location;
|
||||
if (!location) {
|
||||
throw new UnsafeUrlError(
|
||||
'Redirect response did not include a location',
|
||||
502
|
||||
);
|
||||
}
|
||||
if (redirectCount >= maxRedirects) {
|
||||
throw new UnsafeUrlError('Too many redirects', 502);
|
||||
}
|
||||
|
||||
const nextUrl = new URL(location, validatedUrl);
|
||||
const method = requestConfig.method?.toUpperCase();
|
||||
const shouldRewriteToGet =
|
||||
(response.status === 303 && method !== 'HEAD') ||
|
||||
((response.status === 301 || response.status === 302) &&
|
||||
method === 'POST');
|
||||
if (shouldRewriteToGet) {
|
||||
requestConfig = {
|
||||
...requestConfig,
|
||||
data: undefined,
|
||||
method: 'GET',
|
||||
};
|
||||
}
|
||||
if (nextUrl.origin !== validatedUrl.origin) {
|
||||
if (requestConfig.data !== undefined) {
|
||||
throw new UnsafeUrlError(
|
||||
'Cross-origin redirects with request bodies are not supported',
|
||||
502
|
||||
);
|
||||
}
|
||||
requestConfig = {
|
||||
...requestConfig,
|
||||
auth: undefined,
|
||||
headers: copyHeadersWithoutSensitiveValues(
|
||||
requestConfig.headers
|
||||
),
|
||||
params: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
currentUrl = nextUrl.toString();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user