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
7 changes: 5 additions & 2 deletions lib/dns/ratelimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
27 changes: 25 additions & 2 deletions scripts/moshpit-dns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 : () => {},
});
Expand Down
18 changes: 18 additions & 0 deletions tests/dns-server.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading