Refuse look-alike handles and throwaway addresses at sign-up #74

Merged
paulhitt merged 1 commits from feat/signup-validation into main 2026-09-03 00:16:30 +00:00
6 changed files with 277 additions and 2 deletions
+32
View File
@@ -14,6 +14,10 @@ final class User: Model, @unchecked Sendable {
@ID(custom: "id", generatedBy: .database) var id: Int?
@Field(key: "username") var username: String
/// `username` with look-alike characters folded — see `confusableSkeleton`.
/// Stored and indexed so the collision check is a lookup rather than a scan
/// of every account on the relay.
@OptionalField(key: "username_skeleton") var usernameSkeleton: String?
/// Optional, because an account authenticated by the shared FlameNet
/// account service has no email here and storing one it never uses would
/// be a field that exists only to leak. Empty string is not an option: the
@@ -86,6 +90,34 @@ final class User: Model, @unchecked Sendable {
username.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
/// A canonical form in which look-alike handles collide.
///
/// 🔴 **The same argument as `normalize` above, one step further.** Two
/// accounts differing only in case are an impersonation primitive; so are
/// two differing only by a digit that is drawn like a letter. `paulh1tt`
/// beside `paulhitt` is not a typo somebody made, it is the whole attack —
/// and in a product whose identity story is "you know who you are talking
/// to", a handle is the thing being trusted.
///
/// ⚠️ **Only single characters, and only inside `[a-z0-9]`.** The username
/// charset is already restricted to lowercase letters and digits
/// (`isValidUsername`), so Unicode homoglyphs cannot arise here at all —
/// which makes this a small, closed problem rather than the open-ended one
/// it is on platforms that allow the whole of Unicode.
///
/// ⚠️ Deliberately NOT `rn` → `m`, which is the other classic confusable.
/// It is real, and it would fold `corner` into `comer` — a false positive
/// on an ordinary word costs a legitimate sign-up, and somebody refused at
/// sign-up rarely comes back. Single-character swaps are where the ratio of
/// caught to refused is defensible.
static func confusableSkeleton(_ username: String) -> String {
let folded: [Character: Character] = [
"0": "o", "1": "l", "i": "l", "2": "z", "3": "e",
"4": "a", "5": "s", "7": "t", "8": "b", "9": "g",
]
return String(normalize(username).map { folded[$0] ?? $0 })
}
static func isValidUsername(_ username: String) -> Bool {
let u = normalize(username)
guard (3...60).contains(u.count) else { return false }
+10
View File
@@ -201,6 +201,10 @@ struct Config: Sendable {
/// Enforcement also still requires SMTP: see `canSendMail`. Both must be
/// true, because a rule that cannot be satisfied must not be enforced.
let emailVerificationRequired: Bool
/// Extra disposable-email domains to refuse at sign-up, comma separated.
/// Adds to the built-in list rather than replacing it.
let disposableEmailDomains: Set<String>
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
@@ -365,6 +369,12 @@ struct Config: Sendable {
let raw = (env["FN_EMAIL_VERIFICATION_REQUIRED"] ?? "").lowercased()
return raw == "true" || raw == "1" || raw == "yes"
}(),
disposableEmailDomains: Set(
(env["FN_DISPOSABLE_EMAIL_DOMAINS"] ?? "")
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
.filter { !$0.isEmpty }),
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
@@ -344,7 +344,8 @@ func configure(_ app: Application) async throws {
mailer: config.smtp.map {
Mailer(config: $0, group: app.eventLoopGroup, logger: app.logger)
},
siteURL: config.audience)
siteURL: config.audience,
disposableEmailDomains: config.disposableEmailDomains)
try register(account: accounts, config: config, on: app)
// The shipped iOS client's surface, mounted where it already looks.
+37 -1
View File
@@ -564,6 +564,42 @@ struct AddEmailVerification: AsyncMigration {
}
}
/// Give every account a folded form of its username, so a look-alike handle can
/// be caught with a lookup instead of a scan.
///
/// 🔴 **Backfilled, not left to fill itself.** A column that only new sign-ups
/// populate would let every account that exists today be impersonated freely —
/// the check would find no collision because there would be nothing to collide
/// with. The backfill is the point; the column is bookkeeping.
struct AddUsernameSkeleton: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(User.schema)
.field("username_skeleton", .string)
.update()
// Folded here rather than in SQL: the mapping lives in one place
// (`User.confusableSkeleton`) and a second copy in a `translate()` would
// drift from it silently the first time a character is added.
let users = try await User.query(on: database).all()
for user in users {
user.usernameSkeleton = User.confusableSkeleton(user.username)
try await user.update(on: database)
}
// The sign-up path queries this on every attempt, including refused
// ones — a scan per attempt is a free way to make sign-up expensive.
if let sql = database as? any SQLDatabase {
try? await sql.raw("""
CREATE INDEX IF NOT EXISTS users_username_skeleton
ON users (username_skeleton)
""").run()
}
}
func revert(on database: any Database) async throws {
try await database.schema(User.schema).deleteField("username_skeleton").update()
}
}
/// Give every device already registered a leaf in the transparency log.
///
/// 🔴 **Without this, turning on automatic verification strands the existing
@@ -650,6 +686,6 @@ enum RelayMigrations {
// ⚠️ Appended, never inserted. Fluent runs these in registration
// order and records what it has run; putting a new one in the middle
// changes the order for a database that has already migrated.
CreateContentReport(), CreateVouchCode(), AddEmailVerification()]
CreateContentReport(), CreateVouchCode(), AddEmailVerification(), AddUsernameSkeleton()]
}
}
+81
View File
@@ -82,6 +82,10 @@ struct AccountRoutes: Sendable {
/// Named in the verification message so somebody can tell which relay asked.
var siteURL: String = ""
/// Extra disposable-email domains from the environment, on top of the
/// built-in list. Empty by default — see `builtInDisposableDomains`.
var disposableEmailDomains: Set<String> = []
/// Five failures per IP+username per fifteen minutes, matching the plugin.
///
/// This route turns a password into a long-lived credential, so without a
@@ -225,8 +229,41 @@ struct AccountRoutes: Sendable {
throw WireError("email_taken", "That email is already registered.", 409)
}
// 🔴 **A handle that only LOOKS like somebody else's.** The charset is
// already lowercase letters and digits, so this is not the open-ended
// Unicode homoglyph problem — it is the small closed one: `paulh1tt`
// beside `paulhitt`. The case-folding in `User.normalize` exists for
// exactly this reason one step earlier, and its own comment says why:
// in a product whose identity story is "you know who you are talking
// to", the handle is the thing being trusted.
//
// ⚠️ Refused rather than warned, and only on a FULL skeleton
// collision. A near-miss is somebody's real name; an exact fold of an
// existing handle is not a coincidence.
let skeleton = User.confusableSkeleton(username)
guard try await User.query(on: request.db)
.filter(\.$usernameSkeleton == skeleton).first() == nil else {
throw WireError("username_confusable",
"That username is too easily mistaken for one that already exists. "
+ "Try one that differs by more than a digit.", 409)
}
// Disposable addresses. Not a security control — anybody determined
// registers a domain — but it raises the floor on bulk sign-ups, and
// the cost of being wrong is one person using a different address.
//
// ⚠️ Extendable from the environment, because a list baked into a
// binary is wrong the week after it ships and a self-hoster may
// reasonably want a different one.
guard !Self.isDisposableEmailDomain(email, extra: disposableEmailDomains) else {
throw WireError("disposable_email",
"That email provider isn't accepted. Please use an address you can "
+ "receive mail at later — it is how you get back in.", 400)
}
let user = User()
user.username = username
user.usernameSkeleton = skeleton
user.email = email
user.displayName = (input.display_name?.trimmingCharacters(in: .whitespacesAndNewlines))
.flatMap { $0.isEmpty ? nil : $0 } ?? input.username
@@ -358,9 +395,21 @@ struct AccountRoutes: Sendable {
throw WireError("username_taken",
"Somebody already uses that name on this server. Pick another.", 409)
}
// ⚠️ **The same look-alike check as `signup`, and it has to be here
// too.** This path creates accounts for people authenticated
// elsewhere, so a check that lived only on the password path would be
// one an impersonator simply walks around.
let skeleton = User.confusableSkeleton(username)
guard try await User.query(on: request.db)
.filter(\.$usernameSkeleton == skeleton).first() == nil else {
throw WireError("username_confusable",
"That username is too easily mistaken for one that already exists on "
+ "this server. Pick another.", 409)
}
let user = User()
user.username = username
user.usernameSkeleton = skeleton
// No email and no password: this account is authenticated elsewhere,
// and storing a credential it never uses would be a credential that
// can leak for nothing.
@@ -768,6 +817,38 @@ struct AccountRoutes: Sendable {
private static let dummyHash =
"$2b$12$C6UzMDM.H6dfI/f/IKcEe.7BEnJXo4jFmBH3nXWa5FRuBAbUiFbW."
/// Domains that exist to be thrown away.
///
/// ⚠️ **A floor, not a wall.** Anybody determined registers a domain for a
/// few pounds, so this stops bulk sign-ups rather than a targeted one. It
/// is worth having anyway because the email is how somebody gets back into
/// their account, and an address they cannot receive at is a lockout
/// waiting to happen — this refuses at the one moment it can still be fixed
/// painlessly.
///
/// ⚠️ Matches the exact domain and any subdomain of it, so
/// `x.mailinator.com` is caught by `mailinator.com`.
static func isDisposableEmailDomain(_ email: String, extra: Set<String> = []) -> Bool {
guard let at = email.lastIndex(of: "@") else { return false }
let domain = email[email.index(after: at)...]
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !domain.isEmpty else { return false }
let blocked = builtInDisposableDomains.union(extra)
if blocked.contains(domain) { return true }
return blocked.contains { domain.hasSuffix("." + $0) }
}
/// Deliberately short. A long list goes stale and gives false confidence;
/// these are the ones that show up in bulk sign-ups. Extend from the
/// environment with `FN_DISPOSABLE_EMAIL_DOMAINS` rather than editing here.
static let builtInDisposableDomains: Set<String> = [
"mailinator.com", "guerrillamail.com", "10minutemail.com", "tempmail.com",
"temp-mail.org", "throwawaymail.com", "yopmail.com", "trashmail.com",
"getnada.com", "dispostable.com", "maildrop.cc", "fakeinbox.com",
"sharklasers.com", "grr.la", "spam4.me", "mohmal.com", "moakt.com",
"emailondeck.com", "tempr.email", "discard.email",
]
private func isPlausibleEmail(_ s: String) -> Bool {
let t = s.trimmingCharacters(in: .whitespacesAndNewlines)
guard t.count >= 6, t.count <= 254, !t.contains(" ") else { return false }
@@ -0,0 +1,115 @@
import XCTest
@testable import FlamenetRelay
/// Sign-up refusals that protect somebody other than the person signing up.
///
/// 🔴 **A handle is the thing being trusted here.** The product's identity story
/// is "you know who you are talking to because you have known them for years",
/// and `User.normalize` already refuses two accounts differing only in case for
/// exactly that reason. A digit drawn like a letter is the same attack with a
/// different keystroke: `paulh1tt` beside `paulhitt` is not a typo somebody
/// made, it is the whole point.
final class SignupValidationTests: XCTestCase {
// MARK: - Look-alike handles
/// The cases this exists to catch. Each pair must fold together.
func testLookAlikeHandlesCollide() {
let pairs = [
("paulhitt", "paulh1tt"), // 1 for i
("paulhitt", "paulhltt"), // l for i — the other direction
("bobsmith", "b0bsmith"), // 0 for o
("alice", "a1ice"), // 1 for l
("steve", "st3ve"), // 3 for e
("sarah", "s4rah"), // 4 for a
("boss", "b055"), // 5 for s, twice
("gate", "g4t3"), // several at once
("tessa", "7essa"), // 7 for t
("bella", "8ella"), // 8 for b
("greg", "9re9"), // 9 for g
("zoe", "2oe"), // 2 for z
]
for (real, fake) in pairs {
XCTAssertEqual(User.confusableSkeleton(real), User.confusableSkeleton(fake),
"\(fake) should be caught as a look-alike of \(real)")
}
}
/// ⚠️ **The other half, and the more important one.** A rule that folds too
/// eagerly refuses people using their own names, and somebody turned away at
/// sign-up rarely comes back. These must NOT collide.
func testOrdinaryDifferentNamesDoNotCollide() {
let pairs = [
("paul", "paula"), // a real suffix, not a swap
("sam", "samuel"),
("corner", "comer"), // rn/m is deliberately NOT folded
("rnark", "mark"), // same reason, other direction
("dan", "don"), // different vowels are different names
("kate", "katie"),
("chris", "cris"),
("jon", "john"),
]
for (a, b) in pairs {
XCTAssertNotEqual(User.confusableSkeleton(a), User.confusableSkeleton(b),
"\(a) and \(b) are different names and must not collide")
}
}
/// Case folding already happens in `normalize`; the skeleton must keep it.
func testTheSkeletonIsCaseInsensitiveAndTrimmed() {
XCTAssertEqual(User.confusableSkeleton(" PaulH1tt "),
User.confusableSkeleton("paulhitt"))
}
/// ⚠️ A username that is already its own skeleton must be left alone —
/// otherwise the column would differ from the name for no reason and the
/// lookup would miss.
func testAPlainNameIsItsOwnSkeleton() {
XCTAssertEqual(User.confusableSkeleton("paulhtt"), "paulhtt")
}
// MARK: - Disposable email
func testKnownDisposableProvidersAreRefused() {
for address in ["a@mailinator.com", "b@YOPMAIL.COM", "c@10minutemail.com",
"d@guerrillamail.com", "e@sharklasers.com"] {
XCTAssertTrue(AccountRoutes.isDisposableEmailDomain(address),
"\(address) should have been refused")
}
}
/// Subdomains too — `x.mailinator.com` is the same provider.
func testSubdomainsOfADisposableProviderAreRefused() {
XCTAssertTrue(AccountRoutes.isDisposableEmailDomain("a@inbox.mailinator.com"))
}
/// 🔴 The expensive failure. Refusing a real address costs a real person.
func testOrdinaryProvidersAreAccepted() {
for address in ["someone@gmail.com", "someone@proton.me", "a@pm.me",
"person@their-own-domain.co.uk", "x@fastmail.com",
// ⚠️ Contains a blocked domain as a SUBSTRING but is not
// it, and must not be caught by a naive `contains`.
"x@notmailinator.com", "y@mailinator.com.example.org"] {
XCTAssertFalse(AccountRoutes.isDisposableEmailDomain(address),
"\(address) is a legitimate address and must be accepted")
}
}
/// A self-hoster's own additions are honoured alongside the built-ins.
func testTheEnvironmentCanAddDomains() {
XCTAssertFalse(AccountRoutes.isDisposableEmailDomain("a@example.test"))
XCTAssertTrue(AccountRoutes.isDisposableEmailDomain(
"a@example.test", extra: ["example.test"]))
// And the built-ins still apply when extras are supplied.
XCTAssertTrue(AccountRoutes.isDisposableEmailDomain(
"a@mailinator.com", extra: ["example.test"]))
}
/// Malformed input must not crash or accidentally match.
func testMalformedAddressesAreNotRefusedByAccident() {
for address in ["", "no-at-sign", "@", "trailing@"] {
XCTAssertFalse(AccountRoutes.isDisposableEmailDomain(address),
"\(address) should fall through to the ordinary email check")
}
}
}