From 2b49608fd29cae7d4e5be9d3320396571cbfabe7 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 05:56:18 +0300 Subject: [PATCH] fix(sharing): answer malformed request URLs instead of crashing the server handle() is dispatched via `void`, so a throw before its try/catch is an unhandled rejection on a LAN-facing server. A request target the HTTP parser accepts but the WHATWG URL parser rejects (unterminated IPv6 host like //[::1) threw at new URL() and hung/killed the process. Parse inside a guard and answer 400. Mutation-checked: the test times out with an unhandled error before the fix, passes after. --- src/sharing/share-server.ts | 13 +++++- tests/sharing/malformed-request.test.ts | 58 +++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/sharing/malformed-request.test.ts diff --git a/src/sharing/share-server.ts b/src/sharing/share-server.ts index f56cce3..1f820c6 100644 --- a/src/sharing/share-server.ts +++ b/src/sharing/share-server.ts @@ -73,11 +73,22 @@ export class ShareServer { } private async handle(req: IncomingMessage, res: ServerResponse): Promise { - const url = new URL(req.url ?? '/', 'https://localhost') const json = (code: number, body: unknown): void => { res.writeHead(code, { 'content-type': 'application/json' }) res.end(JSON.stringify(body)) } + // handle() is dispatched with `void` (see the createServer callback), so a + // throw here is an UNHANDLED rejection, not a caught 500. A request target + // the HTTP parser accepts but the WHATWG URL parser rejects - e.g. an + // unterminated IPv6 host like `//[::1` - would otherwise crash this + // LAN-facing server. Parse inside the guard and answer 400 instead. + let url: URL + try { + url = new URL(req.url ?? '/', 'https://localhost') + } catch { + json(400, { error: 'malformed request URL' }) + return + } try { await this.route(url, req, res, json) } catch (err) { diff --git a/tests/sharing/malformed-request.test.ts b/tests/sharing/malformed-request.test.ts new file mode 100644 index 0000000..5d7b19c --- /dev/null +++ b/tests/sharing/malformed-request.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { connect as tlsConnect } from 'tls' + +import { generateIdentity, type Identity } from '../../src/sharing/identity.js' +import { PeerStore } from '../../src/sharing/pairing.js' +import { ShareServer } from '../../src/sharing/share-server.js' + +// The share server listens on the LAN for device pairing and is dispatched via +// `void this.handle(...)`, so a throw inside handle() is an UNHANDLED rejection. +// A request target the HTTP parser accepts but the WHATWG URL parser rejects +// (an unterminated IPv6 host) used to throw at `new URL(...)` before the +// try/catch, which could crash the host process. The server must instead answer +// and stay alive. +describe('share server: malformed request URL does not crash the process', () => { + let server: ShareServer + let serverId: Identity + let clientId: Identity + let port: number + + beforeAll(async () => { + serverId = await generateIdentity('Server') + clientId = await generateIdentity('Client') + server = new ShareServer({ identity: serverId, peers: new PeerStore(), getUsage: async () => ({ current: { cost: 1 } }) }) + port = await server.listen(0, '127.0.0.1') + }) + + afterAll(async () => { + await server.close() + }) + + // Send one raw HTTP request line over mTLS and resolve with the response head. + function rawRequest(line: string): Promise { + return new Promise((resolve, reject) => { + const socket = tlsConnect( + { host: '127.0.0.1', port, key: clientId.key, cert: clientId.cert, rejectUnauthorized: false }, + () => socket.write(`${line}\r\nHost: localhost\r\nConnection: close\r\n\r\n`), + ) + let buf = '' + socket.setTimeout(4000, () => { socket.destroy(); reject(new Error('timed out (server hung)')) }) + socket.on('data', (d) => { buf += d.toString() }) + socket.on('end', () => resolve(buf)) + socket.on('error', reject) + }) + } + + it('answers an unterminated-IPv6 target instead of hanging or crashing', async () => { + // `new URL('//[::1', 'https://localhost')` throws TypeError; llhttp accepts + // the target, so this exercises the exact pre-try throw path. + const res = await rawRequest('GET //[::1 HTTP/1.1') + expect(res).toMatch(/^HTTP\/1\.1 400/) + }) + + it('is still alive for a valid request afterward', async () => { + const res = await rawRequest('GET /api/peer/hello HTTP/1.1') + expect(res).toMatch(/^HTTP\/1\.1 200/) + expect(res).toContain(serverId.fingerprint) + }) +})