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
110 changes: 96 additions & 14 deletions test/did-webvh-ssrf.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,25 @@ function blocked(reason) {
// control tells "dialed" apart from "refused".
async function listener() {
const hits = [];
let connections = 0;
const server = createServer((req, res) => {
hits.push({ method: req.method, path: req.url, host: req.headers.host });
res.setHeader("content-type", "application/jsonl");
res.end(`${JSON.stringify(["1-bogus", "2026-01-01T00:00:00Z", {}, { value: {} }])}\n`);
});
// A socket opened and then abandoned is still a dial the guard should
// have refused, and it never shows up as a request.
server.on("connection", () => {
connections += 1;
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address();
return {
hits,
port,
get connections() {
return connections;
},
close: () =>
new Promise((resolve) => {
server.closeAllConnections?.();
Expand Down Expand Up @@ -121,23 +130,95 @@ test("webvhLogUrl: an unusable identifier is rejected, not guessed at", () => {

// ─── resolve() against a real listener ──────────────────────────────────

test("did:webvh resolve: a localhost identifier gets zero requests", async () => {
// The same bypass table the mediator gate uses, in the spelling a
// did:webvh identifier can carry: the host is a DID segment, so a port is
// percent-encoded and a bracketed IPv6 literal cannot be expressed at all
// (the identifier parse fails closed on one — see the "unusable
// identifier" case above). `port` marks the hosts that fold onto
// 127.0.0.1, which are the ones a local listener can catch.
function webvhVectors(port) {
const p = port === undefined ? "" : `%3A${port}`;
return [
// Special-use names, and the root-dot form of each.
[`localhost${p}`, "private_name", { loopback: true }],
[`localhost.${p}`, "private_name", { loopback: true }],
[`log.localhost${p}`, "private_name", { loopback: true }],
[`log.localhost.${p}`, "private_name", { loopback: true }],
// The substring case the upstream downgrade turns into plaintext.
[`localhost.example${p}`, "private_name"],
["log.local", "private_name"],
["log.local.", "private_name"],
["log.internal", "private_name"],
["log.home.arpa", "private_name"],
// Loopback, and its alternate IPv4 spellings.
[`127.0.0.1${p}`, "private_address", { loopback: true }],
[`127.0.0.1.${p}`, "private_address", { loopback: true }],
[`127.1${p}`, "private_address", { loopback: true }],
[`0x7f000001${p}`, "private_address", { loopback: true }],
[`2130706433${p}`, "private_address", { loopback: true }],
[`0177.0.0.1${p}`, "private_address", { loopback: true }],
// Link-local and cloud metadata, CGNAT, RFC 1918, "this network".
["169.254.169.254", "private_address"],
["100.64.0.1", "private_address"],
["100.100.100.200", "private_address"],
["10.0.0.5", "private_address"],
["172.16.0.1", "private_address"],
["192.168.1.1", "private_address"],
["0.0.0.0", "private_address"],
];
}

// Both entry points, because `resolve(did)` through the dispatcher is the
// one `unpackInbound` reaches for an unauthenticated frame's `skid`.
async function resolveOutcomes(vectors, options) {
const outcomes = [];
for (const [host, reason] of vectors) {
const did = `did:webvh:${SCID}:${host}`;
for (const call of [() => didWebvh.resolve(did, options), () => resolveDid(did, options)]) {
let err;
try {
await call();
} catch (e) {
err = e;
}
outcomes.push({ did, reason, err });
}
}
return outcomes;
}

function wrongOutcomes(outcomes) {
return outcomes
.filter(({ reason, err }) => err?.code !== BLOCKED_ENDPOINT || err.reason !== reason)
.map(({ did, reason, err }) => `${did} -> want ${reason}, got ${err?.reason ?? err?.message ?? "resolved"}`);
}

// Two passes, as in the mediator gate: a spy proves no vector reaches
// `fetch` at all — including the spellings that are unroutable from a
// test host, which is what keeps this hermetic whatever state the guard
// is in — and the local listener proves that for the live spellings the
// refusal lands before the socket.

test("did:webvh resolve: no vector in the set reaches fetch", async () => {
const calls = [];
const spy = async (input) => {
calls.push(String(typeof input === "string" || input instanceof URL ? input : input?.url));
return new Response("", { status: 200 });
};
const outcomes = await resolveOutcomes(webvhVectors(8443), { fetch: spy });
assert.deepEqual(calls, [], "the guard must refuse before fetch is reached");
assert.deepEqual(wrongOutcomes(outcomes), []);
});

test("did:webvh resolve: the loopback spellings get zero TCP connections", async () => {
const internal = await listener();
try {
for (const [host, reason] of [
[`localhost%3A${internal.port}`, "private_name"],
// The substring case the upstream downgrade turns into plaintext.
[`localhost.example%3A${internal.port}`, "private_name"],
[`127.0.0.1%3A${internal.port}`, "private_address"],
["169.254.169.254", "private_address"],
["2130706433", "private_address"],
]) {
const did = `did:webvh:${SCID}:${host}`;
await assert.rejects(() => didWebvh.resolve(did), blocked(reason), did);
// Through the method dispatcher, the way callers reach it.
await assert.rejects(() => resolveDid(did), blocked(reason), did);
}
const live = webvhVectors(internal.port).filter(([, , flags]) => flags?.loopback);
const outcomes = await resolveOutcomes(live);
// The dial first: no vector may get as far as a socket.
assert.equal(internal.connections, 0, "no vector may open a socket to the internal listener");
assert.equal(internal.hits.length, 0, "no request may reach the internal listener");
assert.deepEqual(wrongOutcomes(outcomes), []);
} finally {
await internal.close();
}
Expand Down Expand Up @@ -191,6 +272,7 @@ test("did:webvh resolve: a redirect from an allowed host is not followed", async
blocked("redirect"),
);
assert.equal(target.hits.length, 0, "the redirect target must not be requested");
assert.equal(target.connections, 0, "the redirect target must not be dialed at all");
} finally {
server.closeAllConnections?.();
await new Promise((resolve) => server.close(resolve));
Expand Down
Loading