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
58 changes: 55 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,11 @@ installWebSocketGateway(adapter, connector, {
});
```

Payment headers, hop-by-hop headers, `host`, `content-length`, and lease tokens are never forwarded
upstream. Dynamic route params use `[name]` segments and upstream placeholders must come from the
matched public path.
Client-supplied payment headers (including all `x-x402-*` names), hop-by-hop headers, `host`,
`content-length`, and lease tokens are never forwarded upstream. The proxy itself injects trusted
`x-x402-*` payment-metadata headers on paid upstream requests (see
[Payment metadata forwarding](#payment-metadata-forwarding)). Dynamic route params use `[name]`
segments and upstream placeholders must come from the matched public path.

## Header forwarding

Expand Down Expand Up @@ -254,6 +256,56 @@ Responses always forward a safe default set (`content-type`, `content-dispositio
the proxy re-frames the body (`fetch` transparently decompresses upstream responses). Upstream `3xx`
responses are relayed verbatim (`Location` included) rather than followed.

## Payment metadata forwarding

For every paid upstream request the proxy injects trusted `x-x402-*` headers describing the verified
payment, so upstreams (e.g. a host API mounting this SDK in-process) can do payer/transaction-level
accounting. Values come from the verified accepted payment requirements and the settle result — never
from client-controlled payload fields.

| Header | Value |
| --- | --- |
| `x-x402-payment-id` | SDK-generated UUID for this verified payment; correlates with `onPaymentSettled` |
| `x-x402-resource-id` | Resource id, **always `encodeURIComponent`-encoded** (ids may contain `/`, spaces, `%`, ...) — decode before comparing |
| `x-x402-scheme` | Accepted payment scheme (e.g. `exact`) |
| `x-x402-network` | Accepted CAIP-2 network |
| `x-x402-amount` | Accepted on-chain amount (base units) |
| `x-x402-asset` | Accepted asset address |
| `x-x402-pay-to` | Accepted payout address |
| `x-x402-payer` | Settled payer address (settle result) — only when settlement precedes the upstream request |
| `x-x402-transaction` | Settlement transaction hash — only when settlement precedes the upstream request |

Per-kind availability:

| Kind | Headers on the upstream request |
| --- | --- |
| `http` | All except `x-x402-payer`/`x-x402-transaction` (settles after the upstream accepts) — correlate via `x-x402-payment-id` + `onPaymentSettled` |
| `http-stream-direct` | Same as `http` |
| `http-stream` (lease relay) | Full set including `x-x402-payer` and `x-x402-transaction` (settlement happened at lease issuance; the details ride inside the signed lease token, so leases minted before 0.2.1 relay without metadata headers) |

To be notified of every successful settlement — including `http`/`http-stream-direct`, whose
`payer`/`transaction` are only known post-request — supply `onPaymentSettled`:

```ts
const sdk = createX402ProxySdk({
// ...
onPaymentSettled: async (event) => {
// event: { paymentId, resourceId, kind, transaction, network, payer?, requirements, settledAt }
await accounting.recordSettlement(event.paymentId, event.payer, event.transaction, event.requirements);
},
});
```

The callback fires for all resource kinds and is fire-and-forget: a throwing or rejecting callback
never affects the payment or the proxied response. Set `forwardPaymentMetadata: false` to disable the
upstream headers; the `onPaymentSettled` callback fires regardless.

**Security.** The nine `x-x402-*` metadata names are internal payment headers: client-supplied values
for them are always stripped and can never reach the upstream — not even when a resource's
`forwardRequestHeaders` explicitly lists them — so any `x-x402-*` value an upstream sees was injected
by the proxy. Metadata values are additionally guarded (printable ASCII only, resource ids
URI-encoded); an invalid value is skipped rather than breaking the paid request.

## Request bodies

The proxy forwards `POST`, `PUT`, `PATCH`, and `DELETE` bodies. When no body parser has consumed the
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@missionsquad/x402-proxy",
"version": "0.2.0",
"version": "0.2.1",
"description": "TypeScript SDK for x402-protected HTTP and WebSocket proxy endpoints.",
"repository": {
"type": "git",
Expand Down
88 changes: 86 additions & 2 deletions src/headerPolicy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Request, Response } from "express";

import type { X402HeaderPolicy, X402HeaderPreset, X402ResourceAccess } from "./types";
import type { X402HeaderPolicy, X402HeaderPreset, X402PaymentMetadata, X402ResourceAccess } from "./types";

