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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
- User-supplied content/download URLs (e.g. `fileUrl`, `sourceUrl`, `imageUrl`) must ALWAYS be validated public-only — call `assertPublicHttpUrl` without `allowPrivateNetwork` and download them with the public-only `providerFetch`, never a private-aware `context.fetcher`. The private-network opt-in covers only the trusted instance host.
- Prefer the shared `assertPublicHttpUrl` / `isBlockedIpAddress` over a bespoke per-provider hostname guard; bespoke guards have missed the cloud-metadata blocklist and bracketed-IPv6 forms.
- Gotcha: a provider that branches on `fetcher === fetch` (e.g. to gate rate limiting to production) must compare against `providerFetch`, since that is the fetcher the runtime now injects — not the global `fetch`.
- Non-fetch egress is held to the same policy. A provider that opens a WebSocket must use `openGuardedWebSocket` from `src/core/guarded-websocket.ts`, never `new WebSocket(...)` directly: it validates the target with the same `assertGuardedEgressUrl` hop check the guarded fetch uses (URL literal plus DNS resolved addresses), accepts the same `allowPrivateNetwork` / `skipDnsValidation` options, and maps `ws`/`wss` onto the `http`/`https` form the guard understands. It works on Node and on workerd, which both expose a client `WebSocket` constructor. Any future non-HTTP transport should reuse `assertGuardedEgressUrl` rather than growing a second, drifting host check.
- The private-network opt-in only means the guard permits the target — it does not make it reachable. Cloudflare Workers cannot route to private addresses at all, so a self-hosted provider pointed at a LAN instance works on Node/Docker/Fly deployments only, regardless of `OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK`.

## TypeScript And Tooling

Expand Down
48 changes: 43 additions & 5 deletions src/core/guarded-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,8 @@ export function createGuardedFetch(options: GuardedFetchOptions = {}): typeof fe
: options.lookup === undefined
? await resolveDefaultLookup()
: options.lookup;
const guardHop = async (value: string, fieldName: string): Promise<URL> => {
const url = assertPublicHttpUrl(value, { fieldName, createError, allowPrivateNetwork });
await assertResolvedAddressesAllowed(url.hostname, fieldName, { allowPrivateNetwork, createError, lookup });
return url;
};
const guardHop = (value: string, fieldName: string): Promise<URL> =>
assertGuardedEgressUrl(value, { fieldName, createError, allowPrivateNetwork, lookup });

const request = input instanceof Request ? input : undefined;
let url = await guardHop(request?.url ?? (input instanceof URL ? input.href : String(input)), "request URL");
Expand Down Expand Up @@ -265,6 +262,47 @@ export function createGuardedFetch(options: GuardedFetchOptions = {}): typeof fe
return guardedFetch;
}

export interface GuardedEgressUrlOptions {
/** Field name used in guard violation messages, e.g. `"request URL"`. */
fieldName: string;
/** Error factory for guard violations. */
createError: (message: string) => Error;
/** Allow RFC 1918 and other private targets while retaining reserved-target guards. */
allowPrivateNetwork?: boolean;
/**
* DNS lookup used to validate resolved addresses: `null` disables the check,
* `undefined` uses the module default (`node:dns` where available).
*/
lookup?: GuardedFetchDnsLookup | null;
}

/**
* Apply the shared SSRF egress policy to one URL: validate the literal with
* {@link assertPublicHttpUrl}, then validate the addresses its hostname
* resolves to, and return the normalized URL.
*
* This is the single hop check behind {@link createGuardedFetch} (which applies
* it to the request URL and every redirect `Location`). It is exported so
* non-fetch egress transports that cannot reuse the fetch wrapper — currently
* {@link ../core/guarded-websocket.ts openGuardedWebSocket} — enforce exactly
* the same policy instead of growing a second, drifting implementation.
*/
export async function assertGuardedEgressUrl(value: string, options: GuardedEgressUrlOptions): Promise<URL> {
const allowPrivateNetwork = options.allowPrivateNetwork === true;
const lookup = options.lookup === undefined ? await resolveDefaultLookup() : options.lookup;
const url = assertPublicHttpUrl(value, {
fieldName: options.fieldName,
createError: options.createError,
allowPrivateNetwork,
});
await assertResolvedAddressesAllowed(url.hostname, options.fieldName, {
allowPrivateNetwork,
createError: options.createError,
lookup,
});
return url;
}

