Skip to content

Commit 14f2dbb

Browse files
committed
Add DNS checks to hosted egress guard
1 parent 6897a3c commit 14f2dbb

2 files changed

Lines changed: 153 additions & 16 deletions

File tree

packages/core/sdk/src/hosted-http-client.test.ts

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@ import { describe, expect, it } from "@effect/vitest";
22
import { Effect, Predicate, Result } from "effect";
33
import { HttpClient, HttpClientRequest } from "effect/unstable/http";
44

5-
import { makeHostedHttpClientLayer, validateHostedOutboundUrl } from "./hosted-http-client";
5+
import {
6+
type HostedHostnameResolver,
7+
makeHostedHttpClientLayer,
8+
validateHostedOutboundUrl,
9+
} from "./hosted-http-client";
10+
11+
const publicResolver: HostedHostnameResolver = async () => [
12+
{ address: "93.184.216.34", family: 4 },
13+
];
614

715
describe("hosted outbound HTTP client", () => {
816
it.effect("allows public HTTP and HTTPS URLs", () =>
@@ -51,6 +59,42 @@ describe("hosted outbound HTTP client", () => {
5159
}),
5260
);
5361

62+
it.effect("rejects hostnames that resolve to local or private addresses", () =>
63+
Effect.gen(function* () {
64+
const error = yield* validateHostedOutboundUrl("https://api.example/openapi.json", {
65+
resolveHostname: async () => [{ address: "10.0.0.10", family: 4 }],
66+
}).pipe(Effect.flip);
67+
68+
expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true);
69+
}),
70+
);
71+
72+
it.effect("checks DNS before the first fetch call", () =>
73+
Effect.gen(function* () {
74+
let calls = 0;
75+
const fakeFetch: typeof globalThis.fetch = (async () => {
76+
calls++;
77+
return new Response("unexpected", { status: 200 });
78+
}) as typeof globalThis.fetch;
79+
80+
const result = yield* Effect.gen(function* () {
81+
const client = yield* HttpClient.HttpClient;
82+
return yield* client.execute(HttpClientRequest.get("https://api.example/start"));
83+
}).pipe(
84+
Effect.provide(
85+
makeHostedHttpClientLayer({
86+
fetch: fakeFetch,
87+
resolveHostname: async () => [{ address: "169.254.169.254", family: 4 }],
88+
}),
89+
),
90+
Effect.result,
91+
);
92+
93+
expect(Result.isFailure(result)).toBe(true);
94+
expect(calls).toBe(0);
95+
}),
96+
);
97+
5498
it.effect("checks redirected URLs before following them", () =>
5599
Effect.gen(function* () {
56100
let calls = 0;
@@ -68,7 +112,12 @@ describe("hosted outbound HTTP client", () => {
68112
const result = yield* Effect.gen(function* () {
69113
const client = yield* HttpClient.HttpClient;
70114
return yield* client.execute(HttpClientRequest.get("https://public.example/start"));
71-
}).pipe(Effect.provide(makeHostedHttpClientLayer({ fetch: fakeFetch })), Effect.result);
115+
}).pipe(
116+
Effect.provide(
117+
makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: publicResolver }),
118+
),
119+
Effect.result,
120+
);
72121

73122
expect(Result.isFailure(result)).toBe(true);
74123
expect(calls).toBe(1);
@@ -110,7 +159,11 @@ describe("hosted outbound HTTP client", () => {
110159
}),
111160
),
112161
);
113-
}).pipe(Effect.provide(makeHostedHttpClientLayer({ fetch: fakeFetch })));
162+
}).pipe(
163+
Effect.provide(
164+
makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: publicResolver }),
165+
),
166+
);
114167

115168
expect(response.status).toBe(200);
116169
expect(seen).toHaveLength(2);
@@ -152,7 +205,11 @@ describe("hosted outbound HTTP client", () => {
152205
HttpClientRequest.setHeaders({ authorization: "Bearer secret" }),
153206
),
154207
);
155-
}).pipe(Effect.provide(makeHostedHttpClientLayer({ fetch: fakeFetch })));
208+
}).pipe(
209+
Effect.provide(
210+
makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: publicResolver }),
211+
),
212+
);
156213

157214
expect(response.status).toBe(200);
158215
expect(seen).toHaveLength(2);
@@ -181,7 +238,12 @@ describe("hosted outbound HTTP client", () => {
181238
const result = yield* Effect.gen(function* () {
182239
const client = yield* HttpClient.HttpClient;
183240
return yield* client.execute(HttpClientRequest.get("https://api.example/start"));
184-
}).pipe(Effect.provide(makeHostedHttpClientLayer({ fetch: fakeFetch })), Effect.result);
241+
}).pipe(
242+
Effect.provide(
243+
makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: publicResolver }),
244+
),
245+
Effect.result,
246+
);
185247

186248
expect(Result.isFailure(result)).toBe(true);
187249
expect(calls).toBe(1);

packages/core/sdk/src/hosted-http-client.ts

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,20 @@ export class HostedOutboundRequestBlocked extends Schema.TaggedErrorClass<Hosted
99
},
1010
) {}
1111

12+
export interface HostedResolvedAddress {
13+
readonly address: string;
14+
readonly family?: 4 | 6;
15+
}
16+
17+
export type HostedHostnameResolver = (
18+
hostname: string,
19+
) => Promise<ReadonlyArray<HostedResolvedAddress>>;
20+
1221
export interface HostedHttpClientOptions {
1322
readonly allowLocalNetwork?: boolean;
1423
readonly maxRedirects?: number;
1524
readonly fetch?: typeof globalThis.fetch;
25+
readonly resolveHostname?: HostedHostnameResolver;
1626
}
1727

