Skip to content

Commit ea8fc6e

Browse files
committed
Resolve inbox destinations for safety
Classify off-origin inbox destinations with the same DNS-aware resolver used for benchmark targets before applying the inbox safety gate. This lets private staging shared inboxes run without the unsafe public override while keeping public destination override checks bounded. #744 #784 Assisted-by: Codex:gpt-5.5
1 parent ef07fd0 commit ea8fc6e

6 files changed

Lines changed: 101 additions & 18 deletions

File tree

packages/cli/src/bench/action.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ async function writeSuite(content: string): Promise<string> {
2828
return path;
2929
}
3030

31+
function resolvePublicHost(_hostname: string): Promise<readonly string[]> {
32+
return Promise.resolve(["93.184.216.34"]);
33+
}
34+
3135
function inboxSuite(target: URL, expectLine: string): string {
3236
// Uses `${{ target.host }}` templating to form the actor URI (WebFinger is
3337
// https-only, so an acct: handle would not resolve over http loopback).
@@ -246,6 +250,7 @@ scenarios:
246250
{ headers: { "content-type": "application/json" } },
247251
),
248252
),
253+
resolveTargetAddresses: resolvePublicHost,
249254
});
250255
assert.strictEqual(code, 2);
251256
assert.match(message, /advertise-host/);
@@ -306,6 +311,7 @@ scenarios:
306311
log: (m) => {
307312
message = m;
308313
},
314+
resolveTargetAddresses: resolvePublicHost,
309315
});
310316
assert.strictEqual(code, 2);
311317
assert.match(message, /public inbox|allow-unsafe-target/);
@@ -314,6 +320,57 @@ scenarios:
314320
}
315321
});
316322

