From 1520f76f46a2476d0f44e145d4cba7c2ed67a217 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 20 Jun 2026 23:41:53 +0200 Subject: [PATCH] fix(epg): harden compressed stream decoding --- .../app/workers/epg-response-utils.spec.ts | 8 +++ .../src/app/workers/epg-response-utils.ts | 31 +++++++++--- .../app/workers/epg-stream-decoder.spec.ts | 49 ++++++++++++++++++- .../src/app/workers/epg-stream-decoder.ts | 39 ++++++--------- 4 files changed, 93 insertions(+), 34 deletions(-) diff --git a/apps/electron-backend/src/app/workers/epg-response-utils.spec.ts b/apps/electron-backend/src/app/workers/epg-response-utils.spec.ts index b8b3b879a..dd4683b9f 100644 --- a/apps/electron-backend/src/app/workers/epg-response-utils.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-response-utils.spec.ts @@ -107,4 +107,12 @@ describe('getEpgResponseContentEncoding', () => { }) ).toBe(null); }); + + it('returns the outermost supported transfer encoding first', () => { + expect( + getEpgResponseContentEncoding({ + 'content-encoding': 'gzip, br', + }) + ).toBe('br'); + }); }); diff --git a/apps/electron-backend/src/app/workers/epg-response-utils.ts b/apps/electron-backend/src/app/workers/epg-response-utils.ts index fb12a833b..e4ed92cce 100644 --- a/apps/electron-backend/src/app/workers/epg-response-utils.ts +++ b/apps/electron-backend/src/app/workers/epg-response-utils.ts @@ -6,6 +6,12 @@ export type HeaderReader = export type EpgResponseContentEncoding = 'br' | 'gzip' | 'deflate'; +const SUPPORTED_CONTENT_ENCODINGS: readonly EpgResponseContentEncoding[] = [ + 'br', + 'gzip', + 'deflate', +]; + function getHeaderValue(headers: HeaderReader, name: string): string | null { const value = 'get' in headers && typeof headers.get === 'function' @@ -32,17 +38,28 @@ function hasGzipPath(url: string | null | undefined): boolean { export function getEpgResponseContentEncoding( headers: HeaderReader ): EpgResponseContentEncoding | null { - const contentEncoding = getHeaderValue(headers, 'content-encoding') - ?.toLowerCase() - .split(',') - .map((encoding) => encoding.trim()) - .find((encoding): encoding is EpgResponseContentEncoding => - ['br', 'gzip', 'deflate'].includes(encoding) - ); + const contentEncoding = getEpgResponseContentEncodings(headers).at(0); return contentEncoding ?? null; } +export function getEpgResponseContentEncodings( + headers: HeaderReader +): EpgResponseContentEncoding[] { + return ( + getHeaderValue(headers, 'content-encoding') + ?.toLowerCase() + .split(',') + .map((encoding) => encoding.trim()) + .filter((encoding): encoding is EpgResponseContentEncoding => + SUPPORTED_CONTENT_ENCODINGS.includes( + encoding as EpgResponseContentEncoding + ) + ) + .reverse() ?? [] + ); +} + /** * Detect whether an EPG response should be gunzipped. * Some providers redirect plain-looking URLs to a `.gz` payload. diff --git a/apps/electron-backend/src/app/workers/epg-stream-decoder.spec.ts b/apps/electron-backend/src/app/workers/epg-stream-decoder.spec.ts index b9c2743d8..73c6d2882 100644 --- a/apps/electron-backend/src/app/workers/epg-stream-decoder.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-stream-decoder.spec.ts @@ -1,4 +1,4 @@ -import { Readable } from 'stream'; +import { PassThrough, Readable } from 'stream'; import { brotliCompressSync, gzipSync } from 'zlib'; import { createDecodedEpgStream } from './epg-stream-decoder'; import { StreamingEpgParser } from './epg-streaming-parser'; @@ -69,11 +69,26 @@ describe('createDecodedEpgStream', () => { }); it('decodes gzip XML payloads after transfer decoding', async () => { - const transferEncodedPayload = gzipSync(gzipSync(xmltvFixture)); + const transferEncodedPayload = brotliCompressSync( + gzipSync(xmltvFixture) + ); const decodedText = await collectDecodedText( createDecodedEpgStream( Readable.from([transferEncodedPayload]), + { 'content-encoding': 'br' }, + true + ) + ); + + expect(decodedText.startsWith(' { + const decodedText = await collectDecodedText( + createDecodedEpgStream( + Readable.from([gzipSync(xmltvFixture)]), { 'content-encoding': 'gzip' }, true ) @@ -82,4 +97,34 @@ describe('createDecodedEpgStream', () => { expect(decodedText.startsWith(' { + const encodedPayload = brotliCompressSync(gzipSync(xmltvFixture)); + + const decodedText = await collectDecodedText( + createDecodedEpgStream( + Readable.from([encodedPayload]), + { 'content-encoding': 'gzip, br' }, + false + ) + ); + + expect(decodedText.startsWith(' { + const source = new PassThrough(); + const decodedStream = createDecodedEpgStream( + source, + { 'content-encoding': 'gzip' }, + false + ); + + const readPromise = collectDecodedText(decodedStream); + source.end(Buffer.from('not gzip')); + + await expect(readPromise).rejects.toThrow(); + expect(source.destroyed).toBe(true); + }); }); diff --git a/apps/electron-backend/src/app/workers/epg-stream-decoder.ts b/apps/electron-backend/src/app/workers/epg-stream-decoder.ts index e5bd70d3e..726294e0a 100644 --- a/apps/electron-backend/src/app/workers/epg-stream-decoder.ts +++ b/apps/electron-backend/src/app/workers/epg-stream-decoder.ts @@ -1,17 +1,18 @@ -import { Readable, Transform } from 'stream'; +import { PassThrough, Readable, Transform, pipeline } from 'stream'; import { createBrotliDecompress, createGunzip, createInflate, } from 'zlib'; import { - getEpgResponseContentEncoding, + EpgResponseContentEncoding, + getEpgResponseContentEncodings, HeaderReader, } from './epg-response-utils'; function createContentEncodingDecoder( - contentEncoding: ReturnType -): Transform | null { + contentEncoding: EpgResponseContentEncoding +): Transform { switch (contentEncoding) { case 'br': return createBrotliDecompress(); @@ -19,8 +20,6 @@ function createContentEncodingDecoder( return createGunzip(); case 'deflate': return createInflate(); - default: - return null; } } @@ -29,16 +28,10 @@ export function createDecodedEpgStream( headers: HeaderReader, shouldGunzipPayload: boolean ): Readable { - const transforms: Transform[] = []; - const contentEncoding = getEpgResponseContentEncoding(headers); - const contentEncodingDecoder = - createContentEncodingDecoder(contentEncoding); + const contentEncodings = getEpgResponseContentEncodings(headers); + const transforms = contentEncodings.map(createContentEncodingDecoder); - if (contentEncodingDecoder) { - transforms.push(contentEncodingDecoder); - } - - if (shouldGunzipPayload) { + if (shouldGunzipPayload && !contentEncodings.includes('gzip')) { transforms.push(createGunzip()); } @@ -46,16 +39,12 @@ export function createDecodedEpgStream( return source; } - const decodedStream = transforms.reduce( - (stream, transform) => stream.pipe(transform), - source - ); - - for (const stream of [source, ...transforms]) { - if (stream !== decodedStream) { - stream.on('error', (error) => decodedStream.destroy(error)); + const output = new PassThrough(); + pipeline([source, ...transforms, output], (error) => { + if (error) { + output.destroy(error); } - } + }); - return decodedStream; + return output; }