mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
* 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
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
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',
|
|
});
|
|
}
|
|
);
|
|
});
|