From 4b2e60a0ff1c65d1e22f205ef64795ddf951c741 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 17 Jul 2026 21:33:13 +0200 Subject: [PATCH] fix(embedded-mpv): sanitize Linux helper environment --- .../embedded-mpv-frame-copy-runtime.spec.ts | 91 +++++++++++++ .../embedded-mpv-frame-copy-runtime.ts | 121 ++++++++++++++++-- .../embedded-mpv-frame-copy.adapter.spec.ts | 102 ++++++++++++++- .../embedded-mpv-frame-copy.adapter.ts | 41 +++++- .../embedded-mpv-native.service.spec.ts | 19 +++ .../services/embedded-mpv-native.service.ts | 12 ++ 6 files changed, 368 insertions(+), 18 deletions(-) diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts index fcdfe2e27..930911975 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts @@ -17,6 +17,7 @@ import { import { tmpdir } from 'os'; import path from 'path'; import { + createLinuxFrameCopyHelperEnvironment, createEmbeddedMpvFrameCopyRuntimeProbe, EmbeddedMpvFrameCopyRuntimeDependencies, } from './embedded-mpv-frame-copy-runtime'; @@ -477,6 +478,95 @@ function cloneManifest(manifest: T): T { return JSON.parse(JSON.stringify(manifest)) as T; } +describe('createLinuxFrameCopyHelperEnvironment', () => { + it('removes ambient loader overrides for system packages', () => { + expect( + createLinuxFrameCopyHelperEnvironment( + { + PATH: '/usr/bin', + HOME: '/home/user', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_PRELOAD: '/tmp/inject.so', + }, + '/opt/iptvnator/native', + 'system' + ) + ).toEqual({ + PATH: '/usr/bin', + HOME: '/home/user', + }); + }); + + it('reconstructs only trusted Snap loader roots for a bundled package inside the Snap mount', () => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + + expect( + createLinuxFrameCopyHelperEnvironment( + { + PATH: '/snap/bin:/usr/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: [ + '/var/lib/snapd/lib/gl', + '/tmp/hostile-gl', + '/var/lib/snapd/lib/gl/nvidia', + '/var/lib/snapd/lib/gl-evil', + ].join(':'), + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_PRELOAD: '/tmp/inject.so', + }, + nativeDir, + 'bundled' + ) + ).toEqual({ + PATH: '/snap/bin:/usr/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: [ + '/var/lib/snapd/lib/gl', + '/tmp/hostile-gl', + '/var/lib/snapd/lib/gl/nvidia', + '/var/lib/snapd/lib/gl-evil', + ].join(':'), + LD_LIBRARY_PATH: [ + path.join(nativeDir, 'lib'), + path.join(snapRoot, 'lib'), + path.join(snapRoot, 'usr', 'lib'), + path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), + path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'), + '/var/lib/snapd/lib/gl', + '/var/lib/snapd/lib/gl/nvidia', + ].join(':'), + }); + }); + + it('does not trust Snap loader paths when nativeDir is outside the declared mount', () => { + expect( + createLinuxFrameCopyHelperEnvironment( + { + PATH: '/usr/bin', + SNAP: '/snap/iptvnator/42', + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_PRELOAD: '/tmp/inject.so', + }, + '/opt/iptvnator/native', + 'bundled' + ) + ).toEqual({ + PATH: '/usr/bin', + SNAP: '/snap/iptvnator/42', + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', + LD_LIBRARY_PATH: '/opt/iptvnator/native/lib', + }); + }); +}); + describe('embedded-mpv-frame-copy-runtime', () => { let rootDir: string; let spawnRuntimeProbe: jest.Mock; @@ -540,6 +630,7 @@ describe('embedded-mpv-frame-copy-runtime', () => { { encoding: 'utf8', timeout: 3000, + killSignal: 'SIGKILL', windowsHide: true, env: { PATH: '/usr/bin', diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts index 0bd8cf7a0..1b884fd2a 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts @@ -326,6 +326,7 @@ export type EmbeddedMpvFrameCopyRuntimeFailureReason = | 'runtime-probe-internal-error'; export type EmbeddedMpvFrameCopyManifestContract = 'packaged' | 'development'; +export type EmbeddedMpvFrameCopyRuntimeMode = 'system' | 'bundled'; type RuntimeProfile = keyof typeof PROFILE_CONTRACTS; type RuntimeProbeProfile = RuntimeProfile | 'development'; @@ -366,7 +367,7 @@ interface RuntimeFile { interface ValidManifest { profile: RuntimeProbeProfile; - runtimeMode: 'system' | 'bundled'; + runtimeMode: EmbeddedMpvFrameCopyRuntimeMode; runtimeFiles: RuntimeFile[]; } @@ -1337,19 +1338,118 @@ function parseSuccessfulProbe( }; } +const TRUSTED_SNAP_MOUNT_ROOTS = ['/snap', '/var/lib/snapd/snap'] as const; +const TRUSTED_SNAP_GL_ROOT = '/var/lib/snapd/lib/gl'; + +function isPathInside( + parentPath: string, + candidatePath: string, + allowEqual: boolean +): boolean { + const relativePath = path.relative(parentPath, candidatePath); + if (relativePath === '') { + return allowEqual; + } + return ( + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ); +} + +function resolveTrustedSnapRoot( + environment: NodeJS.ProcessEnv, + nativeDir: string +): string | null { + const declaredSnapRoot = environment.SNAP; + if ( + !declaredSnapRoot || + !path.isAbsolute(declaredSnapRoot) || + !path.isAbsolute(nativeDir) + ) { + return null; + } + + const normalizedSnapRoot = path.resolve(declaredSnapRoot); + const resemblesReadOnlySnapMount = TRUSTED_SNAP_MOUNT_ROOTS.some( + (mountRoot) => { + const relativePath = path.relative(mountRoot, normalizedSnapRoot); + return ( + isPathInside(mountRoot, normalizedSnapRoot, false) && + relativePath.split(path.sep).filter(Boolean).length >= 2 + ); + } + ); + if ( + !resemblesReadOnlySnapMount || + !isPathInside(normalizedSnapRoot, path.resolve(nativeDir), false) + ) { + return null; + } + return normalizedSnapRoot; +} + +function getTrustedSnapLibraryPaths( + environment: NodeJS.ProcessEnv, + snapRoot: string +): string[] { + const snapLibraryPaths = (environment.SNAP_LIBRARY_PATH ?? '') + .split(':') + .filter(Boolean) + .filter((libraryPath) => path.isAbsolute(libraryPath)) + .map((libraryPath) => path.resolve(libraryPath)) + .filter((libraryPath) => + isPathInside(TRUSTED_SNAP_GL_ROOT, libraryPath, true) + ); + + return [ + path.join(snapRoot, 'lib'), + path.join(snapRoot, 'usr', 'lib'), + path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), + path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'), + ...snapLibraryPaths, + ]; +} + +/** + * Builds the loader environment shared by the bounded startup probe and each + * real Linux helper session. The validated package profile is authoritative: + * system packages use the system loader, while bundled packages start at + * native/lib and may add only immutable-looking Snap runtime/GL roots. + */ +export function createLinuxFrameCopyHelperEnvironment( + environment: NodeJS.ProcessEnv, + nativeDir: string, + runtimeMode: EmbeddedMpvFrameCopyRuntimeMode +): NodeJS.ProcessEnv { + const helperEnvironment = { ...environment }; + delete helperEnvironment.LD_LIBRARY_PATH; + delete helperEnvironment.LD_PRELOAD; + + if (runtimeMode === 'system') { + return helperEnvironment; + } + + const libraryPaths = [path.join(nativeDir, 'lib')]; + const trustedSnapRoot = resolveTrustedSnapRoot(environment, nativeDir); + if (trustedSnapRoot) { + libraryPaths.push( + ...getTrustedSnapLibraryPaths(environment, trustedSnapRoot) + ); + } + helperEnvironment.LD_LIBRARY_PATH = [...new Set(libraryPaths)].join(':'); + return helperEnvironment; +} + function runHelperProbe( runtimePackage: ValidatedPackage, dependencies: EmbeddedMpvFrameCopyRuntimeDependencies ): EmbeddedMpvFrameCopyRuntimeResult { - const probeEnvironment = { ...dependencies.env }; - // The manifest/OS package is the loader contract. Ambient overrides could - // replace libmpv or inject code into the helper before its bounded probe. - delete probeEnvironment.LD_LIBRARY_PATH; - delete probeEnvironment.LD_PRELOAD; - if (runtimePackage.manifest.runtimeMode === 'bundled') { - const bundledLibDir = path.join(runtimePackage.nativeDir, 'lib'); - probeEnvironment.LD_LIBRARY_PATH = bundledLibDir; - } + const probeEnvironment = createLinuxFrameCopyHelperEnvironment( + dependencies.env, + runtimePackage.nativeDir, + runtimePackage.manifest.runtimeMode + ); let result: ReturnType; try { @@ -1359,6 +1459,7 @@ function runHelperProbe( { encoding: 'utf8', timeout: RUNTIME_PROBE_TIMEOUT_MS, + killSignal: 'SIGKILL', windowsHide: true, env: probeEnvironment, } diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts index e0dc4ba82..388e8c3d9 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts @@ -7,6 +7,7 @@ jest.mock('child_process', () => ({ })); import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; +import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime'; class FakeHelperProcess extends EventEmitter { exitCode: number | null = null; @@ -36,10 +37,21 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { let frameSourceChanges: Array<{ sessionId: string; shmName: string }>; let adapter: EmbeddedMpvFrameCopyAdapter; - const createAdapter = (helperPath: string | null = '/native/helper') => { + const createAdapter = ( + helperPath: string | null = '/native/helper', + { + runtimeMode = 'system', + environment, + }: { + runtimeMode?: EmbeddedMpvFrameCopyRuntimeMode | null; + environment?: NodeJS.ProcessEnv; + } = {} + ) => { frameSourceChanges = []; return new EmbeddedMpvFrameCopyAdapter({ resolveHelperPath: () => helperPath, + resolveRuntimeMode: () => runtimeMode, + environment, getScaleFactor: () => 2, onFrameSourceChanged: (sessionId, source) => frameSourceChanges.push({ sessionId, shmName: source.shmName }), @@ -83,6 +95,94 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { ]); }); + describe('Linux loader environment', () => { + const originalPlatform = process.platform; + const originalArch = process.arch; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + Object.defineProperty(process, 'arch', { value: originalArch }); + }); + + it('uses a sanitized system environment for the real helper session', () => { + adapter = createAdapter('/opt/iptvnator/native/helper', { + runtimeMode: 'system', + environment: { + PATH: '/usr/bin', + HOME: '/home/user', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_PRELOAD: '/tmp/inject.so', + }, + }); + + createSession(); + + expect(spawnMock.mock.calls[0][2]).toEqual({ + stdio: ['pipe', 'pipe', 'pipe'], + env: { + PATH: '/usr/bin', + HOME: '/home/user', + }, + }); + }); + + it('uses only the bundled runtime and trusted Snap GL roots for the real helper session', () => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + adapter = createAdapter(path.join(nativeDir, 'helper'), { + runtimeMode: 'bundled', + environment: { + PATH: '/snap/bin:/usr/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_PRELOAD: '/tmp/inject.so', + }, + }); + + createSession(); + + expect(spawnMock.mock.calls[0][2]).toEqual({ + stdio: ['pipe', 'pipe', 'pipe'], + env: { + PATH: '/snap/bin:/usr/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', + LD_LIBRARY_PATH: [ + path.join(nativeDir, 'lib'), + path.join(snapRoot, 'lib'), + path.join(snapRoot, 'usr', 'lib'), + path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), + path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'), + '/var/lib/snapd/lib/gl', + ].join(':'), + }, + }); + }); + + it('refuses a Linux session without a validated runtime mode', () => { + adapter = createAdapter('/native/helper', { runtimeMode: null }); + + expect(() => createSession()).toThrow( + 'validated Linux frame-copy runtime' + ); + expect(spawnMock).not.toHaveBeenCalled(); + }); + }); + it('caches helper snapshot events for getSessionSnapshot', () => { const sessionId = createSession(); child.emitStdout({ diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts index 5edc3c2fa..c2c453f52 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts @@ -7,6 +7,8 @@ import { ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { isFrameCopyPlatformSupported } from './embedded-mpv-frame-copy-platform.util'; +import { createLinuxFrameCopyHelperEnvironment } from './embedded-mpv-frame-copy-runtime'; +import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime'; import type { NativeEmbeddedMpvAddon, NativeEmbeddedMpvSessionSnapshot, @@ -28,6 +30,8 @@ import type { export interface EmbeddedMpvFrameCopyAdapterOptions { resolveHelperPath: () => string | null; + resolveRuntimeMode: () => EmbeddedMpvFrameCopyRuntimeMode | null; + environment?: NodeJS.ProcessEnv; getScaleFactor: () => number; onFrameSourceChanged: ( sessionId: string, @@ -76,14 +80,18 @@ function createInitialSnapshot(): NativeEmbeddedMpvSessionSnapshot { export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { private readonly sessions = new Map(); - constructor( - private readonly options: EmbeddedMpvFrameCopyAdapterOptions - ) {} + constructor(private readonly options: EmbeddedMpvFrameCopyAdapterOptions) {} isSupported(): boolean { + if ( + !isFrameCopyPlatformSupported() || + this.options.resolveHelperPath() === null + ) { + return false; + } return ( - isFrameCopyPlatformSupported() && - this.options.resolveHelperPath() !== null + process.platform !== 'linux' || + this.options.resolveRuntimeMode() !== null ); } @@ -104,6 +112,20 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { const scale = this.options.getScaleFactor(); const width = Math.max(16, Math.round(bounds.width * scale)); const height = Math.max(16, Math.round(bounds.height * scale)); + let helperEnvironment: NodeJS.ProcessEnv | undefined; + if (process.platform === 'linux') { + const runtimeMode = this.options.resolveRuntimeMode(); + if (!runtimeMode) { + throw new Error( + 'A validated Linux frame-copy runtime is not available.' + ); + } + helperEnvironment = createLinuxFrameCopyHelperEnvironment( + this.options.environment ?? process.env, + path.dirname(helperPath), + runtimeMode + ); + } const child = spawn( helperPath, @@ -126,7 +148,10 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { ] : []), ], - { stdio: ['pipe', 'pipe', 'pipe'] } + { + stdio: ['pipe', 'pipe', 'pipe'], + ...(helperEnvironment ? { env: helperEnvironment } : {}), + } ); console.log( @@ -177,7 +202,9 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { } loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void { - const fields: string[] = [`url=${encodeProtocolValue(playback.streamUrl)}`]; + const fields: string[] = [ + `url=${encodeProtocolValue(playback.streamUrl)}`, + ]; if (playback.title) { fields.push( `opt.force-media-title=${encodeProtocolValue(playback.title)}` diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index 69bfd282e..1f85402b2 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -381,6 +381,25 @@ describe('EmbeddedMpvNativeService power blocker', () => { expect(service.isFrameCopyAvailable()).toBe(true); }); + it('supplies the adapter with the runtime mode from the validated Linux capability', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + mockGetFrameCopyRuntimeAvailability.mockReturnValue({ + usable: true, + profile: 'portable', + runtimeMode: 'bundled', + libmpv: '2.3', + renderApi: 'egl', + }); + + expect( + ( + service as unknown as { + resolveFrameCopyRuntimeMode(): string | null; + } + ).resolveFrameCopyRuntimeMode() + ).toBe('bundled'); + }); + it('keeps the frame-copy engine Apple-Silicon-only on macOS', () => { Object.defineProperty(process, 'arch', { value: 'x64' }); process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index 4b190f607..d30ad596f 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -34,6 +34,7 @@ import { isFrameCopyRuntimeUsable, resolveFrameCopyHelperPath, } from './embedded-mpv-frame-copy-platform.util'; +import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime'; import { EMBEDDED_MPV_EXPERIMENT_ENV, isEmbeddedMpvFeatureEnabled, @@ -154,6 +155,16 @@ export class EmbeddedMpvNativeService { }; } + private resolveFrameCopyRuntimeMode(): EmbeddedMpvFrameCopyRuntimeMode | null { + if (process.platform !== 'linux') { + return null; + } + const availability = getFrameCopyRuntimeAvailability(); + return availability.usable && 'runtimeMode' in availability + ? availability.runtimeMode + : null; + } + getFrameSource(sessionId: string): EmbeddedMpvFrameSource | null { return this.frameCopyAdapter?.getFrameSource(sessionId) ?? null; } @@ -162,6 +173,7 @@ export class EmbeddedMpvNativeService { if (!this.frameCopyAdapter) { this.frameCopyAdapter = new EmbeddedMpvFrameCopyAdapter({ resolveHelperPath: resolveFrameCopyHelperPath, + resolveRuntimeMode: () => this.resolveFrameCopyRuntimeMode(), getScaleFactor: () => this.getMainWindowScaleFactor(), onFrameSourceChanged: (sessionId, source) => { if (!App.mainWindow || App.mainWindow.isDestroyed()) {