@@ -224,8 +224,8 @@ sToMs = \s -> stripUnit s * 1000
224224-- ============================================
225225-- Wire/state shapes plus refinement aliases for hex/server-name validation.
226226-- Hex types are bounded length + lowercase-hex; server names are lowercased
227−-- and stripped of trailing dots, with a deny-list filter (`isBadServerName`)
228−-- applied where SSRF is a concern.
227+-- and stripped of trailing dots. The SSRF deny-list (`isBadServerName`) is
228+-- applied at federation egress, not in the type predicate.
229229
230230-- One event in a /poll response: the opaque cipher blob addressed to the
231231-- polling pubkey. The server cannot read the blob; clients derive any
@@ -272,17 +272,14 @@ isValidMessageId : Text -> Bool
272272isValidMessageId = isValidBoundedHex maxMessageIdLen
273273-- Hex-encoded message id, length ≤ `maxMessageIdLen`.
274274type MessageIdHex = Text where isValidMessageId
275−-- Normalized hostname (lowercased, no trailing dot, length-bounded).
276−isAnyServerName : Text -> Bool
277−isAnyServerName = \s -> not tooLong s maxServerNameLen && s == normalizeServerName s
278−-- A normalized hostname that also passes the SSRF deny-list (`isBadServerName`).
275+-- Normalized hostname (lowercased, no trailing dot, length-bounded). The SSRF
276+-- deny-list (`isBadServerName`) is enforced separately at federation egress
277+-- (`withFederationAuth`, `handlePingPeer`); keeping it out of the type predicate
278+-- lets the default `serverHost = "localhost"` flow through presence rows in a
279+-- non-federated single-node deployment.
279280isValidServerName : Text -> Bool
280−isValidServerName = \s -> isAnyServerName s && not isBadServerName s
281−-- Peer hostname suitable for outbound federation calls.
281+isValidServerName = \s -> not tooLong s maxServerNameLen && s == normalizeServerName s
282282type ServerName = Text where isValidServerName
283−-- Normalized hostname with no SSRF filter; used when the value comes from
284−-- presence rows that the client only ever sees masked to "remote".
285−type AnyServerName = Text where isAnyServerName
286283
287284-- Wall-clock time in milliseconds since epoch.
288285type Timestamp = Int<Ms>
@@ -306,7 +303,7 @@ type Session = {
306303type Message = {toKey: PubkeyHex, encryptedBlob: BlobHex, receivedAt: Timestamp}
307304-- Last-known location of a pubkey, either local (`server == serverHostN`) or
308305-- learned from a peer via gossip. Multiple entries per pubkey are allowed.
309−type PresenceEntry = {pubkey: PubkeyHex, server: AnyServerName, expiresAt: Timestamp}
306+type PresenceEntry = {pubkey: PubkeyHex, server: ServerName, expiresAt: Timestamp}
310307-- A message queued for cross-server delivery to `toServer`. `retries` is
311308-- incremented by `retryPendingForwards` until `maxForwardRetries` is hit.
312309-- Identity is derived from `encryptedBlob` (see `Message`).
@@ -406,8 +403,8 @@ route ClientApi where
406403 POST {messageIds: [MessageIdHex]} /messages/ack headers {authorization: Text, clientIp: Text} -> {ok: Bool, authorized: Bool, ackedCount: Int, error: Text} = AckMessages
407404 POST {offset: Int} /messages/list headers {authorization: Text, clientIp: Text} -> {messages: [{messageId: MessageIdHex, receivedAt: Timestamp}], authorized: Bool, count: Int, error: Text} = ListMessages
408405 POST {messageIds: [MessageIdHex]} /messages/delete headers {authorization: Text, clientIp: Text} -> {ok: Bool, authorized: Bool, deletedCount: Int, error: Text} = DeleteMessages
409− GET /lookup/{pubkey: PubkeyHex} headers {authorization: Text, clientIp: Text} -> {server: AnyServerName, authorized: Bool, error: Text} = LookupPresence
410− POST {pubkeys: [PubkeyHex]} /lookup/batch headers {authorization: Text, clientIp: Text} -> {results: [{pubkey: PubkeyHex, server: AnyServerName}], authorized: Bool, error: Text} = BatchLookupPresence
406+ GET /lookup/{pubkey: PubkeyHex} headers {authorization: Text, clientIp: Text} -> {server: ServerName, authorized: Bool, error: Text} = LookupPresence
407+ POST {pubkeys: [PubkeyHex]} /lookup/batch headers {authorization: Text, clientIp: Text} -> {results: [{pubkey: PubkeyHex, server: ServerName}], authorized: Bool, error: Text} = BatchLookupPresence
411408
412409route FederationApi where
413410 POST {events: [GossipEvent], fromServer: ServerName, requestTimestamp: Timestamp, correlationId: Text} /federation/gossip headers {authorization: Text} -> {ok: Bool, error: Text, retryAfterMs: Int} = RecvGossip
@@ -776,14 +773,18 @@ verifyFederationAuth = \authorization fromServer scope requestTimestamp -> do
776773
777774-- Combined gate for inbound federation calls. Order of checks (each short-
778775-- circuits with an error code in the response):
779−-- 1. peer not block-listed; 2. peer in allow-list (if any); 3. caller-supplied
780−-- `precondition` holds; 4. clock skew within `clockSkewTolerance`;
781−-- 5. Ed25519 signature verifies; 6. per-peer federation rate limit.
776+-- 1. `fromServer` passes the SSRF deny-list (so `verifyFederationAuth`'s
777+-- pubkey fetch can't be steered at loopback/private hosts);
778+-- 2. peer not block-listed; 3. peer in allow-list (if any); 4. caller-supplied
779+-- `precondition` holds; 5. clock skew within `clockSkewTolerance`;
780+-- 6. Ed25519 signature verifies; 7. per-peer federation rate limit.
782781withFederationAuth = \authorization t respond fromServer scope requestTimestamp precondition action -> do
783782 blocked <- *blockedPeers
784783 allowed <- *allowedPeers
785784 let err = \e r -> yield (respond {ok: False {}, error: e, retryAfterMs: r})
786− let preErr = if elem fromServer blocked
785+ let preErr = if isBadServerName fromServer
786+ then "invalid_request"
787+ else if elem fromServer blocked
787788 then "blocked"
788789 else if count allowed > 0 && not elem fromServer allowed
789790 then "not_allowed"
@@ -977,7 +978,7 @@ knownPeerServersFromPresence = \presence -> collectPeers presence (\p -> p.serve
977978-- One sweep over every state ref: drops expired rows in a single atomic block.
978979-- Returns the locally-expired presence rows whose pubkey has no other live
979980-- local session, so the caller can broadcast offline gossip for them.
980−pruneExpired : Int<Ms> -> IO {rw *activePolls, rw *challenges, rw *deliveredIds, rw *forwardFailures, rw *forwards, rw *messages, rw *presence, rw *sessions} [{expiresAt: Timestamp, pubkey: PubkeyHex, server: AnyServerName}]
981+pruneExpired : Int<Ms> -> IO {rw *activePolls, rw *challenges, rw *deliveredIds, rw *forwardFailures, rw *forwards, rw *messages, rw *presence, rw *sessions} [{expiresAt: Timestamp, pubkey: PubkeyHex, server: ServerName}]
981982pruneExpired = \t -> atomic do
982983 ch <- *challenges
983984 *challenges = filter ch (\x -> x.expiresAt > t)
@@ -1912,7 +1913,9 @@ handlePingPeer = \server authorization respond -> withMaintAuth authorization (y
19121913 ok: False {},
19131914 latencyMs: 0,
19141915 error: "unauthorized"
1915−})) (do
1916+})) (if isBadServerName server
1917+then yield (respond {ok: False {}, latencyMs: 0, error: "invalid_server"})
1918+else do
19161919 startTime <- now
19171920 auth <- generateFederationAuth "gossip" startTime
19181921 result <- fetch (federationUrl server) (RecvGossip {
@@ -2297,7 +2300,7 @@ handleStatsPeers = \authorization respond -> withMaintAuth authorization (yield
22972300
22982301-- GET /stats/key/{pubkey}: per-key footprint — message count, presence rows,
22992302-- session count, queued forwards (with details), and active long-poll count.
2300−handleStatsKey : PubkeyHex -> Text -> ({activePolls: Int, forwardDetails: [{createdAt: Int<Ms>, retries: Int, server: ServerName}], forwards: Int, messages: Int, ok: Bool, presence: [{expiresAt: Int<Ms>, server: AnyServerName}], sessions: Int} -> a) -> IO {r *activePolls, r *forwards, r *messages, r *presence, r *runtimeMaintenanceSecret, r *sessions, clock} a
2303+handleStatsKey : PubkeyHex -> Text -> ({activePolls: Int, forwardDetails: [{createdAt: Int<Ms>, retries: Int, server: ServerName}], forwards: Int, messages: Int, ok: Bool, presence: [{expiresAt: Int<Ms>, server: ServerName}], sessions: Int} -> a) -> IO {r *activePolls, r *forwards, r *messages, r *presence, r *runtimeMaintenanceSecret, r *sessions, clock} a
23012304handleStatsKey = \pubkey authorization respond -> withMaintAuth authorization (yield (respond {
23022305 messages: 0,
23032306 presence: [],