1828
const parseIpv4 = (hostname: string): readonly [number, number, number, number] | null => {
@@ -58,13 +68,37 @@ const parseIpv4MappedIpv6 = (
5868
return [high >> 8, high & 0xff, low >> 8, low & 0xff];
5969
};
6070

61-
const isPrivateIpv4 = ([a, b]: readonly [number, number, number, number]): boolean =>
71+
const isBlockedIpv4 = ([a, b]: readonly [number, number, number, number]): boolean =>
6272
a === 0 ||
6373
a === 10 ||
6474
a === 127 ||
75+
(a === 100 && b >= 64 && b <= 127) ||
6576
(a === 169 && b === 254) ||
6677
(a === 172 && b >= 16 && b <= 31) ||
67-
(a === 192 && b === 168);
78+
(a === 192 && b === 0) ||
79+
(a === 192 && b === 168) ||
80+
(a === 198 && (b === 18 || b === 19)) ||
81+
a >= 224;
82+
83+
const isBlockedIpv6 = (hostname: string): boolean => {
84+
const normalized = hostname.toLowerCase();
85+
if (
86+
normalized === "::" ||
87+
normalized === "::1" ||
88+
normalized === "0:0:0:0:0:0:0:0" ||
89+
normalized === "0:0:0:0:0:0:0:1"
90+
) {
91+
return true;
92+
}
93+
const firstWordText = normalized.split(":").find((part) => part.length > 0);
94+
if (!firstWordText || !/^[0-9a-f]{1,4}$/.test(firstWordText)) return false;
95+
const firstWord = Number.parseInt(firstWordText, 16);
96+
return (
97+
(firstWord & 0xffc0) === 0xfe80 ||
98+
(firstWord & 0xfe00) === 0xfc00 ||
99+
(firstWord & 0xff00) === 0xff00
100+
);
101+
};
68102

69103
const isBlockedMetadataHostname = (hostname: string): boolean => {
70104
const normalized = hostname.toLowerCase();
@@ -80,15 +114,24 @@ const isLocalOrPrivateHostname = (hostname: string): boolean => {
80114
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
81115
if (normalized === "localhost" || normalized.endsWith(".localhost")) return true;
82116
const ipv4 = parseIpv4(normalized);
83-
if (ipv4) return isPrivateIpv4(ipv4);
117+
if (ipv4) return isBlockedIpv4(ipv4);
84118
const mappedIpv4 = parseIpv4MappedIpv6(normalized);
85-
if (mappedIpv4) return isPrivateIpv4(mappedIpv4);
86-
return (
87-
normalized === "::1" ||
88-
normalized.startsWith("fe80:") ||
89-
normalized.startsWith("fc") ||
90-
normalized.startsWith("fd")
91-
);
119+
if (mappedIpv4) return isBlockedIpv4(mappedIpv4);
120+
return isBlockedIpv6(normalized);
121+
};
122+
123+
const isAddressLiteral = (hostname: string): boolean => {
124+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
125+
return parseIpv4(normalized) !== null || /^[0-9a-f:.]+$/i.test(normalized);
126+
};
127+
128+
const resolveHostnameWithNodeDns: HostedHostnameResolver = async (hostname) => {
129+
const { lookup } = await import("node:dns/promises");
130+
const addresses = await lookup(hostname, { all: true, verbatim: true });
131+
return addresses.map(({ address, family }) => ({
132+
address,
133+
family: family === 6 ? 6 : 4,
134+
}));
92135
};
93136

94137
export const validateHostedOutboundUrl = (
@@ -125,6 +168,34 @@ export const validateHostedOutboundUrl = (
125168
reason: "Local and private network addresses are not allowed",
126169
});
127170
}
171+
172+
const normalizedHostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
173+
if (!options.allowLocalNetwork && options.resolveHostname && !isAddressLiteral(url.hostname)) {
174+
const addresses = yield* Effect.tryPromise({
175+
try: () => options.resolveHostname!(normalizedHostname),
176+
catch: () =>
177+
new HostedOutboundRequestBlocked({
178+
url: value,
179+
reason: "Hostname could not be resolved",
180+
}),
181+
});
182+
183+
if (addresses.length === 0) {
184+
return yield* new HostedOutboundRequestBlocked({
185+
url: value,
186+
reason: "Hostname did not resolve to an address",
187+
});
188+
}
189+
190+
for (const { address } of addresses) {
191+
if (isLocalOrPrivateHostname(address)) {
192+
return yield* new HostedOutboundRequestBlocked({
193+
url: value,
194+
reason: "Resolved address is local or private",
195+
});
196+
}
197+
}
198+
}
128199
});
129200

130201
const CREDENTIAL_HEADERS = ["authorization", "proxy-authorization", "cookie"] as const;
@@ -140,12 +211,16 @@ const guardFetch = (
140211
options: HostedHttpClientOptions,
141212
): typeof globalThis.fetch =>
142213
(async (input, init) => {
214+
const guardOptions = {
215+
...options,
216+
resolveHostname: options.resolveHostname ?? resolveHostnameWithNodeDns,
217+
};
143218
const maxRedirects = options.maxRedirects ?? 10;
144219
let current: Parameters<typeof globalThis.fetch>[0] | URL = input;
145220
let currentInit = init;
146221
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
147222
const url = current instanceof Request ? current.url : String(current);
148-
Effect.runSync(validateHostedOutboundUrl(url, options));
223+
await Effect.runPromise(validateHostedOutboundUrl(url, guardOptions));
149224
const response = await underlying(current, {
150225
...currentInit,
151226
redirect: "manual",

0 commit comments

Comments
 (0)