Refuse look-alike handles and throwaway addresses at sign-up #74
@@ -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 }
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user