diff --git a/Sources/FlamenetRelay/Account.swift b/Sources/FlamenetRelay/Account.swift index 1766605..8f51b75 100644 --- a/Sources/FlamenetRelay/Account.swift +++ b/Sources/FlamenetRelay/Account.swift @@ -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 } diff --git a/Sources/FlamenetRelay/Config.swift b/Sources/FlamenetRelay/Config.swift index 453f9bd..5e2783c 100644 --- a/Sources/FlamenetRelay/Config.swift +++ b/Sources/FlamenetRelay/Config.swift @@ -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 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), diff --git a/Sources/FlamenetRelay/Entrypoint.swift b/Sources/FlamenetRelay/Entrypoint.swift index 9066c29..62b30a8 100644 --- a/Sources/FlamenetRelay/Entrypoint.swift +++ b/Sources/FlamenetRelay/Entrypoint.swift @@ -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. diff --git a/Sources/FlamenetRelay/Migrations.swift b/Sources/FlamenetRelay/Migrations.swift index d8cb513..18cc959 100644 --- a/Sources/FlamenetRelay/Migrations.swift +++ b/Sources/FlamenetRelay/Migrations.swift @@ -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()] } } diff --git a/Sources/FlamenetRelay/RoutesAccount.swift b/Sources/FlamenetRelay/RoutesAccount.swift index 72596e7..97ed04a 100644 --- a/Sources/FlamenetRelay/RoutesAccount.swift +++ b/Sources/FlamenetRelay/RoutesAccount.swift @@ -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 = [] + /// 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 = []) -> 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 = [ + "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 } diff --git a/Tests/FlamenetRelayTests/SignupValidationTests.swift b/Tests/FlamenetRelayTests/SignupValidationTests.swift new file mode 100644 index 0000000..afd0ca2 --- /dev/null +++ b/Tests/FlamenetRelayTests/SignupValidationTests.swift @@ -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") + } + } +}