From d643e2a0b6a2448944f233bc263d583bece3dad4 Mon Sep 17 00:00:00 2001 From: birme Date: Thu, 3 Sep 2026 07:10:58 +0000 Subject: [PATCH] fix(security): constrain WHIP/WHEP path params to prevent log injection The username, lineId and productionId path params on the WHIP/WHEP POST routes were URL-decoded and interpolated into log messages without any sanitization, allowing newline/CR/ANSI injection to forge log entries. Add restrictive TypeBox patterns: numeric-only (^[0-9]+$) for productionId and lineId (consistent with api_productions.ts), and ^[\w .-]{1,200}$ for username. A literal space is used instead of \s so control chars such as \n and \r are rejected. Adds regression tests. Co-Authored-By: Claude Sonnet 4.6 --- src/api_whep.ts | 9 ++++--- src/api_whip.test.ts | 58 +++++++++++++++++++++++++++++++++----------- src/api_whip.ts | 9 ++++--- 3 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/api_whep.ts b/src/api_whep.ts index 0057d98..7c00a17 100644 --- a/src/api_whep.ts +++ b/src/api_whep.ts @@ -92,9 +92,12 @@ export const apiWhep: FastifyPluginCallback = ( schema: { description: 'WHEP endpoint for Egress WebRTC streams', params: Type.Object({ - productionId: Type.String({ maxLength: 200 }), - lineId: Type.String({ maxLength: 200 }), - username: Type.String({ maxLength: 200 }) + productionId: Type.String({ minLength: 1, pattern: '^[0-9]+$' }), + lineId: Type.String({ minLength: 1, pattern: '^[0-9]+$' }), + username: Type.String({ + maxLength: 200, + pattern: '^[\\w .-]{1,200}$' + }) }), body: WhipWhepRequest, response: { diff --git a/src/api_whip.test.ts b/src/api_whip.test.ts index 5dcb291..5d1f12f 100644 --- a/src/api_whip.test.ts +++ b/src/api_whip.test.ts @@ -145,7 +145,7 @@ describe('apiWhip', () => { const response = await fastify.inject({ method: 'POST', - url: '/whip/prod1/line1/testuser', + url: '/whip/123/456/testuser', headers: { 'content-type': 'application/sdp' }, @@ -156,7 +156,7 @@ describe('apiWhip', () => { expect(response.statusCode).toBe(201); expect(response.headers['content-type']).toBe('application/sdp'); expect(response.headers['location']).toContain( - '/whip/prod1/line1/mock-session-id' + '/whip/123/456/mock-session-id' ); expect(response.payload).toContain('v=0'); }); @@ -170,7 +170,7 @@ describe('apiWhip', () => { const response = await fastify.inject({ method: 'POST', - url: '/whip/prod1/line1/testuser', + url: '/whip/123/456/testuser', headers: { 'content-type': 'application/sdp' }, @@ -192,7 +192,7 @@ describe('apiWhip', () => { const response = await fastify.inject({ method: 'POST', - url: '/whip/prod1/line1/testuser', + url: '/whip/123/456/testuser', headers: { 'content-type': 'application/json' }, @@ -202,6 +202,36 @@ describe('apiWhip', () => { expect(response.statusCode).toBe(415); }); + it('should return 400 when username contains control chars (log injection)', async () => { + const fastify = await createTestServer(); + + const response = await fastify.inject({ + method: 'POST', + url: '/whip/123/456/' + encodeURIComponent('evil\ninjected'), + headers: { + 'content-type': 'application/sdp' + }, + payload: 'v=0\r\n' + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when productionId is not numeric', async () => { + const fastify = await createTestServer(); + + const response = await fastify.inject({ + method: 'POST', + url: '/whip/abc/456/testuser', + headers: { + 'content-type': 'application/sdp' + }, + payload: 'v=0\r\n' + }); + + expect(response.statusCode).toBe(400); + }); + it('should return 429 when rate limit is exceeded', async () => { const fastify = await createTestServer(); @@ -209,7 +239,7 @@ describe('apiWhip', () => { for (let i = 0; i < 10; i++) { await fastify.inject({ method: 'POST', - url: '/whip/prod1/line1/testuser', + url: '/whip/123/456/testuser', headers: { 'content-type': 'application/sdp' }, @@ -220,7 +250,7 @@ describe('apiWhip', () => { // The 11th request should exceed the rate limit const response = await fastify.inject({ method: 'POST', - url: '/whip/prod1/line1/testuser', + url: '/whip/123/456/testuser', headers: { 'content-type': 'application/sdp' }, @@ -241,7 +271,7 @@ describe('apiWhip', () => { const fastify = await createAuthServer(); const res = await fastify.inject({ method: 'POST', - url: '/whip/prod1/line1/testuser', + url: '/whip/123/456/testuser', headers: { 'content-type': 'application/sdp' }, payload: 'v=0\r\n' }); @@ -253,7 +283,7 @@ describe('apiWhip', () => { const fastify = await createAuthServer(); const res = await fastify.inject({ method: 'POST', - url: '/whip/prod1/line1/testuser', + url: '/whip/123/456/testuser', headers: { 'content-type': 'application/sdp', authorization: 'Bearer wrong' @@ -268,7 +298,7 @@ describe('apiWhip', () => { const fastify = await createAuthServer(); const res = await fastify.inject({ method: 'POST', - url: '/whip/prod1/line1/testuser', + url: '/whip/123/456/testuser', headers: { 'content-type': 'application/sdp', authorization: 'Bearer secret-123' @@ -285,7 +315,7 @@ describe('apiWhip', () => { const fastify = await createAuthServer(); const res = await fastify.inject({ method: 'DELETE', - url: '/whip/prod1/line1/mock-session-id' + url: '/whip/123/456/mock-session-id' }); expect(res.statusCode).toBe(401); }); @@ -294,7 +324,7 @@ describe('apiWhip', () => { const fastify = await createAuthServer(); const res = await fastify.inject({ method: 'DELETE', - url: '/whip/prod1/line1/mock-session-id', + url: '/whip/123/456/mock-session-id', headers: { authorization: 'Bearer secret-123' } }); expect(res.statusCode).toBe(200); @@ -309,7 +339,7 @@ describe('apiWhip', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/whip/prod1/line1/mock-session-id' + url: '/whip/123/456/mock-session-id' }); expect(response.statusCode).toBe(200); @@ -322,7 +352,7 @@ describe('apiWhip', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/whip/prod1/line1/nonexistent-session' + url: '/whip/123/456/nonexistent-session' }); expect(response.statusCode).toBe(404); @@ -378,7 +408,7 @@ describe('apiWhip', () => { const response = await fastify.inject({ method: 'PATCH', - url: '/whip/prod1/line1/mock-session-id', + url: '/whip/123/456/mock-session-id', headers: { 'content-type': 'application/trickle-ice-sdpfrag' }, payload: 'a=candidate:1 1 UDP 12345 192.168.1.2 54321 typ host' }); diff --git a/src/api_whip.ts b/src/api_whip.ts index 52d82ed..305afd2 100644 --- a/src/api_whip.ts +++ b/src/api_whip.ts @@ -92,9 +92,12 @@ export const apiWhip: FastifyPluginCallback = ( schema: { description: 'WHIP endpoint for ingesting WebRTC streams', params: Type.Object({ - productionId: Type.String({ maxLength: 200 }), - lineId: Type.String({ maxLength: 200 }), - username: Type.String({ maxLength: 200 }) + productionId: Type.String({ minLength: 1, pattern: '^[0-9]+$' }), + lineId: Type.String({ minLength: 1, pattern: '^[0-9]+$' }), + username: Type.String({ + maxLength: 200, + pattern: '^[\\w .-]{1,200}$' + }) }), body: WhipWhepRequest, response: {