323+
test("runBench - allows a DNS-resolved private inbox off the target", async () => {
324+
const target = await spawnBenchmarkTarget();
325+
try {
326+
const file = await writeSuite(`version: 1
327+
target: ${target.url.href}
328+
scenarios:
329+
- name: inbox-shared
330+
type: inbox
331+
recipient: "${new URL("/users/alice", target.url).href}"
332+
inbox: "https://shared.staging.example/inbox"
333+
load: { concurrency: 2 }
334+
duration: 250ms
335+
`);
336+
let code = -1;
337+
let message = "";
338+
const resolved: string[] = [];
339+
await runBench(
340+
command({
341+
scenario: file,
342+
advertiseHost: "127.0.0.1",
343+
}),
344+
{
345+
exit: (c) => {
346+
code = c;
347+
},
348+
writeOutput: () => Promise.resolve(),
349+
log: (m) => {
350+
message = m;
351+
},
352+
resolveTargetAddresses: (hostname) => {
353+
resolved.push(hostname);
354+
return Promise.resolve(
355+
hostname === "shared.staging.example" ? ["10.0.0.8"] : [],
356+
);
357+
},
358+
fetch: (input) => {
359+
const url = new URL(input instanceof Request ? input.url : input);
360+
if (url.hostname === "shared.staging.example") {
361+
return Promise.resolve(new Response("accepted", { status: 202 }));
362+
}
363+
return fetch(input);
364+
},
365+
},
366+
);
367+
assert.strictEqual(code, 0, message);
368+
assert.deepStrictEqual(resolved, ["shared.staging.example"]);
369+
} finally {
370+
await target.close();
371+
}
372+
});
373+
317374
test("runBench - unsafe public inbox destination needs an explicit CLI target", async () => {
318375
const target = await spawnBenchmarkTarget();
319376
try {
@@ -343,6 +400,7 @@ scenarios:
343400
log: (m) => {
344401
message = m;
345402
},
403+
resolveTargetAddresses: resolvePublicHost,
346404
},
347405
);
348406
assert.strictEqual(code, 2);
@@ -381,6 +439,7 @@ scenarios:
381439
log: (m) => {
382440
message = m;
383441
},
442+
resolveTargetAddresses: resolvePublicHost,
384443
},
385444
);
386445
assert.strictEqual(code, 2);
@@ -421,6 +480,7 @@ scenarios:
421480
log: (m) => {
422481
message = m;
423482
},
483+
resolveTargetAddresses: resolvePublicHost,
424484
fetch: (input) => {
425485
const url = new URL(input instanceof Request ? input.url : input);
426486
if (url.hostname === "prod.example") {

packages/cli/src/bench/action.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ import {
3030
} from "./safety/gate.ts";
3131
import {
3232
classifyResolvedTarget,
33-
classifyTarget,
3433
type ResolveTargetAddresses,
34+
type TargetTier,
3535
} from "./safety/tiers.ts";
3636
import { runnerFor } from "./scenarios/registry.ts";
3737
import {
@@ -165,13 +165,17 @@ export default async function runBench(
165165

166166
// Gates each resolved inbox destination (which can differ from the suite
167167
// target) before the runner sends load to it.
168-
const assertDestinationAllowed = (
168+
const assertDestinationAllowed = async (
169169
url: URL,
170170
scenario: ResolvedScenario,
171-
): void => {
171+
): Promise<void> => {
172+
const destinationTier = url.origin === suite.target.origin
173+
? tier
174+
: await classifyResolvedTarget(url, deps.resolveTargetAddresses);
172175
assertInboxDestinationAllowed(url, {
173176
targetOrigin: suite.target.origin,
174177
targetTier: tier,
178+
destinationTier,
175179
targetBenchmarkMode: probe.benchmarkMode,
176180
allowUnsafe: command.allowUnsafeTarget,
177181
advertised: command.advertiseHost != null,
@@ -181,6 +185,7 @@ export default async function runBench(
181185
targetBenchmarkMode: probe.benchmarkMode,
182186
allowUnsafe: command.allowUnsafeTarget,
183187
explicitCliTarget: command.target != null,
188+
destinationTier,
184189
suite: validated,
185190
});
186191
};
@@ -338,7 +343,7 @@ interface DryRunPlanContext {
338343
readonly assertDestinationAllowed: (
339344
url: URL,
340345
scenario: ResolvedScenario,
341-
) => void;
346+
) => Promise<void>;
342347
}
343348

344349
async function renderPlan(
@@ -404,9 +409,11 @@ async function describeInboxDiscoveryPlan(
404409
`inbox ${inbox.href}`,
405410
);
406411
lines.push(
407-
` destination safety: ${
408-
describeDestinationSafety(inbox, scenario, context)
409-
}`,
412+
` destination safety: ${await describeDestinationSafety(
413+
inbox,
414+
scenario,
415+
context,
416+
)}`,
410417
);
411418
}
412419
return lines;
@@ -427,13 +434,13 @@ function describeWebFingerPlan(
427434
});
428435
}
429436

430-
function describeDestinationSafety(
437+
async function describeDestinationSafety(
431438
inbox: URL,
432439
scenario: ResolvedScenario,
433440
context: DryRunPlanContext,
434-
): string {
441+
): Promise<string> {
435442
try {
436-
context.assertDestinationAllowed(inbox, scenario);
443+
await context.assertDestinationAllowed(inbox, scenario);
437444
return "allowed";
438445
} catch (error) {
439446
if (error instanceof UnsafeTargetError) {
@@ -448,6 +455,7 @@ interface PublicDestinationOverrideContext {
448455
readonly targetBenchmarkMode: boolean;
449456
readonly allowUnsafe: boolean;
450457
readonly explicitCliTarget: boolean;
458+
readonly destinationTier: TargetTier;
451459
readonly suite: Suite;
452460
}
453461

@@ -459,7 +467,7 @@ function assertPublicDestinationOverrideAllowed(
459467
const inheritsTargetGate = url.origin === context.targetOrigin &&
460468
context.targetBenchmarkMode;
461469
if (
462-
classifyTarget(url) !== "public" || inheritsTargetGate ||
470+
context.destinationTier !== "public" || inheritsTargetGate ||
463471
!context.allowUnsafe
464472
) {
465473
return;

packages/cli/src/bench/safety/gate.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ function destContext(
147147
return {
148148
targetOrigin: "http://127.0.0.1:3000",
149149
targetTier: "loopback" as const,
150+
destinationTier: "public" as const,
150151
targetBenchmarkMode: false,
151152
allowUnsafe: false,
152153
advertised: false,
@@ -216,6 +217,18 @@ test("assertInboxDestinationAllowed - same-origin inbox uses the resolved target
216217
);
217218
});
218219

220+
test("assertInboxDestinationAllowed - off-origin inbox uses destination tier", () => {
221+
assert.doesNotThrow(() =>
222+
assertInboxDestinationAllowed(
223+
new URL("https://shared.staging.example/inbox"),
224+
destContext({
225+
destinationTier: "private",
226+
advertised: true,
227+
}),
228+
)
229+
);
230+
});
231+
219232
test("assertInboxDestinationAllowed - same host, different scheme does not inherit", () => {
220233
// The target is https (its benchmark-mode probe covered port 443); an http
221234
// inbox on the same hostname is a different service (port 80), so it must not

packages/cli/src/bench/safety/gate.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* @module
1212
*/
1313

14-
import { classifyTarget, type TargetTier } from "./tiers.ts";
14+
import type { TargetTier } from "./tiers.ts";
1515

1616
/** An error raised when a target is refused by the safety gate. */
1717
export class UnsafeTargetError extends Error {}
@@ -124,6 +124,8 @@ export interface InboxDestinationGateContext {
124124
readonly targetOrigin: string;
125125
/** The resolved target tier used by the main safety gate. */
126126
readonly targetTier: TargetTier;
127+
/** The resolved tier for this inbox destination. */
128+
readonly destinationTier: TargetTier;
127129
/** Whether the gated target advertises benchmark mode. */
128130
readonly targetBenchmarkMode: boolean;
129131
/** Whether `--allow-unsafe-target` was given. */
@@ -153,7 +155,7 @@ export function assertInboxDestinationAllowed(
153155
context: InboxDestinationGateContext,
154156
): void {
155157
const sameOrigin = url.origin === context.targetOrigin;
156-
const tier = sameOrigin ? context.targetTier : classifyTarget(url);
158+
const tier = sameOrigin ? context.targetTier : context.destinationTier;
157159
const inheritsTargetGate = sameOrigin &&
158160
context.targetBenchmarkMode;
159161
if (tier === "public" && !inheritsTargetGate && !context.allowUnsafe) {

packages/cli/src/bench/scenarios/inbox.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export const inboxRunner: ScenarioRunner = {
8181
const inbox = selectInbox(discovered, scenario.inbox);
8282
// Gate the actual load destination before sending anything to it: it can
8383
// differ from the gated target (a public recipient, or an explicit inbox).
84-
context.assertDestinationAllowed?.(inbox);
84+
await context.assertDestinationAllowed?.(inbox);
8585
targets.push({ inbox, actorUri: discovered.actorUri });
8686
}
8787

packages/cli/src/bench/scenarios/runner.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ export interface RunContext {
3131
readonly fetch?: typeof fetch;
3232
/**
3333
* Gates a resolved load destination (a discovered or explicit inbox URL)
34-
* before any load is sent to it, throwing if it is not allowed. The suite
35-
* `target` is gated by the orchestrator; this covers destinations that differ
36-
* from it. Optional so direct runner tests need not supply it.
34+
* before any load is sent to it, throwing or rejecting if it is not allowed.
35+
* The suite `target` is gated by the orchestrator; this covers destinations
36+
* that differ from it. Optional so direct runner tests need not supply it.
3737
*/
38-
readonly assertDestinationAllowed?: (url: URL) => void;
38+
readonly assertDestinationAllowed?: (url: URL) => void | Promise<void>;
3939
}
4040

4141
/** A runner for one scenario type. */

0 commit comments

Comments
 (0)