interface ResolvedAddressPolicy {
allowPrivateNetwork: boolean;
createError: (message: string) => Error;
Expand Down
296 changes: 296 additions & 0 deletions src/core/guarded-websocket.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
import type { GuardedFetchDnsLookup, ResolvedAddress } from "./guarded-fetch.ts";
import type { GuardedWebSocketFailure, WebSocketConstructor } from "./guarded-websocket.ts";

import { describe, expect, it, vi } from "vitest";
import { openGuardedWebSocket } from "./guarded-websocket.ts";

type Listener = (event: unknown) => void;

class FakeSocket {
static opened: FakeSocket[] = [];

readonly url: string;
closed = false;
private readonly listeners = new Map<string, Listener[]>();

constructor(url: string) {
this.url = url;
FakeSocket.opened.push(this);
}

send(): void {}

close(): void {
this.closed = true;
}

addEventListener(type: string, listener: Listener): void {
const existing = this.listeners.get(type);
if (existing) {
existing.push(listener);
return;
}
this.listeners.set(type, [listener]);
}

emit(type: string, event: unknown = {}): void {
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}

/** Constructor that reports the socket back so the test can drive its events. */
function fakeConstructor(onCreate?: (socket: FakeSocket) => void): WebSocketConstructor {
return function FakeWebSocket(url: string) {
const socket = new FakeSocket(url);
// Open on the next tick so the caller has attached its handshake listeners.
globalThis.setTimeout(() => onCreate?.(socket), 0);
return socket;
} as unknown as WebSocketConstructor;
}

/** Constructor whose sockets open immediately after listeners are attached. */
function openingConstructor(): WebSocketConstructor {
return fakeConstructor((socket) => socket.emit("open"));
}

function lookupTable(entries: Record<string, ResolvedAddress[]>): GuardedFetchDnsLookup {
return async (hostname: string) => {
const result = entries[hostname];
if (!result) {
throw new Error(`no lookup entry for ${hostname}`);
}
return result;
};
}

/** Records which failure kind the guard reported, so tests assert on the kind and not only the message. */
interface FailureRecorder {
createFailure: (kind: GuardedWebSocketFailure, message: string) => Error;
kinds: GuardedWebSocketFailure[];
}

function failureKinds(): FailureRecorder {
const kinds: GuardedWebSocketFailure[] = [];
return {
createFailure: (kind, message) => {
kinds.push(kind);
return new Error(message);
},
kinds,
};
}

describe("openGuardedWebSocket URL guard", () => {
it("rejects a literal loopback target before constructing a socket", async () => {
FakeSocket.opened = [];
await expect(
openGuardedWebSocket("ws://127.0.0.1:8123/api/websocket", { webSocketConstructor: openingConstructor() }),
).rejects.toThrow(/private or reserved IP addresses/);
expect(FakeSocket.opened).toHaveLength(0);
});

it("rejects a cloud metadata target", async () => {
await expect(
openGuardedWebSocket("ws://169.254.169.254/api/websocket", { webSocketConstructor: openingConstructor() }),
).rejects.toThrow(/private or reserved IP addresses/);
});

it("rejects a hostname that resolves into a private range", async () => {
FakeSocket.opened = [];
await expect(
openGuardedWebSocket("ws://homeassistant.example.com/api/websocket", {
webSocketConstructor: openingConstructor(),
lookup: lookupTable({ "homeassistant.example.com": [{ address: "192.168.1.10", family: 4 }] }),
}),
).rejects.toThrow(/must not resolve to private or reserved IP addresses/);
expect(FakeSocket.opened).toHaveLength(0);
});

it("allows a private-resolving hostname when private network access is opted in", async () => {
const socket = await openGuardedWebSocket("ws://homeassistant.example.com/api/websocket", {
webSocketConstructor: openingConstructor(),
allowPrivateNetwork: true,
lookup: lookupTable({ "homeassistant.example.com": [{ address: "192.168.1.10", family: 4 }] }),
});
expect(socket).toBeInstanceOf(FakeSocket);
});

it("re-evaluates a function-valued private network flag on every call", async () => {
let allowed = false;
const options = {
webSocketConstructor: openingConstructor(),
allowPrivateNetwork: (): boolean => allowed,
lookup: lookupTable({ "ha.example.com": [{ address: "10.0.0.5", family: 4 }] }),
};
await expect(openGuardedWebSocket("ws://ha.example.com/api/websocket", options)).rejects.toThrow(
/must not resolve to private/,
);
allowed = true;
await expect(openGuardedWebSocket("ws://ha.example.com/api/websocket", options)).resolves.toBeInstanceOf(
FakeSocket,
);
});

it("fails closed when the lookup cannot resolve the hostname", async () => {
await expect(
openGuardedWebSocket("wss://ha.example.com/api/websocket", {
webSocketConstructor: openingConstructor(),
lookup: lookupTable({}),
}),
).rejects.toThrow(/could not be resolved for validation/);
});

it("skips resolved-address validation when skipDnsValidation is set", async () => {
await expect(
openGuardedWebSocket("wss://ha.example.com/api/websocket", {
webSocketConstructor: openingConstructor(),
skipDnsValidation: true,
lookup: lookupTable({}),
}),
).resolves.toBeInstanceOf(FakeSocket);
});

it("rejects a non-http scheme", async () => {
await expect(
openGuardedWebSocket("ftp://ha.example.com/api/websocket", { webSocketConstructor: openingConstructor() }),
).rejects.toThrow(/must use http or https/);
});

it("uses the caller's guard error factory and field name", async () => {
class GuardError extends Error {}
await expect(
openGuardedWebSocket("ws://127.0.0.1/api/websocket", {
webSocketConstructor: openingConstructor(),
createError: (message) => new GuardError(message),
fieldName: "Home Assistant base URL",
}),
).rejects.toThrow(/^Home Assistant base URL/);
});
});

describe("openGuardedWebSocket scheme mapping", () => {
it("connects over wss when the validated URL is https", async () => {
FakeSocket.opened = [];
await openGuardedWebSocket("https://ha.example.com/api/websocket", {
webSocketConstructor: openingConstructor(),
});
expect(FakeSocket.opened[0]?.url).toBe("wss://ha.example.com/api/websocket");
});

it("connects over ws when the validated URL is http", async () => {
FakeSocket.opened = [];
await openGuardedWebSocket("http://ha.example.com:8123/api/websocket", {
webSocketConstructor: openingConstructor(),
});
expect(FakeSocket.opened[0]?.url).toBe("ws://ha.example.com:8123/api/websocket");
});

it("preserves a wss input and its query string", async () => {
FakeSocket.opened = [];
await openGuardedWebSocket("wss://ha.example.com/api/websocket?v=1", {
webSocketConstructor: openingConstructor(),
});
expect(FakeSocket.opened[0]?.url).toBe("wss://ha.example.com/api/websocket?v=1");
});

it("normalizes the hostname before connecting", async () => {
FakeSocket.opened = [];
await openGuardedWebSocket("wss://HA.Example.COM./api/websocket", {
webSocketConstructor: openingConstructor(),
});
expect(FakeSocket.opened[0]?.url).toBe("wss://ha.example.com/api/websocket");
});
});

describe("openGuardedWebSocket failures", () => {
it("reports an unsupported runtime when the global constructor is missing", async () => {
const { createFailure, kinds } = failureKinds();
vi.stubGlobal("WebSocket", undefined);
try {
await expect(openGuardedWebSocket("wss://ha.example.com/api/websocket", { createFailure })).rejects.toThrow(
/WebSocket is unavailable in this runtime/,
);
} finally {
vi.unstubAllGlobals();
}
expect(kinds).toEqual(["unsupported"]);
});

it("falls back to the global constructor when none is injected", async () => {
const created: string[] = [];
vi.stubGlobal(
"WebSocket",
fakeConstructor((socket) => {
created.push(socket.url);
socket.emit("open");
}),
);
try {
await expect(openGuardedWebSocket("wss://ha.example.com/api/websocket")).resolves.toBeInstanceOf(FakeSocket);
} finally {
vi.unstubAllGlobals();
}
expect(created).toEqual(["wss://ha.example.com/api/websocket"]);
});

it("reports a connect failure when the socket errors before opening", async () => {
const { createFailure, kinds } = failureKinds();
await expect(
openGuardedWebSocket("wss://ha.example.com/api/websocket", {
webSocketConstructor: fakeConstructor((socket) => socket.emit("error")),
createFailure,
}),
).rejects.toThrow(/WebSocket connection failed/);
expect(kinds).toEqual(["connect"]);
});

it("reports a connect failure when the peer closes before opening", async () => {
const { createFailure, kinds } = failureKinds();
await expect(
openGuardedWebSocket("wss://ha.example.com/api/websocket", {
webSocketConstructor: fakeConstructor((socket) => socket.emit("close", { code: 1006 })),
createFailure,
}),
).rejects.toThrow(/closed before the connection opened/);
expect(kinds).toEqual(["connect"]);
});

it("times out and closes a socket that never opens", async () => {
const { createFailure, kinds } = failureKinds();
FakeSocket.opened = [];
await expect(
openGuardedWebSocket("wss://ha.example.com/api/websocket", {
webSocketConstructor: fakeConstructor(),
connectTimeoutMs: 5,
createFailure,
}),
).rejects.toThrow(/timed out/);
expect(kinds).toEqual(["timeout"]);
expect(FakeSocket.opened[0]?.closed).toBe(true);
});

it("aborts a pending connection when the signal fires", async () => {
const controller = new AbortController();
const pending = openGuardedWebSocket("wss://ha.example.com/api/websocket", {
webSocketConstructor: fakeConstructor(),
signal: controller.signal,
});
controller.abort();
await expect(pending).rejects.toThrow(/aborted/);
});

it("ignores a late close event after the socket opened", async () => {
let opened: FakeSocket | undefined;
const socket = await openGuardedWebSocket("wss://ha.example.com/api/websocket", {
webSocketConstructor: fakeConstructor((created) => {
opened = created;
created.emit("open");
}),
});
expect(socket).toBe(opened);
// Post-handshake events belong to the caller; they must not reject retroactively.
expect(() => opened?.emit("close", { code: 1000 })).not.toThrow();
});
});
Loading
Loading