Files
iptvnator/apps/electron-backend/src/app/util/validated-axios.spec.ts
T
Salem 2c032cd3c8 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
2026-06-12 15:24:29 +02:00

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',
});
}
);
});