From 46d73c10785649bf781f062070b6309bc1ab9b08 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 10:26:16 +0000 Subject: [PATCH] fix(dns): size the per-client limit for a browser, not against one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rate limit prices reflection: a forged source address turns our answers into someone else's inbound traffic, so a client's budget is the most we will ever send a victim who never asked. At 50 qps / 100 burst it priced ordinary browsing too. A client here is an address, not a person, and behind one address is a laptop with tabs open — or a household, or an office. A single page asks A, AAAA and HTTPS for every hostname it touches, so a few tabs clear 100 queries in a burst without trying. The excess is dropped rather than refused, which is right for a spoofing victim and the worst possible answer for a browser: nothing comes back, the stub waits out its full timeout, retries, and the page finishes with subresources that never resolved. Loopback stopped paying this in #102, which covers a machine resolving through the bridge locally. It does not cover the case this deployment actually serves — a laptop pointed at dns.moshcode.sh is a remote client and still paid. 200/600 is still a bound, drawn around a browser instead of inside it. It lets through ~200 answers/sec to a spoofed victim, well under 100KB/s and no kind of amplifier: ANY is already refused, which keeps the amplification factor small enough for this to be the right trade. Both knobs stay configurable via MOSHPIT_DNS_QPS / MOSHPIT_DNS_BURST. Co-Authored-By: Claude Opus 5 (1M context) --- lib/dns/ratelimit.ts | 7 +++++-- scripts/moshpit-dns.ts | 27 +++++++++++++++++++++++++-- tests/dns-server.test.mjs | 18 ++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/lib/dns/ratelimit.ts b/lib/dns/ratelimit.ts index 4523604..eaf803e 100644 --- a/lib/dns/ratelimit.ts +++ b/lib/dns/ratelimit.ts @@ -47,8 +47,11 @@ export function createRateLimiter(options: { maxClients?: number; now?: () => number; } = {}): RateLimiter { - const qps = options.qps ?? 50; - const burst = options.burst ?? 100; + // Sized for a browser rather than against one — a client here is an address, + // and behind one address is a laptop with tabs open, or a whole office. See + // the note on DEFAULT_QPS in scripts/moshpit-dns.ts. + const qps = options.qps ?? 200; + const burst = options.burst ?? 600; const maxClients = options.maxClients ?? 50_000; const now = options.now ?? Date.now; diff --git a/scripts/moshpit-dns.ts b/scripts/moshpit-dns.ts index a457746..c52283b 100644 --- a/scripts/moshpit-dns.ts +++ b/scripts/moshpit-dns.ts @@ -66,6 +66,29 @@ const gateway = createGatewayResolver({ ipv6: list(env.MOSHPIT_GATEWAY_AAAA), }); +/** + * What one client may ask for, per second and in a burst. + * + * The limit prices reflection: a forged source address turns our answers into + * someone else's inbound traffic, so a client's budget is the most we will ever + * send a victim who never asked. It is not meant to price *browsing*. + * + * At 50/100 it priced browsing anyway. A client here is an address, not a + * person, and behind one address is a laptop opening tabs — or a household, or + * an office. A single page asks for A, AAAA and HTTPS on every hostname it + * touches, so a few tabs clear 100 queries in a burst without trying, and the + * excess is dropped rather than refused: nothing comes back, the stub waits out + * its timeout, and the page finishes with subresources that never resolved. + * + * These numbers are still a bound, just one drawn around a browser instead of + * inside it. What they let through is ~200 answers/sec to a spoofed victim, + * well under 100KB/s and no kind of amplifier — ANY is already refused, which + * is what makes the amplification factor small enough for this to be the right + * trade. + */ +const DEFAULT_QPS = 200; +const DEFAULT_BURST = 600; + // Off unless asked for: with it on, a name nobody holds under an ending the // legacy root does not have resolves to the gateway, which lands the visitor on // the pit with the name filled in. That is a funnel, and a funnel is a product @@ -83,8 +106,8 @@ const dns = createDnsServer({ address, port, rateLimiter: createRateLimiter({ - qps: number(env.MOSHPIT_DNS_QPS, 50), - burst: number(env.MOSHPIT_DNS_BURST, 100), + qps: number(env.MOSHPIT_DNS_QPS, DEFAULT_QPS), + burst: number(env.MOSHPIT_DNS_BURST, DEFAULT_BURST), }), log: env.MOSHPIT_DNS_LOG === "queries" ? log : () => {}, }); diff --git a/tests/dns-server.test.mjs b/tests/dns-server.test.mjs index 9c6f098..89c1563 100644 --- a/tests/dns-server.test.mjs +++ b/tests/dns-server.test.mjs @@ -200,6 +200,24 @@ test("a client over its rate limit is dropped rather than answered", async () => assert.equal(limiter.allow("192.0.2.2"), true, "a different client has its own budget"); }); +test("a remote browser's page load fits inside one client's budget", () => { + // The limit prices reflection, not browsing. A client is an address, and + // behind one address is a laptop with tabs open: A, AAAA and HTTPS on every + // hostname a page touches clears 100 queries without trying. At the old + // 50/100 the excess was dropped in silence, which is a page that finishes + // with subresources that never resolved. + const limiter = createRateLimiter(); + let served = 0; + for (let i = 0; i < 400; i++) if (limiter.allow("203.0.113.9")) served++; + assert.equal(served, 400, "a burst the size of a real page load is answered in full"); + + // Still a bound, though — an address cannot ask without limit. + const capped = createRateLimiter(); + let allowed = 0; + for (let i = 0; i < 5000; i++) if (capped.allow("203.0.113.10")) allowed++; + assert.ok(allowed < 5000, "the budget is still finite"); +}); + test("the machine's own queries are never rate limited", () => { // Point a box's resolver at this and every query on it arrives from one // address. Under a per-client limit that made the whole machine share one