Merge pull request #1005 from 4gray/agent/electron-security-hardening

feat(electron): harden renderer security
This commit is contained in:
4gray authored and GitHub committed 2026-05-23 19:37:24 +03:00
commit 87c1c6240d
16 files changed
+930 -73

No files matched your search

@@ -84,6 +84,23 @@ describe('main preload DB IPC contract', () => {
}
);
it('forwards scoped user-agent override arguments', async () => {
const api = getExposedApi();
await api.setUserAgent(
'ChannelAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/live.m3u8'
);
expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith(
'set-user-agent',
'ChannelAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/live.m3u8'
);
});
it('forwards request-scoped DB operation events and unregisters the listener', () => {
const api = getExposedApi();
const callback = jest.fn();
@@ -299,8 +299,11 @@ const electronApi = {
) => ipcRenderer.invoke('save-file-dialog', defaultPath, filters),
writeFile: (filePath: string, content: string) =>
ipcRenderer.invoke('write-file', filePath, content),
setUserAgent: (userAgent: string, referer?: string) =>
ipcRenderer.invoke('set-user-agent', userAgent, referer),
setUserAgent: (
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null
) => ipcRenderer.invoke('set-user-agent', userAgent, referer, scopeUrl),
openInMpv: (
url: string,
title: string,
+93
View File
@@ -0,0 +1,93 @@
jest.mock('electron', () => ({
app: {
getPath: jest.fn(() => '/tmp'),
isPackaged: false,
},
BrowserWindow: jest.fn(),
Menu: {
buildFromTemplate: jest.fn(),
},
screen: {
getPrimaryDisplay: jest.fn(),
},
shell: {
openExternal: jest.fn(),
},
}));
jest.mock('./services/store.service', () => ({
store: {
get: jest.fn(),
set: jest.fn(),
},
WINDOW_BOUNDS: 'windowBounds',
}));
import {
getMainWindowWebPreferences,
isExternalBrowserUrl,
isTrustedRendererNavigationUrl,
} from './app';
describe('Electron app security helpers', () => {
it('creates an explicitly hardened BrowserWindow webPreferences object', () => {
expect(getMainWindowWebPreferences()).toEqual(
expect.objectContaining({
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
backgroundThrottling: false,
})
);
});
it('treats only http and https URLs as external browser URLs', () => {
expect(isExternalBrowserUrl('https://example.com')).toBe(true);
expect(isExternalBrowserUrl('http://example.com')).toBe(true);
expect(isExternalBrowserUrl('file:///tmp/index.html')).toBe(false);
expect(isExternalBrowserUrl('javascript:alert(1)')).toBe(false);
expect(isExternalBrowserUrl('not a url')).toBe(false);
});
it('allows only the dev server origin in development navigation', () => {
expect(
isTrustedRendererNavigationUrl('http://localhost:4200/home', true)
).toBe(true);
expect(
isTrustedRendererNavigationUrl('http://127.0.0.1:4200/home', true)
).toBe(true);
expect(
isTrustedRendererNavigationUrl('http://[::1]:4200/home', true)
).toBe(true);
expect(
isTrustedRendererNavigationUrl('http://localhost:4300/home', true)
).toBe(false);
expect(
isTrustedRendererNavigationUrl('https://example.com', true)
).toBe(false);
expect(
isTrustedRendererNavigationUrl('file:///tmp/index.html', true)
).toBe(false);
});
it('allows only the packaged renderer file in packaged navigation', () => {
expect(
isTrustedRendererNavigationUrl(
'file:///tmp/iptvnator/index.html',
false,
'/tmp/iptvnator/index.html'
)
).toBe(true);
expect(
isTrustedRendererNavigationUrl(
'file:///tmp/other/index.html',
false,
'/tmp/iptvnator/index.html'
)
).toBe(false);
expect(
isTrustedRendererNavigationUrl('https://example.com', false)
).toBe(false);
});
});
+107 -18
View File
@@ -1,5 +1,6 @@
import { app, BrowserWindow, Menu, screen, shell } from 'electron';
import { join } from 'path';
import { join, resolve } from 'path';
import { fileURLToPath } from 'url';
import { rendererAppName, rendererAppPort } from './constants';
import {
isRendererConsoleTraceEnabled,
@@ -8,6 +9,84 @@ import {
} from './services/debug-trace';
import { store, WINDOW_BOUNDS } from './services/store.service';
const externalBrowserProtocols = new Set(['http:', 'https:']);
const trustedDevRendererHosts = new Set([
'localhost',
'127.0.0.1',
'[::1]',
'::1',
]);
function parseUrl(url: string): URL | null {
try {
return new URL(url);
} catch {
return null;
}
}
function getPackagedRendererIndexPath(): string {
return resolve(__dirname, '..', rendererAppName, 'index.html');
}
function getFilePathFromUrl(url: URL): string | null {
try {
return fileURLToPath(url);
} catch {
return null;
}
}
export function isExternalBrowserUrl(url: string): boolean {
const parsedUrl = parseUrl(url);
return Boolean(
parsedUrl && externalBrowserProtocols.has(parsedUrl.protocol)
);
}
export function isTrustedRendererNavigationUrl(
url: string,
isDevelopmentMode: boolean,
packagedRendererIndexPath = getPackagedRendererIndexPath()
): boolean {
const parsedUrl = parseUrl(url);
if (!parsedUrl) {
return false;
}
if (parsedUrl.protocol === 'file:') {
const filePath = getFilePathFromUrl(parsedUrl);
if (isDevelopmentMode || !filePath) {
return false;
}
return resolve(filePath) === resolve(packagedRendererIndexPath);
}
if (!isDevelopmentMode) {
return false;
}
return (
parsedUrl.protocol === 'http:' &&
trustedDevRendererHosts.has(parsedUrl.hostname) &&
parsedUrl.port === String(rendererAppPort)
);
}
export function getMainWindowWebPreferences(): Electron.BrowserWindowConstructorOptions['webPreferences'] {
return {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
backgroundThrottling: false,
preload: join(__dirname, 'main.preload.js'),
};
}
function attachWindowTrace(mainWindow: Electron.BrowserWindow): void {
if (!isWindowTraceEnabled()) {
return;
@@ -125,14 +204,6 @@ export default class App {
App.mainWindow = null;
}
private static onRedirect(event: any, url: string) {
if (url !== App.mainWindow.webContents.getURL()) {
// this is a normal external redirect, open it in a new browser window
event.preventDefault();
shell.openExternal(url);
}
}
private static onReady() {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
@@ -151,6 +222,21 @@ export default class App {
}
}
private static handleRendererNavigation(
event: Electron.Event,
url: string
): void {
if (isTrustedRendererNavigationUrl(url, App.isDevelopmentMode())) {
return;
}
event.preventDefault();
if (isExternalBrowserUrl(url)) {
shell.openExternal(url);
}
}
private static initMainWindow() {
const workAreaSize = screen.getPrimaryDisplay().workAreaSize;
const width = Math.min(1280, workAreaSize.width || 1280);
@@ -164,11 +250,7 @@ export default class App {
width: width,
height: height,
show: false,
webPreferences: {
contextIsolation: true,
backgroundThrottling: false,
preload: join(__dirname, 'main.preload.js'),
},
webPreferences: getMainWindowWebPreferences(),
...savedWindowBounds,
minHeight: 600,
minWidth: 900,
@@ -193,12 +275,21 @@ export default class App {
// Route target="_blank" / window.open() to the OS default browser
App.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//.test(url)) {
if (isExternalBrowserUrl(url)) {
shell.openExternal(url);
}
return { action: 'deny' };
});
App.mainWindow.webContents.on(
'will-navigate',
App.handleRendererNavigation
);
App.mainWindow.webContents.on(
'will-redirect',
App.handleRendererNavigation
);
// Emitted when the window is closed.
App.mainWindow.on('closed', () => {
// Dereference the window object, usually you would store windows
@@ -259,9 +350,7 @@ export default class App {
App.mainWindow.webContents.openDevTools();
}
} else {
App.mainWindow.loadFile(
join(__dirname, '..', rendererAppName, 'index.html')
);
App.mainWindow.loadFile(getPackagedRendererIndexPath());
}
}
@@ -1,4 +1,5 @@
import { ipcMain, session } from 'electron';
import { ipcMain } from 'electron';
import { configureRequestHeaderOverride } from '../services/request-header-overrides.service';
export default class SharedEvents {
static bootstrapSharedEvents(): Electron.IpcMain {
@@ -6,34 +7,21 @@ export default class SharedEvents {
}
}
ipcMain.handle('set-user-agent', (event, userAgent, referer) => {
setUserAgent(userAgent, referer); // TODO: test if defaults needed
ipcMain.handle('set-user-agent', (_event, userAgent, referer, scopeUrl) => {
setUserAgent(userAgent, referer, scopeUrl);
return true;
});
/**
* Sets the user agent header for all http requests
* Sets scoped request headers for the currently selected stream.
* @param userAgent user agent to use
* @param referer referer to use
* @param scopeUrl stream URL used to limit the override to the active origin
*/
export function setUserAgent(userAgent: string, referer?: string): void {
if (userAgent === undefined || userAgent === null || userAgent === '') {
userAgent = this.defaultUserAgent;
}
// Remove trailing slash from referer if it exists
let originURL: string;
if (referer?.endsWith('/')) {
originURL = referer.slice(0, -1);
}
session.defaultSession.webRequest.onBeforeSendHeaders(
(details, callback) => {
details.requestHeaders['User-Agent'] = userAgent;
details.requestHeaders['Referer'] = referer as string;
details.requestHeaders['Origin'] = originURL as string;
callback({ requestHeaders: details.requestHeaders });
}
);
console.log(`Success: Set "${userAgent}" as user agent header`);
export function setUserAgent(
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null
): void {
configureRequestHeaderOverride(userAgent, referer, scopeUrl);
}
@@ -0,0 +1,277 @@
type HeaderListener = (
details: Electron.OnBeforeSendHeadersListenerDetails,
callback: (beforeSendResponse: Electron.BeforeSendResponse) => void
) => void;
const mockOnBeforeSendHeaders = jest.fn();
jest.mock('electron', () => ({
session: {
defaultSession: {
webRequest: {
onBeforeSendHeaders: mockOnBeforeSendHeaders,
},
},
},
}));
function createRequestDetails(
url: string,
requestHeaders: Record<string, string> = {}
): Electron.OnBeforeSendHeadersListenerDetails {
return {
id: 1,
method: 'GET',
referrer: '',
requestHeaders,
resourceType: 'media',
timestamp: Date.now(),
uploadData: [],
url,
webContentsId: 1,
webContents: undefined,
} as Electron.OnBeforeSendHeadersListenerDetails;
}
function runHeaderListener(
listener: HeaderListener,
url: string,
requestHeaders: Record<string, string> = {}
): Record<string, string> {
let response: Electron.BeforeSendResponse | undefined;
listener(createRequestDetails(url, requestHeaders), (nextResponse) => {
response = nextResponse;
});
if (!response?.requestHeaders) {
throw new Error('Expected request headers response');
}
return response.requestHeaders as Record<string, string>;
}
describe('request header overrides', () => {
beforeEach(() => {
jest.resetModules();
mockOnBeforeSendHeaders.mockClear();
});
it('registers one listener and updates the active scoped headers', async () => {
const { configureRequestHeaderOverride } =
await import('./request-header-overrides.service');
configureRequestHeaderOverride(
'FirstAgent/1.0',
'https://portal.example/',
'https://stream.example/live.m3u8'
);
configureRequestHeaderOverride(
'SecondAgent/2.0',
'https://portal.example/referrer',
'https://stream.example/live.m3u8'
);
expect(mockOnBeforeSendHeaders).toHaveBeenCalledTimes(1);
expect(mockOnBeforeSendHeaders).toHaveBeenCalledWith(
{ urls: ['http://*/*', 'https://*/*'] },
expect.any(Function)
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(
listener,
'https://stream.example/segment.ts'
);
expect(headers).toEqual({
Origin: 'https://portal.example',
Referer: 'https://portal.example/referrer',
'User-Agent': 'SecondAgent/2.0',
});
});
it('does not apply scoped headers to unrelated origins', async () => {
const { configureRequestHeaderOverride } =
await import('./request-header-overrides.service');
configureRequestHeaderOverride(
'ScopedAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/live.m3u8'
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(
listener,
'https://unrelated.example/segment.ts',
{ Accept: '*/*' }
);
expect(headers).toEqual({ Accept: '*/*' });
});
it('applies playlist-level headers broadly when no stream scope is provided', async () => {
const { configureRequestHeaderOverride } =
await import('./request-header-overrides.service');
configureRequestHeaderOverride(
'PlaylistAgent/1.0',
'https://portal.example/referrer'
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(
listener,
'https://cdn.example/segment.ts'
);
expect(headers).toEqual({
Origin: 'https://portal.example',
Referer: 'https://portal.example/referrer',
'User-Agent': 'PlaylistAgent/1.0',
});
});
it('applies playlist-level user agents broadly without a referrer', async () => {
const { configureRequestHeaderOverride } =
await import('./request-header-overrides.service');
configureRequestHeaderOverride('PlaylistAgent/1.0');
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(
listener,
'https://cdn.example/segment.ts'
);
expect(headers).toEqual({
'User-Agent': 'PlaylistAgent/1.0',
});
});
it('keeps playlist headers when a channel without headers clears scoped overrides', async () => {
const { configureRequestHeaderOverride } =
await import('./request-header-overrides.service');
configureRequestHeaderOverride(
'PlaylistAgent/1.0',
'https://portal.example/referrer'
);
configureRequestHeaderOverride(
'ChannelAgent/2.0',
'https://channel.example/referrer',
'https://stream.example/live.m3u8'
);
configureRequestHeaderOverride(
null,
null,
'https://stream.example/next.m3u8'
);
expect(mockOnBeforeSendHeaders).toHaveBeenCalledTimes(1);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(
listener,
'https://stream.example/segment.ts'
);
expect(headers).toEqual({
Origin: 'https://portal.example',
Referer: 'https://portal.example/referrer',
'User-Agent': 'PlaylistAgent/1.0',
});
});
it('layers scoped channel headers over playlist defaults', async () => {
const { configureRequestHeaderOverride } =
await import('./request-header-overrides.service');
configureRequestHeaderOverride(
'PlaylistAgent/1.0',
'https://portal.example/referrer'
);
configureRequestHeaderOverride(
'ChannelAgent/2.0',
null,
'https://stream.example/live.m3u8'
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(
listener,
'https://stream.example/segment.ts'
);
expect(headers).toEqual({
Origin: 'https://portal.example',
Referer: 'https://portal.example/referrer',
'User-Agent': 'ChannelAgent/2.0',
});
});
it('clears active header overrides without registering another listener', async () => {
const { clearRequestHeaderOverride, configureRequestHeaderOverride } =
await import('./request-header-overrides.service');
configureRequestHeaderOverride(
'PlaylistAgent/1.0',
'https://portal.example/referrer'
);
configureRequestHeaderOverride(
'ScopedAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/live.m3u8'
);
clearRequestHeaderOverride();
expect(mockOnBeforeSendHeaders).toHaveBeenCalledTimes(1);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(
listener,
'https://stream.example/segment.ts',
{ Accept: '*/*' }
);
expect(headers).toEqual({ Accept: '*/*' });
});
it('does not register a listener when cleared before any override exists', async () => {
const { clearRequestHeaderOverride } =
await import('./request-header-overrides.service');
clearRequestHeaderOverride();
expect(mockOnBeforeSendHeaders).not.toHaveBeenCalled();
});
it('replaces existing header names case-insensitively', async () => {
const { configureRequestHeaderOverride } =
await import('./request-header-overrides.service');
configureRequestHeaderOverride(
'ScopedAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/live.m3u8'
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(
listener,
'https://stream.example/segment.ts',
{
origin: 'https://old.example',
referer: 'https://old.example/ref',
'user-agent': 'OldAgent/0.1',
}
);
expect(headers).toEqual({
Origin: 'https://portal.example',
Referer: 'https://portal.example/referrer',
'User-Agent': 'ScopedAgent/1.0',
});
});
});
@@ -0,0 +1,154 @@
import { session } from 'electron';
type HeaderOverride = {
origin?: string;
referer?: string;
scopeOrigins?: Set<string>;
userAgent?: string;
};
const headerOverrideUrlFilter = {
urls: ['http://*/*', 'https://*/*'],
};
let activeHeaderOverride: HeaderOverride | null = null;
let activeScopedHeaderOverride: HeaderOverride | null = null;
let listenerRegistered = false;
function normalizeHeaderValue(value?: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function getOrigin(value?: string | null): string | undefined {
const normalizedValue = normalizeHeaderValue(value);
if (!normalizedValue) {
return undefined;
}
try {
return new URL(normalizedValue).origin;
} catch {
return undefined;
}
}
function shouldApplyOverride(url: string, override: HeaderOverride): boolean {
if (!override.scopeOrigins) {
return true;
}
const requestOrigin = getOrigin(url);
return Boolean(requestOrigin && override.scopeOrigins.has(requestOrigin));
}
function setRequestHeader(
requestHeaders: Record<string, string>,
headerName: string,
headerValue: string
): void {
const normalizedHeaderName = headerName.toLowerCase();
const existingHeaderName = Object.keys(requestHeaders).find(
(name) => name.toLowerCase() === normalizedHeaderName
);
if (existingHeaderName) {
delete requestHeaders[existingHeaderName];
}
requestHeaders[headerName] = headerValue;
}
function handleBeforeSendHeaders(
details: Electron.OnBeforeSendHeadersListenerDetails,
callback: (beforeSendResponse: Electron.BeforeSendResponse) => void
): void {
const requestHeaders = { ...details.requestHeaders };
const overrides = [activeHeaderOverride, activeScopedHeaderOverride].filter(
(override): override is HeaderOverride =>
Boolean(override && shouldApplyOverride(details.url, override))
);
if (overrides.length === 0) {
callback({ requestHeaders });
return;
}
for (const override of overrides) {
if (override.userAgent) {
setRequestHeader(requestHeaders, 'User-Agent', override.userAgent);
}
if (override.referer) {
setRequestHeader(requestHeaders, 'Referer', override.referer);
}
if (override.origin) {
setRequestHeader(requestHeaders, 'Origin', override.origin);
}
}
callback({ requestHeaders });
}
function ensureHeaderOverrideListener(): void {
if (listenerRegistered) {
return;
}
session.defaultSession.webRequest.onBeforeSendHeaders(
headerOverrideUrlFilter,
handleBeforeSendHeaders
);
listenerRegistered = true;
}
export function configureRequestHeaderOverride(
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null
): void {
const normalizedUserAgent = normalizeHeaderValue(userAgent);
const normalizedReferer = normalizeHeaderValue(referer);
const isScopedOverride = scopeUrl !== undefined && scopeUrl !== null;
if (!normalizedUserAgent && !normalizedReferer) {
if (isScopedOverride) {
clearScopedRequestHeaderOverride();
} else {
clearRequestHeaderOverride();
}
return;
}
const refererOrigin = getOrigin(normalizedReferer);
const scopeOrigin = getOrigin(scopeUrl);
const override: HeaderOverride = {
origin: refererOrigin,
referer: normalizedReferer,
userAgent: normalizedUserAgent,
};
if (isScopedOverride) {
override.scopeOrigins = new Set(
[scopeOrigin, refererOrigin].filter((origin): origin is string =>
Boolean(origin)
)
);
activeScopedHeaderOverride = override;
} else {
activeHeaderOverride = override;
}
ensureHeaderOverrideListener();
}
export function clearRequestHeaderOverride(): void {
activeHeaderOverride = null;
activeScopedHeaderOverride = null;
}
function clearScopedRequestHeaderOverride(): void {
activeScopedHeaderOverride = null;
}
+4
View File
@@ -6,6 +6,10 @@
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; base-uri 'self'; object-src 'none'; frame-src 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http: https: file: data: blob:; font-src 'self' data:; media-src 'self' http: https: file: data: blob:; connect-src 'self' http: https: ws: wss: blob: data:; worker-src 'self' blob:"
/>
<meta property="og:type" content="website" />
<meta property="og:title" content="IPTVnator" />
<meta property="og:url" content="https://iptvnator.vercel.app" />
+5 -1
View File
@@ -67,7 +67,11 @@ declare global {
filePath: string,
content: string
) => Promise<{ success: boolean }>;
setUserAgent: (userAgent: string, referer?: string) => void;
setUserAgent: (
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null
) => Promise<boolean>;
openInMpv: (
url: string,
title: string,
+82
View File
@@ -0,0 +1,82 @@
# Electron Security Contract
This document records the Electron runtime security contract for the desktop app.
## BrowserWindow Defaults
The main window is created in `apps/electron-backend/src/app/app.ts` with an
explicit hardened `webPreferences` object:
- `contextIsolation: true`
- `nodeIntegration: false`
- `sandbox: true`
- `webSecurity: true`
- `preload: apps/electron-backend/src/app/api/main.preload.ts`
Renderer code must use the preload bridge exposed as `window.electron`.
Do not re-enable direct Node.js access from Angular code. New desktop-only APIs
should be added to the preload bridge and backed by an `ipcMain.handle(...)`
owner in the Electron backend.
## Navigation And External URLs
The main window owns three navigation gates:
- `setWindowOpenHandler` denies every new window. `http:` and `https:` targets
are opened in the operating system browser through `shell.openExternal`.
- `will-navigate` allows only the trusted renderer URL. Development mode allows
`http://localhost:4200`, `http://127.0.0.1:4200`, and `http://[::1]:4200`.
- `will-redirect` applies the same allow/deny rules so server-side redirects
cannot move the app window to an untrusted origin.
Packaged mode allows only the app's resolved `index.html` renderer file, not
arbitrary `file:` URLs. External web navigations are denied in the app window
and opened in the operating system browser.
Do not add broad protocol allow-lists for renderer navigation. If a new
desktop-only flow needs to open a URL outside IPTVnator, route it through the
default browser unless the app window is deliberately meant to host that URL.
## Content Security Policy
The Angular shell defines a baseline CSP in `apps/web/src/index.html`.
The policy keeps the application self-hosted for scripts, blocks object and
frame embedding, limits forms to the app origin, and allows IPTV playback
sources through `media-src` and `connect-src` for `http:`, `https:`, `blob:`,
and `data:`. The policy keeps `script-src` self-hosted and currently keeps
`unsafe-inline` for existing inline styles.
Before tightening either value, validate both Electron development startup and
the PWA/electron build configurations. Playback-heavy changes should also check
that HLS, MPEG-TS, thumbnails, and local file playback are still allowed by the
policy.
## Scoped Request Header Overrides
Inline playback can request temporary `User-Agent`, `Referer`, and `Origin`
header overrides through `window.electron.setUserAgent(userAgent, referer,
scopeUrl)`.
The Electron backend handles that IPC in `apps/electron-backend/src/app/events/shared.events.ts`
and delegates to `apps/electron-backend/src/app/services/request-header-overrides.service.ts`.
The service registers one `session.defaultSession.webRequest.onBeforeSendHeaders`
listener and updates layered in-memory overrides instead of stacking a new
listener for every channel change.
Rules:
- empty playlist-level `userAgent` and `referer` clear all active overrides
- empty channel-level `userAgent` and `referer` with a `scopeUrl` clear only
the scoped channel override, preserving playlist-level defaults
- channel playback should pass the stream URL as `scopeUrl`
- scoped overrides apply only to the active stream origin and referer origin
- playlist-level user agents and referrers may call the bridge without a
`scopeUrl`; that is intentionally broader because playlist settings apply to
the whole M3U playlist
- header names are replaced case-insensitively before canonical `User-Agent`,
`Referer`, and `Origin` names are written
When changing this flow, keep stale header cleanup covered. Switching from a
channel or playlist with custom headers to one without custom headers must clear
the previous override.
Vendored
+5 -1
View File
@@ -63,7 +63,11 @@ declare global {
filePath: string,
content: string
) => Promise<{ success: boolean }>;
setUserAgent: (userAgent: string, referer?: string) => void;
setUserAgent: (
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null
) => Promise<boolean>;
openInMpv: (
url: string,
title: string,
+12 -7
View File
@@ -160,13 +160,18 @@ export class PlaylistEffects {
this.epgService.getChannelPrograms(channelId);
}
// Set user agent if specified on channel
if (channel.http['user-agent']) {
window.electron?.setUserAgent(
channel.http['user-agent'],
channel.http.referrer
);
}
void window.electron
?.setUserAgent(
channel.http?.['user-agent'],
channel.http?.referrer,
channel.url
)
.catch((error: unknown) => {
console.warn(
'[PlaylistEffects] Failed to configure Electron request headers:',
error
);
});
firstValueFrom(this.storage.get(STORE_KEY.Settings)).then(
(settings: any) => {
@@ -1,6 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { of, Subject } from 'rxjs';
import { ChannelActions, FavoritesActions } from '@iptvnator/m3u-state';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PlaylistsService } from '@iptvnator/services';
@@ -79,6 +79,9 @@ describe('M3uWorkspaceRouteSession', () => {
const store = {
dispatch: jest.fn(),
};
const electronApi = {
setUserAgent: jest.fn().mockResolvedValue(true),
};
const router = {
url: `/workspace/playlists/${PLAYLIST_ID}/all`,
events: routerEvents.asObservable(),
@@ -89,6 +92,11 @@ describe('M3uWorkspaceRouteSession', () => {
playlistContext.syncFromUrl.mockImplementation((url: string) =>
getM3uRouteContext(url)
);
Object.defineProperty(window, 'electron', {
configurable: true,
value: electronApi,
});
electronApi.setUserAgent.mockClear();
playlistsService.getPlaylist.mockReset();
store.dispatch.mockClear();
@@ -115,6 +123,10 @@ describe('M3uWorkspaceRouteSession', () => {
});
});
afterEach(() => {
delete (window as unknown as { electron?: unknown }).electron;
});
it('does not start channel loading for collection routes like favorites', async () => {
router.url = `/workspace/playlists/${PLAYLIST_ID}/favorites`;
@@ -130,15 +142,55 @@ describe('M3uWorkspaceRouteSession', () => {
expect(playlistsService.getPlaylist).not.toHaveBeenCalled();
});
it('configures playlist-level user agent overrides when loading channels', async () => {
playlistsService.getPlaylist.mockReturnValue(
of({
playlist: {
items: [PRIMARY_CHANNEL],
},
referrer: 'https://portal.example/referrer',
userAgent: 'PlaylistAgent/1.0',
} as Playlist)
);
TestBed.inject(M3uWorkspaceRouteSession);
await flushEffects();
expect(electronApi.setUserAgent).toHaveBeenCalledWith(
'PlaylistAgent/1.0',
'https://portal.example/referrer'
);
});
it('clears stale playlist-level user agent overrides when the playlist has none', async () => {
playlistsService.getPlaylist.mockReturnValue(
of({
playlist: {
items: [PRIMARY_CHANNEL],
},
} as Playlist)
);
TestBed.inject(M3uWorkspaceRouteSession);
await flushEffects();
expect(electronApi.setUserAgent).toHaveBeenCalledWith(
undefined,
undefined
);
});
it('ignores stale playlist responses after a newer route request wins', async () => {
const firstResponse = new Subject<Playlist>();
const secondResponse = new Subject<Playlist>();
playlistsService.getPlaylist.mockImplementation((playlistId: string) => {
return playlistId === PLAYLIST_ID
? firstResponse.asObservable()
: secondResponse.asObservable();
});
playlistsService.getPlaylist.mockImplementation(
(playlistId: string) => {
return playlistId === PLAYLIST_ID
? firstResponse.asObservable()
: secondResponse.asObservable();
}
);
TestBed.inject(M3uWorkspaceRouteSession);
await flushEffects();
@@ -85,7 +85,9 @@ export class M3uWorkspaceRouteSession {
}
const requestId = ++this.loadRequestId;
this.store.dispatch(ChannelActions.setChannelsLoading({ loading: true }));
this.store.dispatch(
ChannelActions.setChannelsLoading({ loading: true })
);
try {
const playlist = await firstValueFrom(
@@ -96,9 +98,14 @@ export class M3uWorkspaceRouteSession {
return;
}
if (playlist.userAgent) {
window.electron?.setUserAgent(playlist.userAgent, 'localhost');
}
void window.electron
?.setUserAgent(playlist.userAgent, playlist.referrer)
.catch((error: unknown) => {
console.warn(
'[M3uWorkspaceRouteSession] Failed to configure Electron request headers:',
error
);
});
this.store.dispatch(
ChannelActions.setChannels({
@@ -126,7 +133,9 @@ export class M3uWorkspaceRouteSession {
}
}
private isLoadedSection(section: string | null): section is M3uLoadedSection {
private isLoadedSection(
section: string | null
): section is M3uLoadedSection {
return section === 'all' || section === 'groups';
}
@@ -2,15 +2,19 @@ import { SimpleChange } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DataService } from '@iptvnator/services';
import { Channel } from '@iptvnator/shared/interfaces';
import { HtmlVideoPlayerComponent } from './html-video-player.component';
describe('HtmlVideoPlayerComponent', () => {
let component: HtmlVideoPlayerComponent;
let fixture: ComponentFixture<HtmlVideoPlayerComponent>;
const electronApi = {
setUserAgent: jest.fn().mockResolvedValue(true),
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let dataService: DataService;
const TEST_CHANNEL = {
const TEST_CHANNEL: Channel = {
id: '1234',
url: 'http://test.ts',
name: 'Test channel',
@@ -18,8 +22,18 @@ describe('HtmlVideoPlayerComponent', () => {
title: 'News group',
},
http: {
origin: '',
referrer: '',
'user-agent': 'localhost',
},
radio: 'false',
tvg: {
id: '',
logo: '',
name: '',
rec: '',
url: '',
},
};
beforeEach(waitForAsync(() => {
@@ -34,12 +48,21 @@ describe('HtmlVideoPlayerComponent', () => {
}));
beforeEach(() => {
Object.defineProperty(window, 'electron', {
configurable: true,
value: electronApi,
});
electronApi.setUserAgent.mockClear();
fixture = TestBed.createComponent(HtmlVideoPlayerComponent);
component = fixture.componentInstance;
dataService = TestBed.inject(DataService);
fixture.detectChanges();
});
afterEach(() => {
delete (window as unknown as { electron?: unknown }).electron;
});
it('should create', () => {
expect(component).toBeTruthy();
});
@@ -57,9 +80,57 @@ describe('HtmlVideoPlayerComponent', () => {
expect(component.playChannel).toHaveBeenCalledWith(TEST_CHANNEL);
});
it('passes channel headers and stream URL to Electron header overrides', () => {
jest.spyOn(
component.videoPlayer.nativeElement,
'play'
).mockResolvedValue(undefined);
component.playChannel({
...TEST_CHANNEL,
http: {
'user-agent': 'ChannelAgent/1.0',
origin: '',
referrer: 'https://portal.example/referrer',
},
radio: 'false',
url: 'https://stream.example/video.mp4',
});
expect(electronApi.setUserAgent).toHaveBeenCalledWith(
'ChannelAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/video.mp4'
);
});
it('clears Electron header overrides for channels without custom headers', () => {
jest.spyOn(
component.videoPlayer.nativeElement,
'play'
).mockResolvedValue(undefined);
component.playChannel({
...TEST_CHANNEL,
http: {
'user-agent': '',
origin: '',
referrer: '',
},
radio: 'false',
url: 'https://stream.example/video.mp4',
});
expect(electronApi.setUserAgent).toHaveBeenCalledWith(
'',
'',
'https://stream.example/video.mp4'
);
});
it('emits a playback issue when the native video element reports an unsupported source', () => {
const issues: unknown[] = [];
component.channel = TEST_CHANNEL as never;
component.channel = TEST_CHANNEL;
component.playbackIssue.subscribe((issue) => issues.push(issue));
Object.defineProperty(component.videoPlayer.nativeElement, 'error', {
@@ -150,13 +150,18 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
const url = channel.url + (channel.epgParams ?? '');
const extension = getPlaybackMediaExtensionFromUrl(channel.url);
// Set user agent if specified on channel
if (channel.http?.['user-agent']) {
window.electron?.setUserAgent(
channel.http['user-agent'],
channel.http.referrer
);
}
void window.electron
?.setUserAgent(
channel.http?.['user-agent'],
channel.http?.referrer,
channel.url
)
.catch((error: unknown) => {
console.warn(
'[HtmlVideoPlayer] Failed to configure Electron request headers:',
error
);
});
if ((extension === 'ts' || !extension) && mpegts.isSupported()) {
debugHtmlPlayer(