diff --git a/apps/electron-backend/src/app/events/player.events.spec.ts b/apps/electron-backend/src/app/events/player.events.spec.ts index c0e0955c1..04ec5a741 100644 --- a/apps/electron-backend/src/app/events/player.events.spec.ts +++ b/apps/electron-backend/src/app/events/player.events.spec.ts @@ -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'); - }); }); diff --git a/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts b/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts index b78363f61..b311b00bf 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts @@ -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); diff --git a/apps/electron-backend/src/app/events/vlc-session.service.spec.ts b/apps/electron-backend/src/app/events/vlc-session.service.spec.ts index 1c80160ea..995a0e377 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.spec.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.spec.ts @@ -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', ]); }); diff --git a/apps/electron-backend/src/app/events/vlc-session.service.ts b/apps/electron-backend/src/app/events/vlc-session.service.ts index a07633bfb..ead216756 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.ts @@ -59,7 +59,6 @@ export function buildVlcEnqueueCommands(options: { userAgent?: string; referer?: string; origin?: string; - headers?: Record; 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);