Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 25 additions & 1 deletion src/commands/listen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -116,16 +128,26 @@ 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,
flags['allow-remote-forward'] ?? false,
),
),
)
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 (
Expand Down Expand Up @@ -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,
}),
Expand Down
72 changes: 70 additions & 2 deletions src/listener/forwarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export interface ForwardEventInput {
readonly targets: readonly string[]
readonly allowRemote?: boolean
readonly followRedirects?: boolean
readonly headers?: Readonly<Record<string, string>>
readonly skipVerify?: boolean
readonly signal?: AbortSignal
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, string> {
const headers = Object.create(null) as Record<string, string>
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
}) ||
Comment thread
purpshell marked this conversation as resolved.
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<void> {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -217,6 +282,7 @@ async function forwardTarget(
interface ResolvedDestination {
readonly address: string
readonly family: 4 | 6
readonly loopback: boolean
readonly url: URL
}

Expand All @@ -238,6 +304,7 @@ function send(
{
family: destination.family,
headers: {
...input.headers,
Accept: '*/*',
'Content-Length': input.body.byteLength,
'Content-Type': 'application/json',
Expand All @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions test/commands/listen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,50 @@ 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}`)

expect(exitCode(result.error)).toBe(2)
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(
{
Expand Down
19 changes: 19 additions & 0 deletions test/fixtures/listener-tls/cert.pem
Original file line number Diff line number Diff line change
@@ -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-----
28 changes: 28 additions & 0 deletions test/fixtures/listener-tls/key.pem
Original file line number Diff line number Diff line change
@@ -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-----
Loading