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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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