Count sign-ups by network, not only by address

Closes #41 — and corrects that ticket's premise while doing it. The
existing limit is per ADDRESS, not per account: 50 an hour keyed on the
client IP. What it misses is that more addresses is the cheapest thing an
abuser buys.

Fifty accounts from one address is refused. Fifty each from two hundred
addresses inside one /24 is ten thousand accounts, and one request at a
time it looks exactly like a busy afternoon. So the address limit stays,
and a much larger ceiling now sits behind it keyed on the network.

⚠️ **/48 for IPv6, not /64.** A /64 is one machine and a customer is
often handed many, so keying on it is barely better than keying on the
address. A /48 is a site — the unit somebody actually rents.

⚠️ **An IPv4-mapped address is IPv4.** Treating `::ffff:203.0.113.7` as
IPv6 would drop every mapped address into one enormous shared bucket,
which is the same as no limit and worse: everybody in it would be
punished for each other.

🔴 **Unparseable input keys on ITSELF, never on a constant.** Failing open
into a shared bucket would let one attacker exhaust everybody's budget,
and returning empty would make the check removable by sending nonsense.
Two tests hold that, including one asserting two different garbage values
do not collide.

⚠️ The allowance is deliberately eight times the per-address one. A
university, an office, or a carrier behind CGNAT is thousands of real
people on one network, and refusing them is a far worse outcome than
admitting a determined abuser. This is a ceiling on industrial-scale
registration, not a throttle on ordinary use.

⚠️ Both limits go through `consumeAll`, so a refusal by either does not
spend the other — otherwise one machine could burn the network budget and
lock out its neighbours.