const HOP_BY_HOP_HEADERS = new Set([
"connection",
Expand All @@ -13,13 +13,33 @@ const HOP_BY_HOP_HEADERS = new Set([
"upgrade",
]);

const INTERNAL_PAYMENT_HEADERS = new Set([
/**
* SDK-minted payment-metadata header names, keyed by `X402PaymentMetadata` field. All
* nine are registered as internal payment headers, so client-supplied values can never
* reach the upstream (not even via a resource's `forwardRequestHeaders` allowlist) —
* an upstream can therefore trust that any value it sees was injected by the proxy via
* `applyPaymentMetadataHeaders`.
*/
export const PAYMENT_METADATA_HEADERS = {
paymentId: "x-x402-payment-id",
resourceId: "x-x402-resource-id",
scheme: "x-x402-scheme",
network: "x-x402-network",
amount: "x-x402-amount",
asset: "x-x402-asset",
payTo: "x-x402-pay-to",
payer: "x-x402-payer",
transaction: "x-x402-transaction",
} as const;

const INTERNAL_PAYMENT_HEADERS = new Set<string>([
"payment-signature",
"payment-required",
"payment-response",
"x-payment",
"x-payment-response",
"x-x402-lease",
...Object.values(PAYMENT_METADATA_HEADERS),
]);

/**
Expand Down Expand Up @@ -225,3 +245,67 @@ export function applyServiceTokenAccess(headers: Headers, access?: X402ResourceA
}
headers.set(serviceTokenHeader, serviceTokenValue);
}

/**
* Conservative payment-metadata value guard: printable ASCII only (0x20-0x7E) with no
* leading/trailing whitespace and at least one visible character. Stricter than
* `isValidHttpHeaderValue` on purpose — metadata values come from facilitator/resource
* data and there is no legitimate reason for them to contain non-ASCII or padding.
*/
function isSafePaymentMetadataValue(value: string): boolean {
return /^[\x21-\x7E](?:[\x20-\x7E]*[\x21-\x7E])?$/.test(value);
}

function setPaymentMetadataHeader(headers: Headers, name: string, value: string | undefined): void {
if (value === undefined || !isSafePaymentMetadataValue(value)) {
return;
}
headers.set(name, value);
}

/**
* `encodeURIComponent` that never throws: a lone surrogate (e.g. `"\uD800"`) in the input
* makes the built-in throw `URIError`. Resource ids are operator-controlled but only
* validated as non-empty strings (and custom stores may skip validation entirely), so a
* mid-proxy throw here would 500 an otherwise-valid paid request. Returns null when the id
* cannot be encoded, so the caller omits the header rather than failing the request.
*/
function tryEncodeResourceId(resourceId: string): string | null {
try {
return encodeURIComponent(resourceId);
} catch {
return null;
}
}

/**
* Inject the trusted `x-x402-*` payment-metadata headers on an outbound upstream
* request. This is the single trusted injection point for these names: it writes via
* `headers.set` directly and deliberately does NOT consult `shouldDropProxyHeader`,
* because all nine names are internal payment headers (which exist precisely to strip
* client-supplied values before this runs).
*
* Value handling:
* - `resourceId` is written `encodeURIComponent`-encoded — resource ids may contain
* path-hostile characters (`/`, spaces, `%`, non-ASCII, ...); upstreams must decode
* `x-x402-resource-id` before comparing. An id that cannot be encoded (lone surrogate)
* is skipped rather than allowed to throw.
* - `paymentId` is SDK-generated (`randomUUID`) and written as-is.
* - Every other value is written raw only if it passes a conservative printable-ASCII
* guard; invalid values are skipped silently (a mid-proxy throw must never break a
* paid request), and absent optionals (`payer`, `transaction`) are simply omitted.
*/
export function applyPaymentMetadataHeaders(headers: Headers, metadata: X402PaymentMetadata): void {
headers.set(PAYMENT_METADATA_HEADERS.paymentId, metadata.paymentId);
const encodedResourceId = tryEncodeResourceId(metadata.resourceId);
if (encodedResourceId !== null) {
headers.set(PAYMENT_METADATA_HEADERS.resourceId, encodedResourceId);
}
setPaymentMetadataHeader(headers, PAYMENT_METADATA_HEADERS.scheme, metadata.scheme);
setPaymentMetadataHeader(headers, PAYMENT_METADATA_HEADERS.network, metadata.network);
setPaymentMetadataHeader(headers, PAYMENT_METADATA_HEADERS.amount, metadata.amount);
setPaymentMetadataHeader(headers, PAYMENT_METADATA_HEADERS.asset, metadata.asset);
setPaymentMetadataHeader(headers, PAYMENT_METADATA_HEADERS.payTo, metadata.payTo);
setPaymentMetadataHeader(headers, PAYMENT_METADATA_HEADERS.payer, metadata.payer);
setPaymentMetadataHeader(headers, PAYMENT_METADATA_HEADERS.transaction, metadata.transaction);
}
20 changes: 19 additions & 1 deletion src/httpProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@ import {
UpstreamRequestError,
UpstreamTimeoutError,
} from "./errors";
import { applyServiceTokenAccess, applyUpstreamResponseHeaders, createForwardHeaders } from "./headerPolicy";
import {
applyPaymentMetadataHeaders,
applyServiceTokenAccess,
applyUpstreamResponseHeaders,
createForwardHeaders,
} from "./headerPolicy";
import { interpolateUpstreamUrl } from "./routePattern";
import type {
HttpMethod,
HttpProxyEndpointConfig,
SecurityConfig,
X402HeaderPolicy,
X402PaymentMetadata,
X402ResourceAccess,
} from "./types";

Expand Down Expand Up @@ -176,6 +182,12 @@ export type ProxyHttpRequestInput = {
securityConfig?: SecurityConfig;
params?: Record<string, string>;
excludeQueryParams?: string[];
/**
* Trusted payment metadata to inject as `x-x402-*` headers on the upstream request
* (after the header policy and service-token injection, so it always wins). Client
* values for these names are stripped by the header policy regardless.
*/
paymentMetadata?: X402PaymentMetadata;
};

export type BufferedProxyResponse = {
Expand Down Expand Up @@ -295,6 +307,9 @@ export async function proxyBufferedHttpRequest(input: ProxyHttpRequestInput): Pr

const headers = createForwardHeaders(input.req, input.target.headers);
applyServiceTokenAccess(headers, input.target.access);
if (input.paymentMetadata) {
applyPaymentMetadataHeaders(headers, input.paymentMetadata);
}
const requestInit: RequestInit = {
method: input.req.method,
headers,
Expand Down Expand Up @@ -382,6 +397,9 @@ export async function openStreamingUpstream(input: ProxyHttpRequestInput): Promi

const headers = createForwardHeaders(input.req, input.target.headers);
applyServiceTokenAccess(headers, input.target.access);
if (input.paymentMetadata) {
applyPaymentMetadataHeaders(headers, input.paymentMetadata);
}
const requestInit: RequestInit = {
method: input.req.method,
headers,
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export type {
X402HeaderPolicy,
X402HeaderPreset,
X402LoadedResource,
X402PaymentMetadata,
X402PaymentSettledEvent,
X402ProxyDiagnostics,
X402ProxySdk,
X402ProxySdkConfig,
Expand Down Expand Up @@ -53,11 +55,13 @@ export {
webSocketGatewayEndpointsFromResources,
} from "./wsGateway";
export {
applyPaymentMetadataHeaders,
applyServiceTokenAccess,
applyUpstreamResponseHeaders,
createForwardHeaders,
isValidHttpHeaderName,
isValidHttpHeaderValue,
PAYMENT_METADATA_HEADERS,
shouldDropProxyHeader,
} from "./headerPolicy";
export {
Expand Down Expand Up @@ -99,6 +103,7 @@ export {
verifyHttpStreamLeaseToken,
type HttpStreamLeaseIssueResult,
type HttpStreamLeasePayload,
type HttpStreamLeasePaymentInfo,
type X402LeaseUseStore,
} from "./streamLease";
export { X402ResourceRuntime, endpointToResource } from "./resourceRuntime";
4 changes: 4 additions & 0 deletions src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,15 @@ export function createX402ProxySdk(config: X402ProxySdkConfig): X402ProxySdk {
leaseTokenSecret: config.leaseTokenSecret,
syncFacilitatorOnStart: config.syncFacilitatorOnStart ?? true,
requireProtectedResources: config.requireProtectedResources ?? true,
forwardPaymentMetadata: config.forwardPaymentMetadata ?? true,
paywallConfig,
};
if (config.paywall) {
runtimeInput.paywall = config.paywall;
}
if (config.onPaymentSettled) {
runtimeInput.onPaymentSettled = config.onPaymentSettled;
}
if (config.security) {
runtimeInput.security = config.security;
}
Expand Down
Loading
Loading