Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion whitenoise-mac/Core/NIP05Resolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions whitenoise-mac/Core/WorkspaceState+ChatList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
92 changes: 92 additions & 0 deletions whitenoise-macTests/whitenoise_macTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
Loading