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