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
17 changes: 12 additions & 5 deletions AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,8 @@ whatever supplied it. `client_ip_source` is one of `runtime` (the address the tr
`unavailable`. When it is `unavailable` the `client_ip` field is **omitted entirely** rather than sent
empty, so a missing address cannot read as a failed lookup of a real one. A forwarded header is never
trusted implicitly: with no `trustedProxy` policy the address is whatever the transport observed, and in a
runtime that exposes no transport peer there is no address to report at all.
runtime that exposes no transport peer there is no address to report at all unless your code supplies one
with `peerAddress` (below).

### Behaviour change: how the client address is determined

Expand All @@ -481,11 +482,17 @@ Two consequences if you are upgrading:
peer. **If your app runs behind a proxy or load balancer, addresses will now show as the proxy's**
until you declare your proxies with `trustedProxy` (below) — which affects attribution in reports and
any rule matching on `server.ip` or `REMOTE_ADDR`.
- **Fetch runtimes report no address at all.** A WHATWG `Request` exposes no transport peer, so a Fetch
guard (Workers, Deno, Bun, edge) has nothing to observe, and no forwarded header is accepted in its
place under any `trustedProxy` policy: `client_ip_source` is `unavailable` and no address is sent.
- **Fetch runtimes report no address unless you supply the peer.** A WHATWG `Request` exposes no
transport peer, so a Fetch guard (Workers, Deno, Bun, edge) has nothing to observe on its own, and no
forwarded header is accepted in its place: `client_ip_source` is `unavailable` and no address is sent.
Earlier versions reported the forwarded header here, so an address-scoped rule that appeared to work on
such a runtime was matching a client-supplied value.
such a runtime was matching a client-supplied value. Where your runtime does know the peer, pass
`peerAddress: (request, ...handlerArgs) => string` — for example `(req, info) => info.remoteAddr.hostname`
on Deno, or `(req, server) => server.requestIP(req)?.address` on Bun. It receives the request and the
arguments your handler was called with (`fetchGuard()(request, ...args)` and
`screenResponse(response, request, ...args)` pass them on), and that address then counts as the
transport peer, including for `trustedProxy`. With `trustedProxy` set and no peer supplied, the guard
warns once, because the policy can never apply.

`trustedProxy` is the only way to make a forwarded header count. It takes the proxies you actually run —
`{ peers: ['10.0.0.0/8'] }`, or `{ hops: 1 }` to trust that many hops in from the peer, plus optional
Expand Down
25 changes: 19 additions & 6 deletions src/protect/protect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ export interface Protection {
mode: "block" | "dry-run";
/** Active rules split by phase. */
rules: { request: unknown[]; response: unknown[]; egress: unknown[] };
/** (request) => Response (403 when blocked) | null (allow / dry-run). Request phase only. */
fetchGuard(): (request: Request) => Promise<Response | null>;
/** (request, ...hostArgs) => Response (403 when blocked) | null (allow / dry-run). Request phase only.
* Any further arguments are the host's handler arguments, passed on to `peerAddress`. */
fetchGuard(): (request: Request, ...hostArgs: unknown[]) => Promise<Response | null>;
/** Screens the request, then the response (secret-leak redaction / withhold). */
fetch(handler: (request: Request, ...rest: unknown[]) => unknown): (request: Request, ...rest: unknown[]) => Promise<unknown>;
/**
Expand All @@ -25,8 +26,11 @@ export interface Protection {
* Pass the originating `request` wherever it is available. A response rule can be scoped to a route or a
* method (`when`), and that scope can only be applied if the engine is given the request the response
* belongs to — without it, a scoped response rule is delivered, counted as protection, and never matches.
*
* The client address is the one resolved when this guard screened that request, if it did; otherwise it
* is resolved here, and any further arguments are passed to `peerAddress` as the host's handler arguments.
*/
screenResponse(response: Response, request?: Request): Promise<Response>;
screenResponse(response: Response, request?: Request, ...hostArgs: unknown[]): Promise<Response>;
express(options?: { screenResponses?: boolean }): (req: unknown, res: unknown, next: () => void) => void;
node(options?: { maxBodyBytes?: number; screenResponses?: boolean }): (req: unknown, res: unknown, next: () => void) => void;
/** Present when `egress: true` — restores the original global fetch. */
Expand Down Expand Up @@ -273,11 +277,20 @@ export interface CreateProtectionOptions {
read(): unknown | Promise<unknown>;
write(envelope: unknown): unknown | Promise<unknown>;
};
/**
* The transport peer of a Fetch request, for runtimes where the host knows it and the `Request` does
* not — e.g. `(req, info) => info.remoteAddr.hostname` on Deno, `(req, server) => server.requestIP(req)?.address`
* on Bun. Called with the request the host served and the arguments its handler received (passed through
* `fetch(handler)`, `fetchGuard()(request, ...args)` and `screenResponse(response, request, ...args)`).
* The result counts as the peer for client address resolution, including `trustedProxy`. A throw, or a result that is not an address, supplies no peer.
*/
peerAddress?: (request: Request, ...hostArgs: unknown[]) => string | null | undefined;
/**
* Declare which peers are this deployment's own reverse proxies, so a forwarded header can be believed.
*
* With no policy, the client address is whatever the transport observed — the socket peer on Node, and
* nothing at all in a runtime that exposes no peer, where the provenance reads `unavailable`. A
* With no policy, the client address is whatever the transport observed — the socket peer on Node, the
* `peerAddress` result on a Fetch runtime, and nothing at all where neither is available, in which case
* the provenance reads `unavailable` (and, with a policy set, the guard warns once). A
* forwarded header is never trusted implicitly: it is ordinary request input that any caller can send.
*
* A policy must say WHO is trusted, not just which header to read. Declare at least one of:
Expand Down Expand Up @@ -385,7 +398,7 @@ export function createSupabaseGuard(opts: {
maxBodyBytes?: number;
/** Maximum upstream request duration. Default 30 seconds. */
timeoutMs?: number;
}): (request: Request) => Promise<Response>;
}): (request: Request, ...hostArgs: unknown[]) => Promise<Response>;

