fix(embedded-mpv): sanitize Linux helper environment

This commit is contained in:
4gray committed 2026-07-17 22:42:37 +02:00
1 parent 08e92d4f27
commit 4b2e60a0ff
6 files changed
+368 -18

No files matched your search

@@ -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<T>(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',
@@ -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<typeof nodeSpawnSync>;
try {
@@ -1359,6 +1459,7 @@ function runHelperProbe(
{
encoding: 'utf8',
timeout: RUNTIME_PROBE_TIMEOUT_MS,
killSignal: 'SIGKILL',
windowsHide: true,
env: probeEnvironment,
}
@@ -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({
@@ -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<string, FrameCopyRuntimeSession>();
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)}`
@@ -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';
@@ -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()) {