mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #1005 from 4gray/agent/electron-security-hardening
feat(electron): harden renderer security
This commit is contained in:
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
Vendored
+5
-1
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+58
-6
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user