/**
* Server-function guard: inspect a TanStack server function's decoded args against the same
Expand Down
73 changes: 60 additions & 13 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { createDetectionReporter } from './detections.js';
import { reportingState } from './reporting-state.js';
import { hardensWithoutBody } from './response-hardening.js';
import { notify } from './notify.js';
import { SOURCE_REQUEST } from './supabase-guard.js';
import { createFirewallLogReporter, resolveApiBase, telemetryEnabled } from './firewall-log.js';

// Supabase-tunnel guard for AI-builder apps (Lovable / TanStack Start + Supabase).
Expand Down Expand Up @@ -687,20 +688,74 @@ export async function createProtection(options = {}) {
};
};

/**
* The transport peer for a Fetch request, from the host's `peerAddress` callback.
*
* A WHATWG Request carries no peer, so on a Fetch runtime the address is only known to the host (Deno's
* handler info, Bun's server, a Node adapter's socket). The callback gets the request the host served —
* for a request rebuilt from another, the original — and the host's own handler arguments. What it
* returns is validated as an address by the resolver; a callback that throws supplies no peer.
*/
const peerOf = (request, hostArgs) => {
if (typeof options.peerAddress !== 'function') return undefined;
try {
return options.peerAddress(request?.[SOURCE_REQUEST] ?? request, ...hostArgs);
} catch (err) {
notify(onError, err, 'onError');

return undefined;
}
};
// A trust policy names the peers a forwarded header may be believed from, so without a usable peer it
// can never apply and every address reads `unavailable`. Said once per guard, because it holds for
// every request.
let warnedNoPeer = false;
const warnNoPeer = () => {
if (warnedNoPeer) return;
warnedNoPeer = true;
const message =
'[patchstack] trustedProxy is set, but this Fetch runtime supplied no peer address, so forwarded ' +
'headers are not used and client addresses read as unavailable. Pass { peerAddress } to supply one.';
if (typeof onError === 'function') notify(onError, new Error(message), 'onError');
else console.warn(message);
};
// The address resolved for each screened Fetch request, so a later response screen for the same request
// names the same client rather than resolving again without the host's arguments.
const clientByRequest = new WeakMap();
// The client for a response screened on its own: the request phase's answer when there was one,
// otherwise resolved here the same way the request phase would have.
const clientForResponse = (request, hostArgs) => {
const known = clientByRequest.get(request);
if (known) return known;
const client = resolveClientIp({
peer: peerOf(request, hostArgs),
headers: headerObject(request.headers),
trustedProxy: options.trustedProxy,
});
if (options.trustedProxy !== undefined && client.source === 'unavailable') warnNoPeer();

return client;
};

