Count sign-ups by network, not only by address #77

Merged
paulhitt merged 2 commits from feat/signup-network-limits into main 2026-09-02 23:53:45 +00:00
6 changed files with 284 additions and 4 deletions
+12 -1
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/CONFIGURATION.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: ab6906714a4d6d47 -->
source-sha256: 00f2d337316548ad -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="configuring-a-relay">Configuring a relay</h1>
@@ -310,6 +310,17 @@ rotated, so treat this as a speed bump against lazy bulk registration
rather than a defence.</td>
</tr>
<tr>
<td><code>FN_SIGNUP_MAX_PER_NETWORK</code></td>
<td><code>400</code>/hr</td>
<td>New accounts per network — a /24 for IPv4, a /48 for IPv6. The
per-address limit counts a rented subnet as separate strangers, and more
addresses is the cheapest thing an abuser buys. Deliberately generous: a
campus or a carrier behind CGNAT is thousands of real people on one
network, and refusing them is the worse failure. <strong><code>0</code>
switches network counting off</strong>, leaving only the per-address
limit.</td>
</tr>
<tr>
<td><code>FN_SEND_RATE</code></td>
<td><code>600</code>/hr</td>
<td>Envelopes, per sender or per recipient when sealed.</td>
+11
View File
@@ -108,6 +108,15 @@ struct Config: Sendable {
/// New accounts per IP per hour. See RoutesAccount.signupMaxPerHour.
let signupMaxPerHour: Int
/// New accounts per network per hour — a /24 for IPv4, a /48 for IPv6.
///
/// On by default and deliberately generous: the per-address limit counts a
/// rented subnet as separate strangers, and more addresses is the cheapest
/// thing an abuser buys. **`0` switches it off**, leaving only the
/// per-address limit — for an operator who would rather admit an abuser
/// than risk refusing a campus.
let signupMaxPerNetworkPerHour: Int
let attachmentBytesPerHour: Int
/// The rolling 30-day per-account upload budget (C3). The hourly limits
/// above stop bursts; this stops a steady drip from quietly owning the
@@ -231,6 +240,8 @@ struct Config: Sendable {
attachmentMaxBytes: int("FN_ATTACHMENT_MAX_BYTES", 10 * 1024 * 1024),
attachmentRatePerHour: int("FN_ATTACHMENT_RATE", 60),
signupMaxPerHour: int("FN_SIGNUP_MAX_PER_HOUR", AccountRoutes.signupMaxPerHourDefault),
signupMaxPerNetworkPerHour:
int("FN_SIGNUP_MAX_PER_NETWORK", AccountRoutes.signupMaxPerNetworkDefault),
attachmentBytesPerHour: int("FN_ATTACHMENT_BYTES_RATE", 200 * 1024 * 1024),
attachmentBytesPerMonth: int("FN_ATTACHMENT_BYTES_PER_MONTH", 500 * 1024 * 1024),
attachmentRetentionDays: int("FN_ATTACHMENT_RETENTION_DAYS", 30),
+2 -1
View File
@@ -327,7 +327,8 @@ func configure(_ app: Application) async throws {
audience: config.audience,
lifetime: config.tokenLifetime),
limits: makeLimiter("signin", AccountRoutes.signinLockout),
signupMaxPerHour: config.signupMaxPerHour)
signupMaxPerHour: config.signupMaxPerHour,
signupMaxPerNetworkPerHour: config.signupMaxPerNetworkPerHour)
try register(account: accounts, config: config, on: app)
// The shipped iOS client's surface, mounted where it already looks.
+69 -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,20 @@ 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.
//
// ⚠️ `0` switches the network bucket off, and it is left OUT of the list
// rather than passed as a limit of zero — a zero limit would refuse
// every sign-up on the relay, which is the exact opposite of off.
var buckets = [(key: "signup:\(clientIP)", amount: 1, limit: signupMaxPerHour)]
if signupMaxPerNetworkPerHour > 0 {
buckets.append((key: "signup:net:\(Self.network(of: clientIP))", amount: 1,
limit: signupMaxPerNetworkPerHour))
}
guard await limits.consumeAll(buckets, now: now) else {
throw WireError.rateLimited(
"Too many accounts created from here recently. Try again later.")
}
@@ -424,6 +454,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,189 @@
import Crypto
import Fluent
import Foundation
import Testing
import Vapor
@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)
}
// MARK: - Helpers
private let now = 1_700_000_000
private func routes(_ h: RelayHarness) -> AccountRoutes {
AccountRoutes(
issuer: Issuer(signingKeyRaw: Data(repeating: 1, count: 32),
audience: "test.relay", lifetime: 300),
limits: RateLimiter(),
signupMaxPerHour: 1000,
signupMaxPerNetworkPerHour: AccountRoutes.signupMaxPerNetworkDefault)
}
private func post(_ h: RelayHarness, _ body: String) -> Request {
var headers = HTTPHeaders()
headers.contentType = .json
var buf = ByteBufferAllocator().buffer(capacity: body.utf8.count)
buf.writeString(body)
return Request(application: h.app, method: .POST, url: URI(path: "/account/signup"),
headers: headers, collectedBody: buf,
on: h.app.eventLoopGroup.next())
}
// MARK: - The switch
/// 🔴 The trap this exists to catch: `0` must mean **off**, not "refuse
/// everybody".
///
/// A limit of zero handed to the limiter refuses every sign-up on the
/// relay — an operator who set `FN_SIGNUP_MAX_PER_NETWORK=0` meaning
/// "stop counting networks" would take the whole door off its hinges
/// instead. So the bucket is left out of the list rather than passed as
/// zero, and this pins that.
@Test("zero switches network counting off and still lets people sign up")
func zeroMeansOffNotClosed() async throws {
try await withRelay { h in
var a = self.routes(h)
a.signupMaxPerNetworkPerHour = 0
// Two accounts from the same /24. With a zero limit read as a
// limit, neither would exist.
for i in 0..<2 {
_ = try await a.signup(
self.post(h, #"{"username":"net\#(i)","email":"net\#(i)@example.com","password":"hunter2hunter2"}"#),
clientIP: "203.0.113.\(i)", now: self.now)
}
#expect(try await User.query(on: h.app.db).count() == 2)
}
}
/// And with it on, the ceiling is real.
@Test("a low ceiling refuses the account past it")
func theCeilingHolds() async throws {
try await withRelay { h in
var a = self.routes(h)
a.signupMaxPerNetworkPerHour = 1
_ = try await a.signup(
self.post(h, #"{"username":"first","email":"first@example.com","password":"hunter2hunter2"}"#),
clientIP: "203.0.113.1", now: self.now)
// A different address, the same /24 — this is the case the
// per-address limit cannot see.
await #expect(throws: WireError.self) {
_ = try await a.signup(
self.post(h, #"{"username":"second","email":"second@example.com","password":"hunter2hunter2"}"#),
clientIP: "203.0.113.2", now: self.now)
}
}
}
/// Unset means the generous default, never off by accident.
@Test("an unset variable leaves the ceiling in place")
func unsetKeepsTheDefault() throws {
let env = [
"FN_ISSUER_PUBLIC_KEY": Curve25519.Signing.PrivateKey()
.publicKey.rawRepresentation.base64EncodedString(),
"FN_RELAY_AUDIENCE": "test.relay",
]
#expect(try Config.fromEnvironment(env).signupMaxPerNetworkPerHour
== AccountRoutes.signupMaxPerNetworkDefault)
var on = env; on["FN_SIGNUP_MAX_PER_NETWORK"] = "0"
#expect(try Config.fromEnvironment(on).signupMaxPerNetworkPerHour == 0)
}
}
+1
View File
@@ -124,6 +124,7 @@ Defaults are sane; raise them only with a reason.
| `FN_BUNDLE_RATE` | `120`/hr | Prekey-bundle fetches per requester. Each pops a one-time prekey per device, so an unmetered version is a pool drain. |
| `FN_BUNDLE_RATE_TARGET` | `6`/hr | Per requester-target pair. Deliberately **not** metered per target alone — that would let one abuser make a popular account unstartable for everyone. |
| `FN_SIGNUP_MAX_PER_HOUR` | `50`/hr | New accounts per IP. ⚠️ An IP is not a person — carrier CGNAT, offices and campuses all share one. It was `5`, which refused the sixth user behind any shared connection. Per-IP is trivially rotated, so treat this as a speed bump against lazy bulk registration rather than a defence. |
| `FN_SIGNUP_MAX_PER_NETWORK` | `400`/hr | New accounts per network — a /24 for IPv4, a /48 for IPv6. The per-address limit counts a rented subnet as separate strangers, and more addresses is the cheapest thing an abuser buys. Deliberately generous: a campus or a carrier behind CGNAT is thousands of real people on one network, and refusing them is the worse failure. **`0` switches network counting off**, leaving only the per-address limit. |
| `FN_SEND_RATE` | `600`/hr | Envelopes, per sender or per recipient when sealed. |
| `FN_ATTACHMENT_RATE` | `60`/hr | |
| `FN_ATTACHMENT_BYTES_RATE` | `200 MiB`/hr | |