diff --git a/README.md b/README.md index 83caac8f..9c4e05c0 100644 --- a/README.md +++ b/README.md @@ -1666,6 +1666,15 @@ checkpoints only after stdout and every forwarding destination have accepted an event. A cursor gap stops with a nonzero exit unless `--on-gap continue` was selected. +Use `-H 'X-Local-Mode: test'` to add a custom header to forwarded requests. +Repeat `-H` for multiple headers. Reserved authentication, framing, and +Polymorfa signature headers cannot be overridden; custom headers cannot follow +a cross-origin redirect. Avoid passing secrets in command arguments. For a +self-signed loopback HTTPS handler, add `--skip-verify`. This option never +changes verification for the Polymorfa API or a public forwarding destination. +`--json` already provides JSONL output. Noninteractive and JSON forwarding +require `--forward-secret-out`; the per-run secret is not printed to stdout. + ## Trigger test events `polymorfa trigger` sends a signed test event for a Test number. The event diff --git a/src/commands/listen.ts b/src/commands/listen.ts index 9f149d92..274cf045 100644 --- a/src/commands/listen.ts +++ b/src/commands/listen.ts @@ -8,6 +8,7 @@ import {loadCheckpoint} from '../listener/checkpoint.js' import { createForwardingSecret, forwardEvent, + parseForwardHeaders, validateForwardDestination, } from '../listener/forwarding.js' import {ListenerProtocolClient} from '../listener/protocol.js' @@ -45,6 +46,17 @@ export default class Listen extends BaseCommand { description: 'Forward exact event payloads to this destination.', multiple: true, }), + header: Flags.string({ + char: 'H', + dependsOn: ['forward-to'], + description: 'Add a custom header to forwarded requests (Name: value).', + multiple: true, + }), + 'skip-verify': Flags.boolean({ + dependsOn: ['forward-to'], + description: + 'Accept a self-signed HTTPS certificate from a loopback forwarding destination.', + }), from: Flags.string({ description: 'Resume after this opaque listener cursor.', exclusive: ['checkpoint-file'], @@ -116,9 +128,10 @@ export default class Listen extends BaseCommand { } const targets = flags['forward-to'] ?? [] + const headers = parseForwardHeaders(flags.header ?? []) let forwardingSecret: string | undefined if (targets.length > 0) { - await Promise.all( + const destinations = await Promise.all( targets.map((target) => validateForwardDestination( target, @@ -126,6 +139,15 @@ export default class Listen extends BaseCommand { ), ), ) + if ( + flags['skip-verify'] && + destinations.some(({loopback}) => !loopback) + ) { + throw new CliConditionError( + 'action_required', + '--skip-verify requires loopback forwarding destinations.', + ) + } const interactive = process.stdin.isTTY === true && process.stderr.isTTY === true if ( @@ -173,7 +195,9 @@ export default class Listen extends BaseCommand { eventId: stringField(event, 'id'), eventType: stringField(event, 'type'), followRedirects: flags['follow-redirects'], + headers, secret: forwardingSecret, + skipVerify: flags['skip-verify'], targets, signal: controller.signal, }), diff --git a/src/listener/forwarding.ts b/src/listener/forwarding.ts index 10c4bf8c..3ece93bd 100644 --- a/src/listener/forwarding.ts +++ b/src/listener/forwarding.ts @@ -51,6 +51,8 @@ export interface ForwardEventInput { readonly targets: readonly string[] readonly allowRemote?: boolean readonly followRedirects?: boolean + readonly headers?: Readonly> + readonly skipVerify?: boolean readonly signal?: AbortSignal } @@ -91,7 +93,7 @@ export async function validateForwardDestination( value: string, allowRemote: boolean, resolveHost: DestinationResolver = resolve, -): Promise<{address: string; family: 4 | 6; url: URL}> { +): Promise<{address: string; family: 4 | 6; loopback: boolean; url: URL}> { let url: URL try { url = new URL(value) @@ -134,7 +136,56 @@ export async function validateForwardDestination( const selected = addresses[0] if (!selected) throw new ForwardingError('The forwarding destination has no address.') - return {address: selected.address, family: selected.family, url} + return { + address: selected.address, + family: selected.family, + loopback: allLoopback, + url, + } +} + +const RESERVED_FORWARD_HEADERS = new Set([ + 'accept', + 'authorization', + 'connection', + 'content-length', + 'content-type', + 'cookie', + 'host', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]) + +export function parseForwardHeaders( + values: readonly string[], +): Record { + const headers = Object.create(null) as Record + for (const value of values) { + const separator = value.indexOf(':') + const name = value.slice(0, separator).trim() + const rawValue = value.slice(separator + 1) + const headerValue = rawValue.trim() + if ( + separator < 1 || + !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || + [...rawValue].some((character) => { + const code = character.charCodeAt(0) + return code < 32 || code === 127 || code > 255 + }) || + RESERVED_FORWARD_HEADERS.has(name.toLowerCase()) || + name.toLowerCase().startsWith('polymorfa-') + ) { + throw new ForwardingError('A forwarding header is invalid or reserved.') + } + const key = name.toLowerCase() + if (Object.hasOwn(headers, key)) + throw new ForwardingError('A forwarding header was repeated.') + headers[key] = headerValue + } + return headers } export async function forwardEvent(input: ForwardEventInput): Promise { @@ -159,6 +210,11 @@ async function forwardTarget( target, input.allowRemote ?? false, ) + if (input.skipVerify && !destination.loopback) { + throw new ForwardingError( + '--skip-verify requires a loopback forwarding destination.', + ) + } const timestamp = Math.floor(Date.now() / 1000) let response: ResponseSummary try { @@ -186,6 +242,15 @@ async function forwardTarget( throw new ForwardingError('The forwarding redirect limit was exceeded.') } target = new URL(response.location, destination.url).toString() + if ( + input.headers && + Object.keys(input.headers).length > 0 && + new URL(target).origin !== destination.url.origin + ) { + throw new ForwardingError( + 'Custom forwarding headers cannot follow a cross-origin redirect.', + ) + } redirects += 1 attempt -= 1 continue @@ -217,6 +282,7 @@ async function forwardTarget( interface ResolvedDestination { readonly address: string readonly family: 4 | 6 + readonly loopback: boolean readonly url: URL } @@ -238,6 +304,7 @@ function send( { family: destination.family, headers: { + ...input.headers, Accept: '*/*', 'Content-Length': input.body.byteLength, 'Content-Type': 'application/json', @@ -254,6 +321,7 @@ function send( method: 'POST', path: `${destination.url.pathname}${destination.url.search}`, port: destination.url.port || undefined, + rejectUnauthorized: !(input.skipVerify && destination.loopback), servername: hostname(destination.url), signal: input.signal, timeout: 10_000, diff --git a/test/commands/listen.test.ts b/test/commands/listen.test.ts index 01442a7a..cc3e9284 100644 --- a/test/commands/listen.test.ts +++ b/test/commands/listen.test.ts @@ -144,6 +144,8 @@ describe('listen', () => { '--allow-remote-forward', '--follow-redirects', '--forward-secret-out /tmp/unused-listener-secret', + '-H X-Mode:local', + '--skip-verify', ])('requires --forward-to when using %s', async (flag) => { const result = await runCommand(`listen --project ${PROJECT} ${flag}`) @@ -151,6 +153,41 @@ describe('listen', () => { expect(result.stderr).toContain('--forward-to') }) + test('rejects a reserved custom header without echoing its value', async () => { + const result = await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + () => + runCommand( + `listen --project ${PROJECT} --forward-to http://127.0.0.1:1 -H 'Authorization: hidden-value'`, + ), + ) + expect(result.error).toBeDefined() + expect(result.stderr).toContain('invalid or reserved') + expect(result.stderr).not.toContain('hidden-value') + }) + + test('rejects skip-verify for a public forwarding destination', async () => { + const result = await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + () => + runCommand( + `listen --project ${PROJECT} --forward-to https://1.1.1.1/hook --allow-remote-forward --skip-verify`, + ), + ) + expect(result.error).toBeDefined() + expect(result.stderr).toContain('loopback') + }) + test('reports an actionable machine error when forwarding has no secret destination', async () => { const result = await withEnvironment( { diff --git a/test/fixtures/listener-tls/cert.pem b/test/fixtures/listener-tls/cert.pem new file mode 100644 index 00000000..d82aaac0 --- /dev/null +++ b/test/fixtures/listener-tls/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUB0Y1O/kJzR8tR1RNzJBfuna2UbswDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDkyNjIxNTAwNVoXDTM2MDky +MzIxNTAwNVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAieNzgK8VPhaX7V6rvbZ5J+Ktyru1qjgxG8clHk0SzJ9u +emSmx6q/zu4PkwfnqlXM28gB2ZNFWhbCr9UmhA76Me4wjuJ5oqabdBKkpJ3Pokzn +5hg9c32dMUa+6+CfB2Fg7JXnRb0O8ifKlFtZ9ys1buZnF/12NjEiuXO7/E43nsoW +zRyMix0kaqobjKE8fOs8JDC36DBByitVOf4C2PE5SsBxOoMFsl9QYS7UY5GrlM5H +Ja5n+N0rIPEkR1naszsjWGNB2wtQXCj72dNkPGo0dEFNtSg3L1+0vr3N9VfeUYAl +e/UQhf7lLraq7UsvfZ/e4QUBDTCi6xbrSo/iSwD4mwIDAQABo1MwUTAdBgNVHQ4E +FgQU67TmPkRlrpgy8Cxd8IUP/j9fDZQwHwYDVR0jBBgwFoAU67TmPkRlrpgy8Cxd +8IUP/j9fDZQwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAffDS +SNpH24EDKXxzlBZ+guetZFagGfUtg5HO46JStz83IDpcXE6dd5MBpubyLWd3Hl6/ +Tf7dGx9qAm+mtkADhyDMn9b7f4GajkJATJ+kfCkPC4c/KC82d3JSA/0gFVfYlVpI +fDLGhVCmstQSgClFansuGYggPLebe3rnpOvsTGMaGscGApEqRWaso1KSF1pa5D3z +jVhBWmAhtAftcoe/cvL2z0FgKhQLtnFAHZUndC0xzIfz4w55k68NeFTsR1i7mTTX +cqzwKOxftfdDXAACvxN6EQZCb5oWPxWisMR33XXVAxBCnmpZoBJwhTcgSSpf/Urk +wQqtOUyNNewPcCNWwA== +-----END CERTIFICATE----- diff --git a/test/fixtures/listener-tls/key.pem b/test/fixtures/listener-tls/key.pem new file mode 100644 index 00000000..bef0a41a --- /dev/null +++ b/test/fixtures/listener-tls/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCJ43OArxU+Fpft +Xqu9tnkn4q3Ku7WqODEbxyUeTRLMn256ZKbHqr/O7g+TB+eqVczbyAHZk0VaFsKv +1SaEDvox7jCO4nmippt0EqSknc+iTOfmGD1zfZ0xRr7r4J8HYWDsledFvQ7yJ8qU +W1n3KzVu5mcX/XY2MSK5c7v8TjeeyhbNHIyLHSRqqhuMoTx86zwkMLfoMEHKK1U5 +/gLY8TlKwHE6gwWyX1BhLtRjkauUzkclrmf43Ssg8SRHWdqzOyNYY0HbC1BcKPvZ +02Q8ajR0QU21KDcvX7S+vc31V95RgCV79RCF/uUutqrtSy99n97hBQENMKLrFutK +j+JLAPibAgMBAAECggEAGRga0iWK8SS9e4aKLNmDjR200Tt+IT9euJZyi+Qs8q5l +NcMRky7g/3DVxsVehOJeiPUKWSVzLqULw2JAQzJSmDmn/qj6x/4YNnSaIBdUTU4/ +ueFZdaY7sFcWsejMKFSxhfz1/PweNzXEqWFCqzfi3o7x3izTkaM5lZSZdp5pgu0x +97DSqxY2W33edcgWfaRiaFrAYkrVucKbXTQkb2yo/uR87UX2MGUU/oKc2zdHJ9ns +pojBh76rwZEypENXz+I2NiP4PWMYEA+rfyZuzLtB/2E6Fd72/w5e7rfoMHSeVotW +k8npw67xFVFAaqKoWtLK06ZvxG453pf+RcdLulNgWQKBgQDARukD0Of1irrGdHZg +E6SCXr5g6kkGpzpgQ3TqSYf+P1IOKCXuI+Hv30hsFRQI2v6Tb3NLeYedkUsD0Cka +oULo+08ysORdhH0zBxWjlq2fNFrB4/yQShCXqrQn7ulIN+ymPfSmAxDihQhbKRf/ +vXJmzAjQK5vb7Il2FeYE+/KNnQKBgQC3liHMFUKcJ2Ik1F9mFiY079AfCNLcgsU7 +dF4XC6ne89/6VT9gRMbh7AwnkzydDnwW3YBJwQfoQzILqDxcUpNS+aHXdUpEvtJS +WKfyPzlLSvMdMKQROjvcV1oY5GT0FjwX6YfXaBAvmki6h8GJDTrZPVTkbfoJn0b5 +JjTEnKrllwKBgGKYeggw6aEpgFkfg0wFBoirmKgNu2CkA/U04hD5n7iqM6N6xuh0 +kfj04Cg8LsfTtFNaBR8gtIAB7xuHdGxpYOwjYhs2RgQGWkT+TwRR0bofmKJwDCTZ +NeTv7e3EPu9/beRZRMiULeZaHUzRp7LGLJG18pb8pbGCe4KH/Cl2IIypAoGAVsQw +CQJrTt4WNo3g4KDLQe2tkV1cOzfa+7SN25p5mJPuYaP0pg0bah9jxziBR0FXSH87 +aKBAmndXqcbDcGbeljLBWNFoXoFJaf3YsldLPtiXek5I3JOHQyZNwMIBLJNtp2eJ ++jaGn9jtUqtivuFv1Mh13NhVekK7CnoSKnJZGtUCgYEAgIDatufThxLBOQiqQpl8 +DBdC3V6RRRSSt5tEa2/ftQWJ3ve/uLR2OjwkR+R/xXBq8t9ZzZ22q+CD5TOCvlUh +om3LTp0d1/U+09IgPJv0Iww00qstLfe0iYJpHJxPGmKLwSp2ozJTvrzXp8BdCdA6 +sZJxJi7+5AkaCT9Z60H80vk= +-----END PRIVATE KEY----- diff --git a/test/listener/forwarding.test.ts b/test/listener/forwarding.test.ts index 9b9397db..4bcddb38 100644 --- a/test/listener/forwarding.test.ts +++ b/test/listener/forwarding.test.ts @@ -1,6 +1,8 @@ import {createServer} from 'node:http' +import {createServer as createHttpsServer} from 'node:https' import {createHmac} from 'node:crypto' import {once} from 'node:events' +import {readFile} from 'node:fs/promises' import {afterEach, describe, expect, test, vi} from 'vitest' @@ -8,11 +10,14 @@ import { createForwardingSecret, delayForRetry, forwardEvent, + parseForwardHeaders, signLocalPayload, validateForwardDestination, } from '../../src/listener/forwarding.js' -const servers: ReturnType[] = [] +const servers: Array< + ReturnType | ReturnType +> = [] afterEach(async () => { vi.restoreAllMocks() @@ -27,6 +32,98 @@ afterEach(async () => { }) describe('local event forwarding', () => { + test('validates custom header names and protects signing and framing headers', () => { + expect(parseForwardHeaders(['X-Mode: local', 'X-Path: a:b'])).toEqual({ + 'x-mode': 'local', + 'x-path': 'a:b', + }) + for (const value of [ + 'Authorization: Bearer hidden', + 'Cookie: session=hidden', + 'Content-Length: 0', + 'Polymorfa-Local-Signature: forged', + 'X-Mode: ok\r\nInjected: true', + 'X-Mode: 🚀', + 'invalid', + ]) { + expect(() => parseForwardHeaders([value])).toThrow() + } + expect(() => parseForwardHeaders(['X-Mode: a', 'x-mode: b'])).toThrow( + 'repeated', + ) + }) + + test('accepts a self-signed loopback HTTPS target only with skipVerify', async () => { + const [key, cert] = await Promise.all([ + readFile(new URL('../fixtures/listener-tls/key.pem', import.meta.url)), + readFile(new URL('../fixtures/listener-tls/cert.pem', import.meta.url)), + ]) + let receivedHeader: string | undefined + const server = createHttpsServer({key, cert}, (request, response) => { + receivedHeader = request.headers['x-mode'] as string | undefined + request.resume() + response.writeHead(204).end() + }) + servers.push(server) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('missing address') + const input = { + body: new TextEncoder().encode('{}'), + eventId: 'evt_1', + eventType: 'message.received', + headers: parseForwardHeaders(['X-Mode: local']), + secret: Buffer.alloc(32, 7).toString('base64url'), + targets: [`https://127.0.0.1:${address.port}/hook`], + } + await expect(forwardEvent(input)).rejects.toThrow('could not be reached') + await expect( + forwardEvent({...input, skipVerify: true}), + ).resolves.toBeUndefined() + expect(receivedHeader).toBe('local') + }) + + test('does not send custom headers through an origin-changing redirect', async () => { + let reachedSecond = false + const second = createServer((_request, response) => { + reachedSecond = true + response.writeHead(204).end() + }) + servers.push(second) + second.listen(0, '127.0.0.1') + await once(second, 'listening') + const secondAddress = second.address() + if (!secondAddress || typeof secondAddress === 'string') + throw new Error('missing address') + const first = createServer((_request, response) => { + response + .writeHead(307, { + Location: `http://127.0.0.1:${secondAddress.port}/hook`, + }) + .end() + }) + servers.push(first) + first.listen(0, '127.0.0.1') + await once(first, 'listening') + const firstAddress = first.address() + if (!firstAddress || typeof firstAddress === 'string') + throw new Error('missing address') + await expect( + forwardEvent({ + body: new TextEncoder().encode('{}'), + eventId: 'evt_1', + eventType: 'message.received', + followRedirects: true, + headers: parseForwardHeaders(['X-Mode: local']), + secret: Buffer.alloc(32, 7).toString('base64url'), + targets: [`http://127.0.0.1:${firstAddress.port}/hook`], + }), + ).rejects.toThrow('cross-origin') + expect(reachedSecond).toBe(false) + }) + test('forwards exact bytes to loopback with a verifiable local signature', async () => { let received: | {body: Buffer; headers: Record}