diff --git a/CHANGELOG.md b/CHANGELOG.md index 021d063534..7e2f2a8cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Menu: move each usage window's used percentage and reset time into its title row, with all pace detail on one line (#2182). Thanks @jack24254029! ### Fixed +- ZoomMate: preserve browser cookie scope so parent-domain sessions reach both API hosts without leaking host-only cookies (fixes #2507). Thanks @weddle! - Sync: propagate provider configuration edits made by the CLI or directly in `config.json` to the iCloud fleet without echoing remotely applied writes. ## 0.47.0 — 2026-08-03 diff --git a/Package.resolved b/Package.resolved index 2b294822f2..86032ac98b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2e8a73bbec33ba73063dc95b68b75cec0a6766aa9f1d5f83925f95e09e47a55e", + "originHash" : "ad67f0d4398bb4a21360148bef34796420ed4bd49ea9337ed53c6dc99e5a96bd", "pins" : [ { "identity" : "commander", @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/SweetCookieKit", "state" : { - "revision" : "228c7927e03b85b25b41da23a44c4f5308519796", - "version" : "0.5.1" + "revision" : "d5ea6d92298779ec0c3ddf7d3d99da186a305e14", + "version" : "0.5.2" } }, { diff --git a/Package.swift b/Package.swift index abad887799..811c9002a2 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let useLocalSweetCookieKit = let sweetCookieKitDependency: Package.Dependency = useLocalSweetCookieKit && FileManager.default.fileExists(atPath: sweetCookieKitPath) ? .package(path: sweetCookieKitPath) - : .package(url: "https://github.com/steipete/SweetCookieKit", from: "0.5.1") + : .package(url: "https://github.com/steipete/SweetCookieKit", from: "0.5.2") let sqlite3LibDir = ProcessInfo.processInfo.environment["CODEXBAR_SQLITE3_LIB_DIR"]? .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift index 37dc42be10..d00482dc56 100644 --- a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift @@ -55,8 +55,8 @@ public enum ZoomMateCookieImporter { /// Includes the parent "zoom.us" domain — ZoomMate's SSO session cookies (`_zm_*`, /// `cf_clearance`, etc.) are scoped to the shared parent domain, not the leaf subdomains, and /// domain matching here is substring-based (`.contains`), so this one pattern also matches the - /// leaf domains below; both are kept for clarity. The over-broad `.contains("zoom.us")` read is - /// then narrowed at send time by `isSendable(toSessionHosts:)`. + /// leaf domains below; both are kept for clarity. The broad read is narrowed per destination + /// using each record's explicit browser scope. private static let cookieDomains = ["zoommate.zoom.us", "ai.zoom.us", "zoom.us"] public struct SessionInfo: Sendable { @@ -92,8 +92,7 @@ public enum ZoomMateCookieImporter { in: browserSource, logger: log) for source in sources where !source.records.isEmpty { - let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) - let cookieHeaders = Self.cookieHeaders(from: cookies) + let cookieHeaders = Self.cookieHeaders(from: source.records) guard !cookieHeaders.isEmpty else { continue } log("\(source.label): found host-scoped cookie headers") sessions.append(SessionInfo(cookieHeaders: cookieHeaders, sourceLabel: source.label)) @@ -108,26 +107,31 @@ public enum ZoomMateCookieImporter { return sessions } - /// Whether a browser would attach a cookie scoped to `cookieDomain` to a request to `host`, per - /// RFC 6265 domain-matching: a host-only cookie matches its exact host; a - /// domain cookie (stored with a leading dot) matches that host and all of its subdomains. This - /// keeps parent `.zoom.us` SSO cookies while preventing an `ai.zoom.us` host-only cookie from - /// reaching `zoommate.zoom.us` (and vice versa). - static func isSendable(cookieDomain: String, toHost host: String) -> Bool { - let normalizedDomain = cookieDomain.lowercased() + /// Whether a browser would attach a cookie to `host`, per RFC 6265 domain-matching. Scope is + /// carried separately because Chromium normalizes `.zoom.us` and `zoom.us` to the same domain + /// string when records become `HTTPCookie` values. + static func isSendable(cookieDomain: String, scope: BrowserCookieScope, toHost host: String) -> Bool { + let normalizedDomain = cookieDomain + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingPrefix(".") + .lowercased() let normalizedHost = host.lowercased() guard ZoomMateCookieHeaders.allowedHosts.contains(normalizedHost), !normalizedDomain.isEmpty else { return false } - guard normalizedDomain.hasPrefix(".") else { return normalizedHost == normalizedDomain } - let bareDomain = String(normalizedDomain.dropFirst()) - guard !bareDomain.isEmpty else { return false } - return normalizedHost == bareDomain || normalizedHost.hasSuffix("." + bareDomain) + switch scope { + case .hostOnly: + return normalizedHost == normalizedDomain + case .domain: + return normalizedHost == normalizedDomain || normalizedHost.hasSuffix("." + normalizedDomain) + } } - static func cookieHeaders(from cookies: [HTTPCookie]) -> ZoomMateCookieHeaders { + static func cookieHeaders(from records: [BrowserCookieRecord]) -> ZoomMateCookieHeaders { let pairs: [(String, String)] = ZoomMateCookieHeaders.allowedHosts.compactMap { host in - let sendable = cookies.filter { Self.isSendable(cookieDomain: $0.domain, toHost: host) } + let sendable = records.filter { + Self.isSendable(cookieDomain: $0.domain, scope: $0.scope, toHost: host) + } guard !sendable.isEmpty else { return nil } let header = sendable.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") return (host, header) diff --git a/Tests/CodexBarTests/Fixtures/ZoomMate/issue-2507-cookie-scope.json b/Tests/CodexBarTests/Fixtures/ZoomMate/issue-2507-cookie-scope.json new file mode 100644 index 0000000000..ea4f1002c6 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/ZoomMate/issue-2507-cookie-scope.json @@ -0,0 +1,39 @@ +{ + "records": [ + { + "sourceDomain": ".zoom.us", + "domain": "zoom.us", + "scope": "domain", + "name": "parent", + "value": "fake" + }, + { + "sourceDomain": "zoom.us", + "domain": "zoom.us", + "scope": "hostOnly", + "name": "parent-host-only", + "value": "fake" + }, + { + "sourceDomain": "ai.zoom.us", + "domain": "ai.zoom.us", + "scope": "hostOnly", + "name": "ai-only", + "value": "fake" + }, + { + "sourceDomain": "zoommate.zoom.us", + "domain": "zoommate.zoom.us", + "scope": "hostOnly", + "name": "mate-only", + "value": "fake" + }, + { + "sourceDomain": "marketing.zoom.us", + "domain": "marketing.zoom.us", + "scope": "hostOnly", + "name": "marketing-only", + "value": "fake" + } + ] +} diff --git a/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift b/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift index 0946707f2c..a76385898b 100644 --- a/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift +++ b/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift @@ -1,6 +1,9 @@ import Foundation import Testing @testable import CodexBarCore +#if os(macOS) +import SweetCookieKit +#endif struct ZoomMateUsageFetcherTests { private final class MessageRecorder: @unchecked Sendable { @@ -821,39 +824,68 @@ struct ZoomMateUsageFetcherTests { #if os(macOS) @Test - func `automatic import partitions parent and host-only cookies per destination`() throws { - func cookie(domain: String, name: String) throws -> HTTPCookie { - try #require(HTTPCookie(properties: [ - .domain: domain, - .path: "/", - .name: name, - .value: "fake", - .secure: "TRUE", - ])) - } - - let headers = try ZoomMateCookieImporter.cookieHeaders(from: [ - cookie(domain: ".zoom.us", name: "parent"), - cookie(domain: "zoom.us", name: "parent-host-only"), - cookie(domain: "ai.zoom.us", name: "ai-only"), - cookie(domain: "zoommate.zoom.us", name: "mate-only"), - cookie(domain: "marketing.zoom.us", name: "marketing-only"), - ]) + func `issue 2507 fixture routes parent domain cookie to both hosts without leaking host-only cookies`() throws { + let records = try Self.issue2507CookieRecords() + let headers = ZoomMateCookieImporter.cookieHeaders(from: records) #expect(headers.header(forHost: "ai.zoom.us") == "parent=fake; ai-only=fake") #expect(headers.header(forHost: "zoommate.zoom.us") == "parent=fake; mate-only=fake") } @Test - func `cookie scope filter follows RFC 6265 host-only and domain matching`() { - #expect(ZoomMateCookieImporter.isSendable(cookieDomain: "ai.zoom.us", toHost: "ai.zoom.us")) - #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "ai.zoom.us", toHost: "zoommate.zoom.us")) - #expect(ZoomMateCookieImporter.isSendable(cookieDomain: ".zoom.us", toHost: "ai.zoom.us")) - #expect(ZoomMateCookieImporter.isSendable(cookieDomain: ".zoom.us", toHost: "zoommate.zoom.us")) - #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "zoom.us", toHost: "ai.zoom.us")) - #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "marketing.zoom.us", toHost: "ai.zoom.us")) - #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "zoom.us.attacker.com", toHost: "ai.zoom.us")) - #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "", toHost: "ai.zoom.us")) + func `cookie scope filter follows explicit RFC 6265 scope`() { + #expect(ZoomMateCookieImporter.isSendable( + cookieDomain: "ai.zoom.us", scope: .hostOnly, toHost: "ai.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable( + cookieDomain: "ai.zoom.us", scope: .hostOnly, toHost: "zoommate.zoom.us")) + #expect(ZoomMateCookieImporter.isSendable( + cookieDomain: "zoom.us", scope: .domain, toHost: "ai.zoom.us")) + #expect(ZoomMateCookieImporter.isSendable( + cookieDomain: "zoom.us", scope: .domain, toHost: "zoommate.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable( + cookieDomain: "zoom.us", scope: .hostOnly, toHost: "ai.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable( + cookieDomain: "marketing.zoom.us", scope: .hostOnly, toHost: "ai.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable( + cookieDomain: "zoom.us.attacker.com", scope: .domain, toHost: "ai.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "", scope: .domain, toHost: "ai.zoom.us")) + } + + private struct CookieScopeFixture: Decodable { + let records: [Record] + + struct Record: Decodable { + let sourceDomain: String + let domain: String + let scope: String + let name: String + let value: String + } + } + + private static func issue2507CookieRecords() throws -> [BrowserCookieRecord] { + let url = try #require(Bundle.module.url( + forResource: "issue-2507-cookie-scope", + withExtension: "json", + subdirectory: "Fixtures/ZoomMate")) + let fixture = try JSONDecoder().decode(CookieScopeFixture.self, from: Data(contentsOf: url)) + return try fixture.records.map { record in + let scope: BrowserCookieScope = switch record.scope { + case "domain": .domain + case "hostOnly": .hostOnly + default: throw ZoomMateUsageError.parseFailed("Unknown cookie fixture scope: \(record.scope)") + } + #expect(record.sourceDomain.trimmingPrefix(".") == record.domain) + return BrowserCookieRecord( + domain: record.domain, + name: record.name, + path: "/", + value: record.value, + expires: nil, + isSecure: true, + isHTTPOnly: true, + scope: scope) + } } #endif