Count sign-ups by network, not only by address #77
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 | |
|
||||
|
||||
Reference in New Issue
Block a user