fix(player): isolate authenticated vlc launches

This commit is contained in:
4gray committed 2026-07-27 14:00:40 +02:00
1 parent bdf353a3b8
commit f5090d623e
4 files changed
+97 -27

No files matched your search

@@ -860,18 +860,17 @@ describe('buildVlcEnqueueCommands', () => {
).toEqual(['clear', 'add http://stream.example/a.m3u8']);
});
it('attaches per-input HTTP options inline with the add command', () => {
it('attaches non-header input options inline with the add command', () => {
const commands = buildVlcEnqueueCommands({
url: 'http://stream.example/a.m3u8',
title: 'Channel One',
userAgent: 'Custom/1.0',
referer: 'https://referer.example',
headers: { 'X-Token': 'abc' },
});
expect(commands[0]).toBe('clear');
expect(commands[1]).toBe(
'add http://stream.example/a.m3u8 :http-user-agent=Custom/1.0 :http-referrer=https://referer.example :http-header=X-Token: abc :meta-title=Channel One'
'add http://stream.example/a.m3u8 :http-user-agent=Custom/1.0 :http-referrer=https://referer.example :meta-title=Channel One'
);
});
@@ -897,13 +896,4 @@ describe('buildVlcEnqueueCommands', () => {
]);
});
it('skips empty header values', () => {
const commands = buildVlcEnqueueCommands({
url: 'http://stream.example/a.m3u8',
headers: { 'X-Empty': ' ', 'X-Real': 'value' },
});
expect(commands[1]).toContain(':http-header=X-Real: value');
expect(commands[1]).not.toContain('X-Empty');
});
});
@@ -114,6 +114,7 @@ describe('vlc-session.service process lifecycle', () => {
beforeEach(() => {
jest.clearAllMocks();
spawnMock.mockReset();
rcWrites.length = 0;
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
(createServer as unknown as jest.Mock).mockImplementation(() => ({
@@ -163,6 +164,77 @@ describe('vlc-session.service process lifecycle', () => {
expect(session.status).toBe('opened');
});
it('relaunches instead of sending Authorization and Cookie through RC', async () => {
const proc = createMockChildProcess();
await openTrackedVlcInstance(proc);
installRcSocketMock('ack');
const freshProc = createMockChildProcess();
spawnMock.mockReturnValueOnce(freshProc);
const openPromise = openVlcPlayer({
title: 'Authenticated',
url: 'https://example.com/authenticated.m3u8',
mainOwnedHeaders: {
Authorization: 'Bearer safe-token',
Cookie: 'session=safe-cookie',
},
});
await waitForSpawnCallCount(2);
freshProc.emit('spawn');
const session = await openPromise;
expect(proc.kill).toHaveBeenCalled();
expect(rcWrites).toEqual([]);
expect(spawnMock.mock.calls[1][1]).toEqual([
':http-header=Authorization: Bearer safe-token',
':http-header=Cookie: session=safe-cookie',
'https://example.com/authenticated.m3u8',
':meta-title=Authenticated',
]);
expect(spawnMock.mock.calls[1][2]).toEqual({
shell: false,
detached: true,
stdio: 'ignore',
});
expect(freshProc.unref).toHaveBeenCalled();
expect(session.status).toBe('opened');
});
it('keeps malicious header syntax inside argv and out of RC', async () => {
const proc = createMockChildProcess();
await openTrackedVlcInstance(proc);
installRcSocketMock('ack');
const freshProc = createMockChildProcess();
spawnMock.mockReturnValueOnce(freshProc);
const openPromise = openVlcPlayer({
title: 'Authenticated',
url: 'https://example.com/authenticated.m3u8',
mainOwnedHeaders: {
Authorization:
'Bearer safe-token :sout=#duplicate{dst=display}',
Cookie: 'session=safe-cookie\n:sout=#display',
},
});
await waitForSpawnCallCount(2);
freshProc.emit('spawn');
await openPromise;
expect(proc.kill).toHaveBeenCalled();
expect(rcWrites).toEqual([]);
expect(spawnMock.mock.calls[1][1]).toContain(
':http-header=Authorization: Bearer safe-token :sout=#duplicate{dst=display}'
);
expect(spawnMock.mock.calls[1][1]).toContain(
':http-header=Cookie: session=safe-cookie\n:sout=#display'
);
expect(spawnMock.mock.calls[1][2]).toEqual({
shell: false,
detached: true,
stdio: 'ignore',
});
});
it('kills the stale instance and spawns fresh when RC reuse fails', async () => {
const proc = createMockChildProcess();
await openTrackedVlcInstance(proc);
@@ -114,14 +114,12 @@ describe('vlc-session.service helpers and launch args', () => {
title: 'My Title',
userAgent: 'UA/1.0',
referer: 'https://ref.example',
headers: { 'X-A': ' padded ', 'X-Empty': ' ' },
startTime: 12.7,
})
).toEqual([
'clear',
'add http://srv/1 :http-user-agent=UA/1.0 ' +
':http-referrer=https://ref.example ' +
':http-header=X-A: padded :meta-title=My Title',
':http-referrer=https://ref.example :meta-title=My Title',
'seek 12',
]);
});
@@ -59,7 +59,6 @@ export function buildVlcEnqueueCommands(options: {
userAgent?: string;
referer?: string;
origin?: string;
headers?: Record<string, string>;
startTime?: number;
}): string[] {
const inputOptions: string[] = [];
@@ -72,12 +71,6 @@ export function buildVlcEnqueueCommands(options: {
} else if (options.origin) {
inputOptions.push(`:http-referrer=${options.origin}`);
}
Object.entries(options.headers ?? {}).forEach(([name, value]) => {
if (!name || value === undefined || value === null) return;
const trimmedValue = String(value).trim();
if (!trimmedValue) return;
inputOptions.push(`:http-header=${name}: ${trimmedValue}`);
});
if (options.title) {
inputOptions.push(`:meta-title=${options.title}`);
}
@@ -329,10 +322,6 @@ export async function openVlcPlayer({
);
const customVlcArguments = store.get(VLC_PLAYER_ARGUMENTS, '');
const requestedReuseInstance = store.get(VLC_REUSE_INSTANCE, false);
const reuseInstance = shouldReuseVlcInstance(
requestedReuseInstance,
isFlatpak
);
const {
mergedHeaders,
effectiveOrigin,
@@ -346,11 +335,27 @@ export async function openVlcPlayer({
headers,
mainOwnedHeaders,
});
const hasHttpHeaders = Object.entries(mergedHeaders).some(
([name, value]) =>
Boolean(name) &&
value !== undefined &&
value !== null &&
Boolean(String(value).trim())
);
// VLC RC has no proven escaping for `add` input options. Header-bearing
// streams must use a fresh argv launch; a per-process RC port may still
// be opened below solely for playback-position polling.
const configuredReuseInstance = shouldReuseVlcInstance(
requestedReuseInstance,
isFlatpak
);
const reuseInstance = configuredReuseInstance && !hasHttpHeaders;
traceExternalPlayer('open vlc player', {
path: vlcLaunchContext.playerPath,
launchMode: vlcLaunchContext.mode,
requestedReuseInstance,
reuseInstance,
hasHttpHeaders,
stream: maskUrlForLogs(url),
hasUserAgent: Boolean(effectiveUserAgent),
hasReferer: Boolean(effectiveReferer),
@@ -361,6 +366,12 @@ export async function openVlcPlayer({
parseExternalPlayerArguments(customVlcArguments).length,
});
if (hasHttpHeaders && vlcProcess) {
killStoredVlcProcess(
'relaunch vlc instead of sending http headers through rc'
);
}
if (reuseInstance && vlcProcess && !vlcProcess.killed && vlcRcPort) {
traceExternalPlayer('reuse existing vlc instance', {
rcPort: vlcRcPort,
@@ -372,7 +383,6 @@ export async function openVlcPlayer({
userAgent: effectiveUserAgent,
referer: effectiveReferer,
origin: effectiveOrigin,
headers: mergedHeaders,
startTime,
});
await sendVlcRcCommands(vlcRcPort, enqueueCommands);