Skip to content

Commit 24b911c

Browse files
committed
Reject address forms the platform resolver reads differently
A dotted quad is legal only at the end of a whole address, and octets are decimal-only: both forms otherwise decode to a public-looking leading word while the resolver dials loopback. Also fail an empty resolver answer so the zero failure TTL drops it instead of blackholing the hostname.
1 parent 5ef218f commit 24b911c

3 files changed

Lines changed: 133 additions & 23 deletions

File tree

.changeset/hosted-dns-guard-latency.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@executor-js/sdk": patch
33
---
44

5-
Cache hosted outbound DNS guard resolutions, so a proxied request no longer pays a fresh lookup on every hop. Also classifies the IPv6 prefixes that carry an IPv4 destination (IPv4-translatable, 6to4, local-use NAT64) by that destination, and checks every address a hostname resolves to rather than the first.
5+
Cache hosted outbound DNS guard resolutions, so a proxied request no longer pays a fresh lookup on every hop. Also tightens the outbound guard's address classifiers: IPv6 prefixes that carry an IPv4 destination (IPv4-translatable, 6to4, local-use NAT64) are classified by that destination, every address a hostname resolves to is checked rather than the first, and address forms the platform resolver reads differently from a decimal-only parser (octal octets, a dotted quad in the head of a compressed literal) no longer classify as public.

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

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,47 @@ describe("hosted outbound HTTP client", () => {
246246
}),
247247
);
248248

249+
// Where the parsers disagree with the platform resolver about what an
250+
// address means, the guard reads one destination and the connection dials
251+
// another. Both forms below are decoded by the parsers, not rejected by
252+
// them, so they reach the classifiers wearing a public-looking leading word
253+
// — the exact failure the "cannot classify" test above cannot catch.
254+
//
255+
// A dotted quad is legal only at the end of the whole address; permitting
256+
// one at the end of the head half of a compressed literal puts 0x7f00 in
257+
// the leading word, where no prefix or mask matches it. And a leading zero
258+
// means octal to inet_aton, so "0177.0.0.1" is 127.0.0.1 to the resolver
259+
// and 177.0.0.1 to a decimal-only parser.
260+
it.effect("rejects resolved addresses whose syntax the platform reads differently", () =>
261+
Effect.gen(function* () {
262+
for (const address of [
263+
"127.0.0.1::1",
264+
"192.168.1.1::",
265+
"10.0.0.1::0",
266+
"169.254.169.254::1",
267+
"0177.0.0.1",
268+
]) {
269+
const error = yield* validateHostedOutboundUrl("https://api.example/x", {
270+
resolveHostname: async () => [{ address }],
271+
}).pipe(Effect.flip);
272+
273+
expect(error).toMatchObject({
274+
_tag: "HostedOutboundRequestBlocked",
275+
reason: "Resolved address is local or private",
276+
});
277+
}
278+
279+
// Guards against over-blocking: the trailing quad is still legal where
280+
// it belongs, so tightening the rule must not reject the v4-compatible
281+
// and v4-mapped forms of an ordinary public address.
282+
for (const address of ["::93.184.216.34", "::ffff:93.184.216.34"]) {
283+
yield* validateHostedOutboundUrl("https://api.example/x", {
284+
resolveHostname: async () => [{ address }],
285+
});
286+
}
287+
}),
288+
);
289+
249290
// node:dns can hand back a link-local address carrying its interface scope.
250291
// The zone identifier is not part of the address and must not defeat the
251292
// classification of the address it is attached to.
@@ -344,6 +385,32 @@ describe("hosted outbound HTTP client", () => {
344385
}),
345386
);
346387

