Give network counting a switch, with zero meaning off

FN_SIGNUP_MAX_PER_NETWORK, defaulting to the generous 400/hr the ceiling was
already written for. On by default: a limit keyed on a single address is the
one an abuser always steps around, and turning that off by default would
leave the hole this closes.

🔴 Zero means OFF, and the bucket is left out of the list rather than passed
as a limit of zero. A zero limit handed to the limiter refuses every sign-up
on the relay — an operator setting this to 0 meaning "stop counting
networks" would take the door off its hinges instead. Pinned by a test that
signs two accounts up from one /24 with the limit at zero.

Also tested: a low ceiling really does refuse the second account from the
same /24 (the case per-address counting cannot see), and an unset variable
leaves the default in place rather than silently disabling it.

No effect on the Android build in the field: v8 signs up well under 400 per
network per hour, and the default is unchanged from what this MR already had.

482 pass.
This commit is contained in:
Paul Hitt
2026-09-02 19:52:35 -04:00
parent 21c77f6731
commit e7e1a78755
6 changed files with 121 additions and 7 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.
+10 -5
View File
@@ -97,11 +97,16 @@ struct AccountRoutes: Sendable {
// 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 {
//
// ⚠️ `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.")
}
@@ -1,5 +1,8 @@
import Crypto
import Fluent
import Foundation
import Testing
import Vapor
@testable import FlamenetRelay
@@ -101,4 +104,86 @@ struct SignupNetworkLimitTests {
#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 | |