From 4b035b4558b6146e41e8a1befbd4ab628eb2c9bd Mon Sep 17 00:00:00 2001 From: Shubham Padkonde Date: Mon, 26 Oct 2026 09:44:59 +0530 Subject: [PATCH] Fix percent-encoded proxy credentials --- scripts/core/proxyParser.ts | 4 +-- tests/proxyParser.test.ts | 68 +++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 tests/proxyParser.test.ts diff --git a/scripts/core/proxyParser.ts b/scripts/core/proxyParser.ts index 545d190a90..844b4a3b9e 100644 --- a/scripts/core/proxyParser.ts +++ b/scripts/core/proxyParser.ts @@ -22,8 +22,8 @@ export class ProxyParser { if (parsed.username || parsed.password) { result.auth = {} - if (parsed.username) result.auth.username = parsed.username - if (parsed.password) result.auth.password = parsed.password + if (parsed.username) result.auth.username = decodeURIComponent(parsed.username) + if (parsed.password) result.auth.password = decodeURIComponent(parsed.password) } return result diff --git a/tests/proxyParser.test.ts b/tests/proxyParser.test.ts new file mode 100644 index 0000000000..c68b52cc30 --- /dev/null +++ b/tests/proxyParser.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest' +import { createServer } from 'node:http' +import { AddressInfo } from 'node:net' +import { ProxyParser } from '../scripts/core/proxyParser' +import { StreamTester } from '../scripts/core/streamTester' + +describe('ProxyParser', () => { + it('decodes URL-encoded credentials', () => { + expect(new ProxyParser().parse('http://user%40example.com:p%3Ass%2Fword@localhost:8080')).toEqual({ + protocol: 'http', + host: 'localhost', + port: 8080, + auth: { username: 'user@example.com', password: 'p:ss/word' } + }) + }) + + it('decodes credentials once and preserves literal plus signs', () => { + const proxy = new ProxyParser().parse('http://user+name:p%252F%25+word@localhost:8080') + + expect(proxy.auth).toEqual({ username: 'user+name', password: 'p%2F%+word' }) + }) + + it('preserves plain credentials', () => { + expect(new ProxyParser().parse('http://user:password@localhost:8080').auth).toEqual({ + username: 'user', + password: 'password' + }) + }) + + it('does not add authentication when credentials are absent', () => { + expect(new ProxyParser().parse('http://localhost:8080')).toEqual({ + protocol: 'http', + host: 'localhost', + port: 8080 + }) + }) + + it('authenticates stream requests through a proxy with escaped credentials', async () => { + const authorization = `Basic ${Buffer.from('user@example.com:p:ss/word').toString('base64')}` + let receivedAuthorization: string | undefined + let receivedUrl: string | undefined + const proxy = createServer((request, response) => { + receivedAuthorization = request.headers['proxy-authorization'] + receivedUrl = request.url + response.writeHead(receivedAuthorization === authorization ? 200 : 407) + response.end('proxy response') + }) + await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve)) + + try { + const { port } = proxy.address() as AddressInfo + const tester = new StreamTester({ + options: { proxy: `http://user%40example.com:p%3Ass%2Fword@127.0.0.1:${port}` } + }) + const response = await tester.client('http://example.test/live/playlist.m3u8', { + timeout: 2000 + }) + + expect(response.status).toBe(200) + expect(receivedAuthorization).toBe(authorization) + expect(receivedUrl).toBe('http://example.test/live/playlist.m3u8') + } finally { + await new Promise((resolve, reject) => { + proxy.close(error => (error ? reject(error) : resolve())) + }) + } + }) +})