Nine tests, proven able to fail: folding IPv6 into one bucket turns eight
red. 479 tests in 41 suites pass.
This commit is contained in:
Paul Hitt
2026-09-02 19:52:35 -04:00
parent 7da09f1960
commit 21c77f6731
2 changed files with 168 additions and 2 deletions
+64 -2
View File
@@ -17,6 +17,21 @@ struct AccountRoutes: Sendable {
/// and threading the config through would make every future limit look like
/// it belongs here too.
let signupMaxPerHour: Int
/// New accounts per NETWORK per hour — a /24 for IPv4, a /48 for IPv6.
///
/// 🔴 **The per-address limit above counts a rented subnet as separate
/// strangers.** Fifty accounts from one address is refused; fifty each from
/// two hundred addresses in one /24 is ten thousand accounts and looks, one
/// request at a time, exactly like a busy afternoon. The cheapest thing an
/// abuser buys is more addresses, so a limit keyed on a single address is
/// the one they can always step around.
///
/// ⚠️ **Deliberately generous.** A university, an office, or a carrier
/// behind CGNAT is thousands of real people sharing one network, and
/// refusing them is a far worse outcome than admitting a determined abuser.
/// This is a ceiling on industrial-scale registration, not a throttle on
/// ordinary use.
var signupMaxPerNetworkPerHour: Int = signupMaxPerNetworkDefault
/// Five failures per IP+username per fifteen minutes, matching the plugin.
///
@@ -68,6 +83,9 @@ struct AccountRoutes: Sendable {
/// speed bump against lazy bulk registration. A speed bump whose false
/// positives are "cannot use the product at all" should be generous.
static let signupMaxPerHourDefault = 50
/// Eight times the per-address allowance: enough that a shared network of
/// real people never notices, small enough that a subnet cannot be farmed.
static let signupMaxPerNetworkDefault = 400
// MARK: - POST /account/signup
@@ -75,8 +93,15 @@ struct AccountRoutes: Sendable {
// Charged BEFORE any validation, deliberately. Charging only on success
// leaves an unlimited username-availability oracle — and charging only
// after the format checks lets a script probe shapes for free.
guard await limits.consume("signup:\(clientIP)",
limit: signupMaxPerHour, now: now) else {
// ⚠️ Both together through `consumeAll`, so a refusal by either does not
// spend the other — the note on `RateLimiting` says why, and it matters
// more here: burning the network budget on requests the address limit
// already refused would let one machine lock out its neighbours.
guard await limits.consumeAll([
(key: "signup:\(clientIP)", amount: 1, limit: signupMaxPerHour),
(key: "signup:net:\(Self.network(of: clientIP))", amount: 1,
limit: signupMaxPerNetworkPerHour),
], now: now) else {
throw WireError.rateLimited(
"Too many accounts created from here recently. Try again later.")
}
@@ -424,6 +449,43 @@ struct AccountRoutes: Sendable {
token: token, expires_in: issuer.lifetime, audience: issuer.audience))
}
/// The network an address belongs to: a /24 for IPv4, a /48 for IPv6.
///
/// ⚠️ **/48 rather than /64 for IPv6.** A /64 is one machine and a customer
/// is often handed many, so keying on it is barely better than keying on
/// the address. A /48 is a site — the unit somebody actually rents, and
/// therefore the unit worth counting.
///
/// ⚠️ An address this cannot parse returns itself, so an odd or spoofed
/// value degrades to the per-address limit rather than to no limit at all.
/// Failing open here would make the whole check removable by sending
/// nonsense.
static func network(of ip: String) -> String {
let address = ip.trimmingCharacters(in: .whitespaces).lowercased()
// IPv4-mapped IPv6 (`::ffff:203.0.113.7`) is an IPv4 address wearing a
// hat; treating it as IPv6 would put every mapped address in one
// enormous shared bucket.
let bare = address.hasPrefix("::ffff:") ? String(address.dropFirst(7)) : address
if bare.contains(".") {
let octets = bare.split(separator: ".")
guard octets.count == 4, octets.allSatisfy({ UInt8($0) != nil }) else { return ip }
return octets.prefix(3).joined(separator: ".") + ".0/24"
}
if bare.contains(":") {
// The first three groups are the /48. `::` cannot hide any of them
// unless the address starts with it, and then there is nothing to
// key on anyway.
guard !bare.hasPrefix("::") else { return ip }
let groups = bare.split(separator: ":", omittingEmptySubsequences: false)
guard groups.count >= 3, groups.prefix(3).allSatisfy({ !$0.isEmpty }) else {
return ip
}
return groups.prefix(3).joined(separator: ":") + "::/48"
}
return ip
}
// MARK: - Credentials
/// A credential is `<selector>.<secret>`. The selector is a lookup key held
@@ -0,0 +1,104 @@
import Foundation
import Testing
@testable import FlamenetRelay
/// Counting sign-ups by network, not only by address.
///
/// 🔴 **The per-address limit counts a rented subnet as separate strangers.**
/// Fifty accounts from one address is refused; fifty each from two hundred
/// addresses in one /24 is ten thousand accounts, and one request at a time it
/// looks exactly like a busy afternoon. More addresses is the cheapest thing an
/// abuser buys, so an address-keyed limit is the one they always step around.
///
/// ⚠️ **The expensive failure is the other one.** A university, an office, or a
/// carrier behind CGNAT is thousands of real people on one network. Half of
/// this suite is about not folding addresses together that should stay apart.
@Suite("sign-up network limits")
struct SignupNetworkLimitTests {
// MARK: - What counts as one network
@Test("IPv4 addresses in one /24 share a network")
func ipv4SameNetwork() {
let key = AccountRoutes.network(of: "203.0.113.7")
#expect(key == "203.0.113.0/24")
for other in ["203.0.113.1", "203.0.113.254", "203.0.113.0"] {
#expect(AccountRoutes.network(of: other) == key)
}
}
/// 🔴 A neighbouring /24 is somebody else. Folding these together would
/// make one abuser able to lock out an unrelated network.
@Test("a neighbouring /24 is a different network")
func ipv4NeighbourIsDifferent() {
#expect(AccountRoutes.network(of: "203.0.113.7")
!= AccountRoutes.network(of: "203.0.114.7"))
#expect(AccountRoutes.network(of: "203.0.113.7")
!= AccountRoutes.network(of: "198.51.100.7"))
}
@Test("IPv6 addresses in one /48 share a network")
func ipv6SameNetwork() {
let key = AccountRoutes.network(of: "2001:db8:1234:5678::1")
#expect(key == "2001:db8:1234::/48")
// A different /64 inside the same /48 is the same site.
#expect(AccountRoutes.network(of: "2001:db8:1234:9999::abcd") == key)
}
/// ⚠️ /48 not /64, and this is the case that says why: a customer handed
/// many /64s would otherwise get a fresh budget for each one.
@Test("a different /48 is a different network")
func ipv6NeighbourIsDifferent() {
#expect(AccountRoutes.network(of: "2001:db8:1234:5678::1")
!= AccountRoutes.network(of: "2001:db8:9999:5678::1"))
}
/// An IPv4-mapped address is an IPv4 address wearing a hat. Treating it as
/// IPv6 would drop every mapped address into one enormous shared bucket —
/// which is the same as having no limit, and worse, would punish everybody
/// in it for each other.
@Test("an IPv4-mapped IPv6 address is treated as IPv4")
func mappedAddressIsIPv4() {
#expect(AccountRoutes.network(of: "::ffff:203.0.113.7") == "203.0.113.0/24")
#expect(AccountRoutes.network(of: "::FFFF:203.0.113.9") == "203.0.113.0/24")
}
/// 🔴 **Fails closed, not open.** Something unparseable degrades to the
/// per-address limit. If it collapsed to a constant instead, one attacker
/// could exhaust everybody's shared budget; if it returned empty, the check
/// would be removable by sending nonsense.
@Test("an address that cannot be parsed keys on itself")
func malformedKeysOnItself() {
for odd in ["", "not-an-address", "203.0.113", "203.0.113.999",
"999.999.999.999", "::", "::1"] {
#expect(AccountRoutes.network(of: odd) == odd,
"\(odd) should key on itself rather than a shared bucket")
}
}
/// Two unparseable values must not land in the same bucket either.
@Test("two unparseable addresses do not share a bucket")
func malformedAreNotFoldedTogether() {
#expect(AccountRoutes.network(of: "garbage-one")
!= AccountRoutes.network(of: "garbage-two"))
}
/// Whitespace and case are not a way to get a fresh budget.
@Test("padding and case do not create a new network")
func normalisedBeforeKeying() {
#expect(AccountRoutes.network(of: " 203.0.113.7 ") == "203.0.113.0/24")
#expect(AccountRoutes.network(of: "2001:DB8:1234:5678::1") == "2001:db8:1234::/48")
}
// MARK: - The ceiling itself
/// ⚠️ Generous by design. A shared network of real people must never meet
/// this, so it sits well above the per-address allowance rather than beside
/// it.
@Test("the network allowance is far larger than the address allowance")
func networkAllowanceIsGenerous() {
#expect(AccountRoutes.signupMaxPerNetworkDefault
> AccountRoutes.signupMaxPerHourDefault * 4)
}
}