diff --git a/whitenoise-mac/Core/NIP05Resolver.swift b/whitenoise-mac/Core/NIP05Resolver.swift index d873c31..848dfca 100644 --- a/whitenoise-mac/Core/NIP05Resolver.swift +++ b/whitenoise-mac/Core/NIP05Resolver.swift @@ -23,7 +23,13 @@ enum NIP05ResolutionError: Error { struct NIP05Resolver: NIP05Resolving { private let session: URLSession - init(session: URLSession = URLSession(configuration: Self.makeSessionConfiguration())) { + init( + session: URLSession = URLSession( + configuration: Self.makeSessionConfiguration(), + delegate: NIP05RedirectPolicy(), + delegateQueue: nil + ) + ) { self.session = session } @@ -72,6 +78,27 @@ struct NIP05Resolver: NIP05Resolving { } } +/// Re-validates each redirect target against the SSRF host policy — URLSession auto-follows +/// redirects, so without this a public well-known host could 3xx the lookup to a private/ +/// loopback/link-local address the up-front guard blocks. Mirrors the avatar path's +/// CappedImageDownloadDelegate. +final class NIP05RedirectPolicy: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + guard let url = request.url, RemoteImageURLPolicy.isAllowed(url) else { + completionHandler(nil) + task.cancel() + return + } + completionHandler(request) + } +} + struct NIP05Identifier: Equatable { let name: String let domain: String diff --git a/whitenoise-mac/Core/WorkspaceState+ChatList.swift b/whitenoise-mac/Core/WorkspaceState+ChatList.swift index 6093d88..a9b7645 100644 --- a/whitenoise-mac/Core/WorkspaceState+ChatList.swift +++ b/whitenoise-mac/Core/WorkspaceState+ChatList.swift @@ -205,16 +205,19 @@ extension WorkspaceState { let previousActiveChatIds = Set((chatsByAccount[account.id] ?? []).map(\.id)) let nextActiveChatIds = Set(activeItems.map(\.id)) - let removedActiveChatIds = previousActiveChatIds.subtracting(nextActiveChatIds) - let previousArchivedChatIds = Set((archivedChatsByAccount[account.id] ?? []).map(\.id)) let nextArchivedChatIds = Set(archivedItems.map(\.id)) - let removedArchivedChatIds = previousArchivedChatIds.subtracting(nextArchivedChatIds) - for groupId in removedActiveChatIds.union(removedArchivedChatIds) { + // A chat that merely moves active↔archived is missing from one "next" list but present in + // the other, so per-list subtraction would wrongly flag it as removed. Only chats gone from + // both lists are truly removed — clearing drafts for a moved chat drops its composer draft. + let removedChatIds = previousActiveChatIds.union(previousArchivedChatIds) + .subtracting(nextActiveChatIds.union(nextArchivedChatIds)) + + for groupId in removedChatIds { invalidateGroupMembers(for: groupId) } - clearComposerDrafts(for: Array(removedActiveChatIds.union(removedArchivedChatIds)), accountId: account.id) + clearComposerDrafts(for: Array(removedChatIds), accountId: account.id) setChats(sortedChatItems(activeItems), forAccountId: account.id) setArchivedChats(sortedChatItems(archivedItems), forAccountId: account.id) dismissGroupImagePickerIfSelectedChatUnavailable() diff --git a/whitenoise-macTests/whitenoise_macTests.swift b/whitenoise-macTests/whitenoise_macTests.swift index eee18d6..7333071 100644 --- a/whitenoise-macTests/whitenoise_macTests.swift +++ b/whitenoise-macTests/whitenoise_macTests.swift @@ -13127,6 +13127,64 @@ struct whitenoise_macTests { #expect(!FileManager.default.fileExists(atPath: url.path)) } + @MainActor + @Test func archivingChatPreservesComposerDraftAcrossSnapshotReload() async throws { + // #466: a chat moved active→archived through the full-snapshot reload (`applyChatRows`) + // must not be treated as removed — its per-conversation composer draft has to survive. + let state = WorkspaceState.preview() + let account = AccountItem.samples[0] + let chatId = "draft-survives-archive" + let sender = "alice1234567890alice1234567890alice1234567890alice1234567890" + + await state.applyChatRows( + [chatListRow(groupIdHex: chatId, title: "Planning", preview: "hi", sender: sender, timelineAt: 1)], + account: account + ) + let activeChat = try #require(state.chatsByAccount[account.id]?.first { $0.id == chatId }) + state.selectChat(activeChat) + state.draftText = "see you at 6" + let draftKey = WorkspaceState.ComposerDraftKey(accountId: account.id, chatId: chatId) + #expect(state.draftTextByConversation[draftKey] == "see you at 6") + + // Same chat, now archived, via a fresh snapshot. + await state.applyChatRows( + [ + ChatListRowFfi( + groupIdHex: chatId, + archived: true, + pendingConfirmation: false, + title: "Planning", + groupName: "", + avatarUrl: nil, + avatar: nil, + lastMessage: ChatListMessagePreviewFfi( + messageIdHex: "preview", + sender: sender, + senderDisplayName: nil, + plaintext: "hi", + contentTokens: emptyMarkdownDocument(), + kind: 9, + timelineAt: 1, + deleted: false + ), + unreadCount: 0, + hasUnread: false, + unreadMentionCount: 0, + unreadMention: false, + firstUnreadMessageIdHex: nil, + lastReadMessageIdHex: nil, + lastReadTimelineAt: nil, + updatedAt: 1, + selfMembership: .member + ) + ], + account: account + ) + + #expect(state.archivedChatsByAccount[account.id]?.contains { $0.id == chatId } == true) + #expect(state.draftTextByConversation[draftKey] == "see you at 6") + } + @MainActor @Test func startingNewChatCreatesAndSelectsConversation() async throws { let account = AccountSummaryFfi( @@ -13265,6 +13323,40 @@ struct whitenoise_macTests { #expect(state.resolvedNewChatRecipient?.pictureURL == "https://example.com/avatar.png") } + @Test func nip05RedirectPolicyRevalidatesRedirectTargets() throws { + // #448: the resolver's session must re-check every redirect hop against the SSRF host + // policy, so a public well-known host cannot 3xx the lookup to a private/loopback/ + // non-https target. + let policy = NIP05RedirectPolicy() + let session = URLSession(configuration: .ephemeral) + defer { session.invalidateAndCancel() } + let task = session.dataTask(with: URL(string: "https://example.com/.well-known/nostr.json")!) + let redirectResponse = HTTPURLResponse( + url: URL(string: "https://example.com")!, statusCode: 302, httpVersion: nil, headerFields: nil + )! + + func followedRequest(to target: String) -> URLRequest? { + var decided: URLRequest? + policy.urlSession( + session, + task: task, + willPerformHTTPRedirection: redirectResponse, + newRequest: URLRequest(url: URL(string: target)!) + ) { decided = $0 } + return decided + } + + // Blocked: loopback, link-local, private, IPv6 loopback, and scheme downgrade. + #expect(followedRequest(to: "https://127.0.0.1:8080/x") == nil) + #expect(followedRequest(to: "https://169.254.169.254/x") == nil) + #expect(followedRequest(to: "https://10.0.0.5/x") == nil) + #expect(followedRequest(to: "https://[::1]/x") == nil) + #expect(followedRequest(to: "http://example.com/x") == nil) + // Allowed: a public https target is followed unchanged. + let allowed = followedRequest(to: "https://relay.example.com/x") + #expect(allowed?.url?.absoluteString == "https://relay.example.com/x") + } + @MainActor @Test func resolvingNewChatRecipientUsesNIP05() async throws { let account = desktopAccount()