From 331db89592a4c179d2c21b1ac118367b8d1b0632 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Mon, 27 Jul 2026 01:03:06 -0500 Subject: [PATCH] Harden hosted handle lifecycle --- docs/operations.md | 6 ++- src/atproto/identity.zig | 46 +++++++++++++++----- src/atproto/server.zig | 21 ++++++---- src/internal/handles.zig | 64 ++++++++++++++++++++++++++-- src/storage/store.zig | 91 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 205 insertions(+), 23 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index cb543cf..b749e51 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -89,8 +89,10 @@ Common deployment settings: - `ZDS_HANDLE_DOMAINS`: comma-separated leading-dot hosted-handle suffixes advertised by `describeServer`, for example `.pds.example.com`. Each suffix needs wildcard DNS and TLS (`*.pds.example.com`) routed to ZDS so hosted - handles can answer `/.well-known/atproto-did`. Do not include the bare PDS - hostname. + handles can answer `/.well-known/atproto-did`. ZDS treats configured suffixes + as authoritative, exposes only active accounts through public handle + resolution, and limits handle updates to 10 per five minutes and 50 per day. + Do not include the bare PDS hostname. - `ZDS_MAIL_PROVIDER`: email delivery provider. Default: `comail`. Supported: `comail`, `resend`. - `ZDS_EMAIL_FROM`: sender address for account and PLC email tokens. The diff --git a/src/atproto/identity.zig b/src/atproto/identity.zig index 9b8ea4f..a28f14c 100644 --- a/src/atproto/identity.zig +++ b/src/atproto/identity.zig @@ -160,19 +160,16 @@ pub fn resolveHandle(request: *http_api.Request) !void { const handle_param = http_api.queryParam(request.url.raw, "handle", &handle_buf) orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing handle"); }; - const handle = std.mem.trim(u8, handle_param, &std.ascii.whitespace); - if (handle.len == 0) { - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing handle"); - } + const handle = handles.normalize(allocator, handle_param) catch { + return http_api.xrpcError(request, .bad_request, "InvalidHandle", "Invalid handle format"); + }; - const did = if (store.resolveRepo(handle)) |account| + const did = if (try store.findActiveAccount(allocator, handle)) |account| account.did - else if (std.ascii.eqlIgnoreCase(handle, "mod-authority.test")) - "did:plc:ar7c4by46qjdydhdevvrndac" + else if (handles.isHostedAuthority(handle, config.handleDomains())) + return http_api.xrpcError(request, .not_found, "HandleNotFound", "Unable to resolve handle") else did: { - const parsed_handle = zat.Handle.parse(handle) orelse { - return http_api.xrpcError(request, .bad_request, "InvalidHandle", "Invalid handle format"); - }; + const parsed_handle = zat.Handle.parse(handle) orelse unreachable; var resolver = zat.HandleResolver.init(store.currentIo(), allocator); defer resolver.deinit(); break :did resolver.resolve(parsed_handle) catch { @@ -192,6 +189,21 @@ pub fn updateHandle(request: *http_api.Request) !void { const auth_ctx = requireAccount(request, allocator) catch return; try requireIdentityScope(request, auth_ctx.oauth_scope, .handle); const account = auth_ctx.account; + switch (store.accountStatus(account.did) catch .deleted) { + .active, .deactivated => {}, + .takendown => return http_api.xrpcError(request, .forbidden, "AccountTakedown", "Account has been taken down"), + .suspended => return http_api.xrpcError(request, .forbidden, "AccountSuspended", "Account is suspended"), + .deleted => return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"), + } + const now = store.nowMs(); + store.consumeRateLimit(account.did, "identity.updateHandle.5m", now, 5 * 60 * 1000, 10) catch |err| switch (err) { + error.RateLimitExceeded => return http_api.xrpcError(request, .too_many_requests, "RateLimitExceeded", "Too many handle updates"), + else => return err, + }; + store.consumeRateLimit(account.did, "identity.updateHandle.day", now, 24 * 60 * 60 * 1000, 50) catch |err| switch (err) { + error.RateLimitExceeded => return http_api.xrpcError(request, .too_many_requests, "RateLimitExceeded", "Too many handle updates"), + else => return err, + }; const body = try http_api.readBodyAlloc(request, allocator, 16 * 1024); const parsed = try http_api.parseJsonBody(request, allocator, body); @@ -226,6 +238,20 @@ pub fn updateHandle(request: *http_api.Request) !void { if (!std.mem.eql(u8, resolved_did, account.did)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "External handle did not resolve to account DID"); } + if (std.mem.startsWith(u8, account.did, "did:web:")) { + var did_resolver = zat.DidResolver.init(store.currentIo(), allocator); + defer did_resolver.deinit(); + var did_doc = did_resolver.resolve(zat.Did.parse(account.did).?) catch { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Account DID did not resolve"); + }; + defer did_doc.deinit(); + const did_handle = did_doc.handle() orelse { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Account DID does not declare the requested handle"); + }; + if (!std.ascii.eqlIgnoreCase(did_handle, handle)) { + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Account DID does not declare the requested handle"); + } + } } if (try store.findAccount(allocator, handle)) |existing| { diff --git a/src/atproto/server.zig b/src/atproto/server.zig index f388b23..114c16a 100644 --- a/src/atproto/server.zig +++ b/src/atproto/server.zig @@ -44,11 +44,13 @@ pub fn atprotoDid(request: *http_api.Request) !void { const host = requestHost(request) orelse { return plain(request, .not_found, "User not found"); }; - const handle = stripPort(host); + const handle = handles.normalize(allocator, stripPort(host)) catch { + return plain(request, .not_found, "User not found"); + }; if (!handles.isHosted(handle, config.handleDomains())) { return plain(request, .not_found, "User not found"); } - const account = store.findAccount(allocator, handle) catch null orelse { + const account = store.findActiveAccount(allocator, handle) catch null orelse { return plain(request, .not_found, "User not found"); }; return plain(request, .ok, account.did); @@ -83,9 +85,12 @@ pub fn createAccount(request: *http_api.Request) !void { const body = try http_api.readBodyAlloc(request, allocator, 1024 * 1024); const parsed = try http_api.parseJsonBody(request, allocator, body); - const handle = zat.json.getString(parsed.value, "handle") orelse { + const raw_handle = zat.json.getString(parsed.value, "handle") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing handle"); }; + const handle = handles.normalize(allocator, raw_handle) catch { + return http_api.xrpcError(request, .bad_request, "InvalidHandle", "invalid handle"); + }; const email = zat.json.getString(parsed.value, "email") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing email"); }; @@ -95,9 +100,6 @@ pub fn createAccount(request: *http_api.Request) !void { if (!isValidEmail(email)) { return http_api.xrpcError(request, .bad_request, "InvalidEmail", "invalid email"); } - if (zat.Handle.parse(handle) == null) { - return http_api.xrpcError(request, .bad_request, "InvalidHandle", "invalid handle"); - } const invite_code = zat.json.getString(parsed.value, "inviteCode"); if (invite_code) |code| { if (!(store.inviteCodeIsAvailable(code) catch false)) { @@ -109,8 +111,11 @@ pub fn createAccount(request: *http_api.Request) !void { const existing_did = zat.json.getString(parsed.value, "did"); if (handles.isHosted(handle, config.handleDomains())) { - handles.validateHosted(handle, config.handleDomains()) catch { - return http_api.xrpcError(request, .bad_request, "InvalidHandle", "invalid hosted handle"); + handles.validateHosted(handle, config.handleDomains()) catch |err| { + return http_api.xrpcError(request, .bad_request, "InvalidHandle", switch (err) { + error.ReservedHandle => "reserved hosted handle", + else => "invalid hosted handle", + }); }; } else if (existing_did) |did| { var resolver = zat.HandleResolver.init(store.currentIo(), allocator); diff --git a/src/internal/handles.zig b/src/internal/handles.zig index 3f01982..ca2509c 100644 --- a/src/internal/handles.zig +++ b/src/internal/handles.zig @@ -3,15 +3,18 @@ const zat = @import("zat"); pub const HostedHandleError = error{ InvalidHandle, + DisallowedTld, + ReservedHandle, UnsupportedDomain, InvalidHostedHandle, }; pub fn normalize(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { - const trimmed = std.mem.trim(u8, raw, &std.ascii.whitespace); - const normalized = try allocator.alloc(u8, trimmed.len); - _ = std.ascii.lowerString(normalized, trimmed); + const normalized = try allocator.alloc(u8, raw.len); + errdefer allocator.free(normalized); + _ = std.ascii.lowerString(normalized, raw); if (zat.Handle.parse(normalized) == null) return HostedHandleError.InvalidHandle; + try validateTld(normalized); return normalized; } @@ -25,6 +28,16 @@ pub fn isHosted(handle: []const u8, raw_domains: []const u8) bool { return false; } +pub fn isHostedAuthority(handle: []const u8, raw_domains: []const u8) bool { + if (isHosted(handle, raw_domains)) return true; + var domains = std.mem.splitScalar(u8, raw_domains, ','); + while (domains.next()) |raw_domain| { + const domain = std.mem.trim(u8, raw_domain, &std.ascii.whitespace); + if (domain.len > 1 and domain[0] == '.' and std.mem.eql(u8, handle, domain[1..])) return true; + } + return false; +} + pub fn validateHosted(handle: []const u8, raw_domains: []const u8) HostedHandleError!void { var domains = std.mem.splitScalar(u8, raw_domains, ','); while (domains.next()) |raw_domain| { @@ -36,11 +49,41 @@ pub fn validateHosted(handle: []const u8, raw_domains: []const u8) HostedHandleE if (name.len < 3 or name.len > 18 or std.mem.indexOfScalar(u8, name, '.') != null) { return HostedHandleError.InvalidHostedHandle; } + if (isReserved(name)) return HostedHandleError.ReservedHandle; return; } return HostedHandleError.UnsupportedDomain; } +fn validateTld(handle: []const u8) HostedHandleError!void { + const disallowed = [_][]const u8{ + ".alt", + ".arpa", + ".example", + ".internal", + ".invalid", + ".local", + ".localhost", + ".onion", + }; + for (disallowed) |suffix| { + if (std.mem.endsWith(u8, handle, suffix)) return HostedHandleError.DisallowedTld; + } +} + +fn isReserved(name: []const u8) bool { + const reserved = [_][]const u8{ + "account", "accounts", "admin", "api", "at", "atp", "atproto", "auth", + "bsky", "bluesky", "did", "help", "handle", "lex", "lexicon", "mail", + "mod", "moderation", "nsid", "oauth", "pds", "plc", "repo", "root", + "status", "support", "system", "tid", "webmaster", "www", "xrpc", + }; + for (reserved) |value| { + if (std.mem.eql(u8, name, value)) return true; + } + return false; +} + pub fn validateDomains(raw_domains: []const u8) error{InvalidHandleDomain}!void { var count: usize = 0; var domains = std.mem.splitScalar(u8, raw_domains, ','); @@ -60,9 +103,24 @@ test "hosted handles require one bounded account label" { try validateHosted("alice.pds.example.com", ".pds.example.com"); try std.testing.expectError(error.InvalidHostedHandle, validateHosted("a.pds.example.com", ".pds.example.com")); try std.testing.expectError(error.InvalidHostedHandle, validateHosted("nested.alice.pds.example.com", ".pds.example.com")); + try std.testing.expectError(error.ReservedHandle, validateHosted("admin.pds.example.com", ".pds.example.com")); try std.testing.expectError(error.UnsupportedDomain, validateHosted("alice.example.com", ".pds.example.com")); } +test "normalization lowercases without accepting surrounding whitespace" { + const normalized = try normalize(std.testing.allocator, "Alice.Example.COM"); + defer std.testing.allocator.free(normalized); + try std.testing.expectEqualStrings("alice.example.com", normalized); + try std.testing.expectError(error.InvalidHandle, normalize(std.testing.allocator, " alice.example.com ")); + try std.testing.expectError(error.DisallowedTld, normalize(std.testing.allocator, "alice.localhost")); +} + +test "hosted authority includes the configured base domain" { + try std.testing.expect(isHostedAuthority("alice.pds.example.com", ".pds.example.com")); + try std.testing.expect(isHostedAuthority("pds.example.com", ".pds.example.com")); + try std.testing.expect(!isHostedAuthority("alice.example.com", ".pds.example.com")); +} + test "handle domains are leading-dot suffixes" { try validateDomains(".pds.example.com,.test.example"); try std.testing.expectError(error.InvalidHandleDomain, validateDomains("pds.example.com")); diff --git a/src/storage/store.zig b/src/storage/store.zig index e659b60..b5d836c 100644 --- a/src/storage/store.zig +++ b/src/storage/store.zig @@ -29,6 +29,7 @@ pub const Error = error{ InvalidAccountStatus, AccountNotFound, HandleNotAvailable, + RateLimitExceeded, StoreNotInitialized, }; @@ -546,6 +547,51 @@ pub fn findAccount(allocator: std.mem.Allocator, identifier: []const u8) !?auth. return try findAccountLocked(allocator, identifier); } +pub fn findActiveAccount(allocator: std.mem.Allocator, identifier: []const u8) !?auth.Account { + db_mutex.lockUncancelable(store_io); + defer db_mutex.unlock(store_io); + try requireInitialized(); + + const row = try conn.row( + \\SELECT handle, did, email, password_hash + \\FROM accounts + \\WHERE account_status = 'active' + \\ AND (lower(handle) = lower(?) OR did = ? OR lower(email) = lower(?)) + \\LIMIT 1 + , .{ identifier, identifier, identifier }); + if (row == null) return null; + defer row.?.deinit(); + return try accountFromRow(allocator, row.?); +} + +pub fn consumeRateLimit(subject: []const u8, action: []const u8, now_ms: i64, window_ms: i64, limit: usize) !void { + db_mutex.lockUncancelable(store_io); + defer db_mutex.unlock(store_io); + try requireInitialized(); + + try conn.execNoArgs("BEGIN IMMEDIATE"); + errdefer conn.execNoArgs("ROLLBACK") catch {}; + try conn.exec( + \\DELETE FROM rate_limit_events + \\WHERE action = ? AND occurred_at < ? + , .{ action, now_ms - window_ms }); + const row = try conn.row( + \\SELECT COUNT(*) + \\FROM rate_limit_events + \\WHERE subject = ? AND action = ? AND occurred_at >= ? + , .{ subject, action, now_ms - window_ms }); + defer if (row) |value| value.deinit(); + if (row != null and row.?.int(0) >= @as(i64, @intCast(limit))) { + try conn.execNoArgs("ROLLBACK"); + return Error.RateLimitExceeded; + } + try conn.exec( + \\INSERT INTO rate_limit_events (subject, action, occurred_at) + \\VALUES (?, ?, ?) + , .{ subject, action, now_ms }); + try conn.execNoArgs("COMMIT"); +} + pub fn searchAccounts(allocator: std.mem.Allocator, query: []const u8, limit: usize) ![]auth.Account { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); @@ -5868,6 +5914,15 @@ const schema_statements = [_][*:0]const u8{ \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) , + "CREATE UNIQUE INDEX IF NOT EXISTS accounts_handle_nocase_idx ON accounts (lower(handle))", + \\CREATE TABLE IF NOT EXISTS rate_limit_events ( + \\ id INTEGER PRIMARY KEY AUTOINCREMENT, + \\ subject TEXT NOT NULL, + \\ action TEXT NOT NULL, + \\ occurred_at INTEGER NOT NULL + \\) + , + "CREATE INDEX IF NOT EXISTS rate_limit_events_lookup_idx ON rate_limit_events (subject, action, occurred_at)", \\CREATE TABLE IF NOT EXISTS records ( \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ collection TEXT NOT NULL, @@ -6267,6 +6322,42 @@ test "persists records in sqlite" { try std.testing.expect(get(account.did, "app.bsky.feed.post", rkey) == null); } +test "active account resolution excludes unavailable identities" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + try init(std.Options.debug_io, ":memory:"); + defer close(); + + const account = try createAccount( + allocator, + "Alice.Example.COM", + "alice-active@test.com", + "password", + "did:plc:activehandle", + true, + ); + try std.testing.expect((try findActiveAccount(allocator, "alice.example.com")) != null); + try setAccountActive(account.did, false); + try std.testing.expect((try findAccount(allocator, "alice.example.com")) != null); + try std.testing.expect((try findActiveAccount(allocator, "alice.example.com")) == null); +} + +test "durable rate limits are isolated by subject and window" { + try init(std.Options.debug_io, ":memory:"); + defer close(); + + try consumeRateLimit("did:plc:alice", "identity.updateHandle", 1_000, 1_000, 2); + try consumeRateLimit("did:plc:alice", "identity.updateHandle", 1_500, 1_000, 2); + try std.testing.expectError( + error.RateLimitExceeded, + consumeRateLimit("did:plc:alice", "identity.updateHandle", 1_750, 1_000, 2), + ); + try consumeRateLimit("did:plc:bob", "identity.updateHandle", 1_750, 1_000, 2); + try consumeRateLimit("did:plc:alice", "identity.updateHandle", 2_100, 1_000, 2); +} + test "repo writes enforce swap and explicit validation preconditions" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); -- 2.51.2