/**
* Screen a fetch request once, and hand back both the decision and the address it resolved.
*
* Shared by `fetchGuard()` and `fetch(handler)` so the response phase can reuse the request phase's
* resolution instead of making its own.
*/
const screenFetchRequest = async (request) => {
const screenFetchRequest = async (request, hostArgs = []) => {
let result;
let shaped;
try {
shaped = await fromFetchRequest(request, {
peer: peerOf(request, hostArgs),
trustedProxy: options.trustedProxy,
maxBodyBytes: options.maxBodyBytes,
});
if (shaped?._clientIp) {
clientByRequest.set(request, shaped._clientIp);
if (options.trustedProxy !== undefined && shaped._clientIp.source === 'unavailable') warnNoPeer();
}
if (shaped?._bodyInspectionSkip) {
recordSkip('request', shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
}
Expand Down Expand Up @@ -1442,20 +1497,12 @@ export async function createProtection(options = {}) {
// .fetch(), and by the Supabase guard on its forwarded upstream response.
// A standalone response screen with no request phase of its own — the Supabase guard's forwarded
// upstream response. It resolves once here, which is the only resolution for this call.
screenResponse: (response, request) =>
screenResp(
response,
request
? reqContextFromFetch(
request,
resolveClientIp({ headers: headerObject(request.headers), trustedProxy: options.trustedProxy }),
)
: undefined,
),
screenResponse: (response, request, ...hostArgs) =>
screenResp(response, request ? reqContextFromFetch(request, clientForResponse(request, hostArgs)) : undefined),

// (request) => Response | null (null = allow, caller proceeds). Request phase only.
fetchGuard() {
return async (request) => (await screenFetchRequest(request)).blocked;
return async (request, ...hostArgs) => (await screenFetchRequest(request, hostArgs)).blocked;
},

// Wrap a fetch handler: screens the request, then the response (redact/block).
Expand All @@ -1465,7 +1512,7 @@ export async function createProtection(options = {}) {
// screening making a second one. Two resolutions for one request can disagree, and a response
// detection naming a different address than the request detection describes two clients that do
// not exist.
const { blocked, client } = await screenFetchRequest(request);
const { blocked, client } = await screenFetchRequest(request, rest);
if (blocked) return blocked;
const response = await handler(request, ...rest);

Expand Down
11 changes: 8 additions & 3 deletions src/protect/supabase-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,17 @@ function responseHeaders(input) {
return output;
}

/** Marks a request rebuilt from another with the request the host served. */
export const SOURCE_REQUEST = Symbol.for('@patchstack/connect.sourceRequest');

/**
* @param {object} opts
* @param {{ fetchGuard: () => (req: Request) => Promise<Response|null> }} opts.protection a createProtection() result
* @param {string|undefined} opts.supabaseUrl the app's Supabase project URL (server-side env) — the only allowed forward target
* @param {typeof fetch} [opts.fetchImpl] injectable fetch (tests)
* @param {number} [opts.maxBodyBytes] maximum tunneled request body size
* @param {number} [opts.timeoutMs] maximum upstream request duration
* @returns {(request: Request) => Promise<Response>}
* @returns {(request: Request, ...hostArgs: unknown[]) => Promise<Response>}
*/
export function createSupabaseGuard({
protection,
Expand All @@ -109,7 +112,7 @@ export function createSupabaseGuard({
const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;

return async function handleGuardRequest(request) {
return async function handleGuardRequest(request, ...hostArgs) {
const target = request.headers.get('x-ps-target');
if (!target) return new Response('patchstack: missing x-ps-target', { status: 400 });

Expand Down Expand Up @@ -148,7 +151,9 @@ export function createSupabaseGuard({
headers: request.headers,
body,
});
const blocked = await guard(evalReq);
// The rebuilt request stands for the one the host served, so the peer is read from that one.
Object.defineProperty(evalReq, SOURCE_REQUEST, { value: request });
const blocked = await guard(evalReq, ...hostArgs);
if (blocked) return blocked;

// Allowed → forward to Supabase, server-side.
Expand Down
Loading
Loading