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

**Look-alike handles.** `User.normalize` already refuses two accounts
differing only in case, and its own comment says why: two accounts that
look the same are an impersonation primitive in a product whose identity
story is "you know who you are talking to because you have known them for
years". A digit drawn like a letter is that same attack with a different
keystroke — `paulh1tt` beside `paulhitt` is not a typo somebody made.

`User.confusableSkeleton` folds the ten single-character swaps that
matter, stored in an indexed `username_skeleton` column so the check is a
lookup rather than a scan of every account.

⚠️ **Applied on BOTH creation paths.** `signup` is the obvious one; the
provisioning route for accounts authenticated elsewhere creates users
too, and a check living only on the password path is one an impersonator
walks around.

⚠️ **Deliberately NOT `rn` → `m`**, the other classic confusable. It is
real and it would fold `corner` into `comer` — refusing an ordinary word
costs a legitimate sign-up, and somebody turned away rarely comes back.
Single-character swaps are where the ratio of caught to refused is
defensible. `testOrdinaryDifferentNamesDoNotCollide` holds that line, and
`corner`/`comer` is one of its cases.

🔴 **The migration BACKFILLS.** A column only new sign-ups populated
would leave every existing account freely impersonable, because the check
would find nothing to collide with. Folded in Swift rather than SQL so
the mapping lives in one place and a second copy cannot drift from it.

**Throwaway addresses.** A short built-in list, extendable with
`FN_DISPOSABLE_EMAIL_DOMAINS`, matching the domain and its subdomains.
Not a security control — anybody determined registers a domain — but it
raises the floor on bulk sign-ups, and the email is how somebody gets
back into their account: refusing at the one moment it is still painless
beats a lockout later.

Nine tests, proven able to fail: weakening the fold turns
`testLookAlikeHandlesCollide` red on four pairs. 470 tests in 40 suites
still pass.

Closes part of flamenet-relay#42.
This commit is contained in:
Paul Hitt
2026-09-02 20:15:23 -04:00
parent 28fe68dc82
commit 54e1c2b9b7
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")
}
}
}