388+
// An empty answer is a failure, not a successful "no addresses". Only the
389+
// error channel takes the zero failure TTL, so caching it as a success would
390+
// stamp the full 60s window on one transient empty answer and blackhole the
391+
// hostname for its duration — something the uncached code could never do.
392+
it("re-resolves after a resolver returns no addresses", async () => {
393+
let attempts = 0;
394+
const hostedFetch = makeHostedFetch({
395+
fetch: async () => new Response("ok"),
396+
resolveHostname: async () => {
397+
attempts++;
398+
return attempts === 1 ? [] : [{ address: "93.184.216.34" }];
399+
},
400+
});
401+
402+
await expect(hostedFetch("https://api.example/x")).rejects.toMatchObject({
403+
_tag: "HostedOutboundRequestBlocked",
404+
reason: "Hostname did not resolve to an address",
405+
});
406+
407+
// The second request must reach the resolver again rather than replaying
408+
// the empty answer out of the cache.
409+
const recovered = await hostedFetch("https://api.example/x");
410+
expect(recovered.status).toBe(200);
411+
expect(attempts).toBe(2);
412+
});
413+
347414
it.effect("rejects IPv4-mapped IPv6 URLs for local and private networks", () =>
348415
Effect.gen(function* () {
349416
for (const url of [
@@ -620,12 +687,18 @@ describe("hosted outbound HTTP client", () => {
620687
await gate.promise;
621688
return [{ address: "93.184.216.34" }];
622689
},
690+
// A zero TTL is what makes the count mean what this test says it means.
691+
// Under the default 60s window a settled entry is reusable, so
692+
// `resolutions === 1` is also satisfied by the second request simply
693+
// arriving after the first finished and reading the cache — the test
694+
// would keep passing while no longer testing in-flight sharing at all.
695+
// With no settled entry to find, one resolution can only be a join.
696+
dnsCacheTtlMillis: 0,
623697
});
624698

625699
// The second request is issued only once the first is provably inside the
626-
// resolver, so a single resolution can only mean it joined the in-flight
627-
// lookup. Firing both at once and counting would also pass if the second
628-
// simply arrived after the first settled and hit the TTL cache.
700+
// resolver, then given a turn of the event loop to reach the cache before
701+
// the gate opens: issuance order alone does not put it there.
629702
const first = hostedFetch("https://api.example/one");
630703
await entered.promise;
631704
const second = hostedFetch("https://api.example/two");
@@ -641,13 +714,11 @@ describe("hosted outbound HTTP client", () => {
641714
// point: before the shared lookup was detached from the requesting fiber it
642715
// failed with an untyped "All fibers interrupted without error".
643716
it("keeps concurrent requests alive when one caller aborts", async () => {
644-
let resolutions = 0;
645717
const gate = Promise.withResolvers<void>();
646718
const entered = Promise.withResolvers<void>();
647719
const hostedFetch = makeHostedFetch({
648720
fetch: async () => new Response("ok"),
649721
resolveHostname: async () => {
650-
resolutions++;
651722
entered.resolve();
652723
await gate.promise;
653724
return [{ address: "93.184.216.34" }];
@@ -667,8 +738,14 @@ describe("hosted outbound HTTP client", () => {
667738
// Asserting the shape, not merely that it threw: the regression this test
668739
// exists to exclude also throws, so a bare toThrow() would accept it.
669740
await expect(aborted).rejects.toMatchObject({ name: "AbortError" });
741+
// The survivor completing is the assertion. Its 200 does not prove it was
742+
// still waiting on the shared lookup — under the default TTL a caller that
743+
// arrived after the first resolution settled would read the cache and get
744+
// the same 200 — so no resolution count is asserted here. That the lookup
745+
// survives an abort at all is what fails without forkDetach: the survivor
746+
// rejects with "All fibers interrupted without error". In-flight sharing
747+
// itself is pinned by the zero-TTL test above.
670748
expect((await survivor).status).toBe(200);
671-
expect(resolutions).toBe(1);
672749
});
673750

674751
// The guard's verdict is decided by the failure's cause, not by whether the

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

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,26 +34,38 @@ export interface HostedHttpClientOptions {
3434
readonly dnsResolutionTimeoutMillis?: number;
3535
}
3636

37+
// Octets are decimal-only and leading zeros are rejected. inet_aton reads a
38+
// leading zero as octal, so accepting "0177.0.0.1" here would classify it as
39+
// 177.0.0.1 (public) while the platform resolver dials 127.0.0.1 — the guard
40+
// and the connection would disagree about the destination. WHATWG normalizes
41+
// these forms, so the URL path never produces one, but a resolver answer
42+
// reaches isAllowedResolvedAddress unnormalized.
3743
const parseIpv4 = (hostname: string): readonly [number, number, number, number] | null => {
3844
const parts = hostname.split(".");
3945
if (parts.length !== 4) return null;
4046
const parsed: number[] = [];
4147
for (const part of parts) {
42-
if (!/^\d+$/.test(part)) return null;
48+
if (!/^(?:0|[1-9]\d*)$/.test(part)) return null;
4349
const value = Number(part);
4450
if (!Number.isInteger(value) || value < 0 || value > 255) return null;
4551
parsed.push(value);
4652
}
4753
return parsed as [number, number, number, number];
4854
};
4955

50-
const parseIpv6Groups = (text: string): number[] | null => {
56+
// `allowTrailingQuad` is the caller's statement that this segment ends the
57+
// whole address, not merely that it ends its own half. Deciding it from the
58+
// segment alone would legalize a quad at the end of the head half of a
59+
// compressed literal: "127.0.0.1::1" would parse, land 0x7f00 in the leading
60+
// word where no prefix or mask matches it, and be classified public.
61+
const parseIpv6Groups = (text: string, allowTrailingQuad: boolean): number[] | null => {
5162
if (text === "") return [];
5263
const parts = text.split(":");
5364
const groups: number[] = [];
5465
for (const [index, part] of parts.entries()) {
5566
// A trailing dotted quad is the only place IPv4 syntax is legal.
5667
if (index === parts.length - 1 && part.includes(".")) {
68+
if (!allowTrailingQuad) return null;
5769
const dotted = parseIpv4(part);
5870
if (!dotted) return null;
5971
groups.push((dotted[0] << 8) | dotted[1], (dotted[2] << 8) | dotted[3]);
@@ -78,11 +90,12 @@ const parseIpv6 = (hostname: string): ReadonlyArray<number> | null => {
7890
const [head, tail, ...extra] = hostname.replace(/%.*$/, "").split("::");
7991
if (extra.length > 0) return null;
8092

81-
const high = parseIpv6Groups(head);
93+
// The head half ends the address only when there is no "::" after it.
94+
const high = parseIpv6Groups(head, tail === undefined);
8295
if (high === null) return null;
8396
if (tail === undefined) return high.length === 8 ? high : null;
8497

85-
const low = parseIpv6Groups(tail);
98+
const low = parseIpv6Groups(tail, true);
8699
if (low === null) return null;
87100
const elided = 8 - high.length - low.length;
88101
if (elided < 1) return null;
@@ -193,10 +206,15 @@ const isAllowedResolvedAddress = (address: string): boolean => {
193206
const isAddressLiteral = (normalized: string): boolean =>
194207
parseIpv4(normalized) !== null || normalized.includes(":");
195208

209+
// `empty` separates "the resolver answered, with nothing" from "the resolver
210+
// did not answer". Both fail closed and both must stay out of the success
211+
// cache, but they are different operator diagnoses, so the block reason keeps
212+
// them apart rather than collapsing into one message.
196213
class HostedHostnameResolutionFailed extends Schema.TaggedErrorClass<HostedHostnameResolutionFailed>()(
197214
"HostedHostnameResolutionFailed",
198215
{
199216
hostname: Schema.String,
217+
empty: Schema.Boolean,
200218
},
201219
) {}
202220

@@ -210,13 +228,24 @@ type GuardResolver = (
210228
hostname: string,
211229
) => Effect.Effect<ReadonlyArray<HostedResolvedAddress>, HostedHostnameResolutionFailed>;
212230

231+
// An empty answer becomes a failure at the resolver boundary, not at the call
232+
// site, so it reaches every caller the same way: the cached path needs it in
233+
// the error channel for the zero failure TTL to drop it, and putting it here
234+
// rather than inside the cache keeps the uncached path — the exported
235+
// validateHostedOutboundUrl — from having a second copy of the rule.
213236
const toGuardResolver =
214237
(resolve: HostedHostnameResolver): GuardResolver =>
215238
(hostname) =>
216239
Effect.tryPromise({
217240
try: (signal) => resolve(hostname, signal),
218-
catch: () => new HostedHostnameResolutionFailed({ hostname }),
219-
});
241+
catch: () => new HostedHostnameResolutionFailed({ hostname, empty: false }),
242+
}).pipe(
243+
Effect.flatMap((addresses) =>
244+
addresses.length === 0
245+
? Effect.fail(new HostedHostnameResolutionFailed({ hostname, empty: true }))
246+
: Effect.succeed(addresses),
247+
),
248+
);
220249

221250
const DNS_CACHE_TTL_MILLIS = 60_000;
222251
const DNS_CACHE_CAPACITY = 256;
@@ -261,12 +290,18 @@ const withResolutionCache = (
261290
// the request instead is a worse outcome than the wait. Callers that will
262291
// not wait that long abort their own fetch, which the detached lookup
263292
// survives on behalf of everyone else waiting on it.
293+
//
294+
// An empty answer is already a failure by the time it reaches the cache —
295+
// toGuardResolver raises it — so the zero failure TTL drops it. Were it a
296+
// success, one transient empty answer would stamp the full TTL on "no
297+
// addresses" and blackhole the hostname for the rest of the window; the
298+
// uncached code re-resolved every request and never could.
264299
const bounded: GuardResolver = (hostname) =>
265300
Effect.timeoutOrElse(resolve(hostname), {
266301
duration: Duration.millis(
267302
options.dnsResolutionTimeoutMillis ?? DNS_RESOLUTION_TIMEOUT_MILLIS,
268303
),
269-
orElse: () => Effect.fail(new HostedHostnameResolutionFailed({ hostname })),
304+
orElse: () => Effect.fail(new HostedHostnameResolutionFailed({ hostname, empty: false })),
270305
});
271306
const cache = Effect.runSync(
272307
Cache.makeWith(bounded, {
@@ -321,24 +356,22 @@ const validateOutboundUrl = (
321356
}
322357

323358
if (!options.allowLocalNetwork && !isAddressLiteral(normalizedHostname)) {
359+
// The empty answer is already a failure by the time it arrives here —
360+
// the cached lookup raises it, so it takes the zero failure TTL instead
361+
// of being retained as a successful "no addresses" for the full window.
324362
const addresses = yield* resolve(normalizedHostname).pipe(
325-
Effect.catchTag("HostedHostnameResolutionFailed", () =>
363+
Effect.catchTag("HostedHostnameResolutionFailed", (error) =>
326364
Effect.fail(
327365
new HostedOutboundRequestBlocked({
328366
url: value,
329-
reason: "Hostname could not be resolved",
367+
reason: error.empty
368+
? "Hostname did not resolve to an address"
369+
: "Hostname could not be resolved",
330370
}),
331371
),
332372
),
333373
);
334374

335-
if (addresses.length === 0) {
336-
return yield* new HostedOutboundRequestBlocked({
337-
url: value,
338-
reason: "Hostname did not resolve to an address",
339-
});
340-
}
341-
342375
for (const { address } of addresses) {
343376
if (!isAllowedResolvedAddress(address)) {
344377
return yield* new HostedOutboundRequestBlocked({

0 commit comments

Comments
 (0)