Skip to content

Commit 4acee6b

Browse files
committed
fix: preserve ZoomMate Chromium cookie scope
1 parent a550713 commit 4acee6b

5 files changed

Lines changed: 421 additions & 69 deletions

File tree

Sources/CodexBarCore/BrowserCookieAccessGate.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,28 @@ extension BrowserCookieClient {
345345
throw error
346346
}
347347
}
348+
349+
public func codexBarRecords(
350+
matching query: BrowserCookieQuery,
351+
in store: BrowserCookieStore,
352+
logger: ((String) -> Void)? = nil) throws -> [BrowserCookieRecord]
353+
{
354+
guard BrowserCookieAccessGate.cookieStoreAccessDecision(
355+
homeDirectories: self.configuration.homeDirectories) == .allowed
356+
else {
357+
throw BrowserCookieStoreAccessSuppressedError()
358+
}
359+
guard BrowserCookieAccessGate.shouldAttempt(store.browser) else { return [] }
360+
guard BrowserCookieAccessGate.claimExplicitRetryCookieReadIfNeeded(for: store.browser) else { return [] }
361+
do {
362+
let records = try self.records(matching: query, in: store, logger: logger)
363+
BrowserCookieAccessGate.recordAllowed(for: store.browser)
364+
return records
365+
} catch {
366+
BrowserCookieAccessGate.recordIfNeeded(error)
367+
throw error
368+
}
369+
}
348370
}
349371
#else
350372
public enum BrowserCookieAccessGate {
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import Foundation
2+
3+
#if os(macOS)
4+
import SQLite3
5+
import SweetCookieKit
6+
7+
private let zoomMateSQLiteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
8+
9+
enum ZoomMateChromiumCookieScopeReader {
10+
enum CookieScope: Hashable, Sendable {
11+
case domain
12+
case hostOnly
13+
}
14+
15+
struct ScopedCookie: Sendable {
16+
let record: BrowserCookieRecord
17+
let scope: CookieScope
18+
}
19+
20+
struct CookieMetadata: Sendable {
21+
let hostKey: String
22+
let topFrameSiteKey: String
23+
let name: String
24+
let path: String
25+
}
26+
27+
struct Source: Sendable {
28+
let label: String
29+
let cookies: [ScopedCookie]
30+
}
31+
32+
enum ReadError: LocalizedError {
33+
case missingDatabase(label: String)
34+
case sqliteFailed(label: String, details: String)
35+
36+
var errorDescription: String? {
37+
switch self {
38+
case let .missingDatabase(label):
39+
"\(label) has no Chromium cookie database."
40+
case let .sqliteFailed(label, details):
41+
"\(label) cookie scope read failed: \(details)"
42+
}
43+
}
44+
}
45+
46+
private struct CookieKey: Hashable {
47+
let domain: String
48+
let name: String
49+
let path: String
50+
}
51+
52+
private struct ScopeResolution {
53+
var scopes: Set<CookieScope> = []
54+
var hasPartitionedRecord = false
55+
}
56+
57+
private static let rawDomains = [
58+
".zoom.us",
59+
"zoom.us",
60+
".ai.zoom.us",
61+
"ai.zoom.us",
62+
".zoommate.zoom.us",
63+
"zoommate.zoom.us",
64+
]
65+
66+
static func read(
67+
matching query: BrowserCookieQuery,
68+
in browser: Browser,
69+
cookieClient: BrowserCookieClient,
70+
logger: ((String) -> Void)? = nil) throws -> [Source]
71+
{
72+
let stores = try cookieClient.codexBarStores(for: browser)
73+
.filter { $0.databaseURL != nil }
74+
var sources: [Source] = []
75+
76+
for store in stores {
77+
try self.withSnapshot(of: store) { snapshotStore in
78+
let records = try cookieClient.codexBarRecords(
79+
matching: query,
80+
in: snapshotStore,
81+
logger: logger)
82+
guard !records.isEmpty else { return }
83+
84+
let metadata = try self.readMetadata(from: snapshotStore)
85+
let cookies = self.resolve(records: records, metadata: metadata, logger: logger)
86+
guard !cookies.isEmpty else { return }
87+
sources.append(Source(label: store.label, cookies: cookies))
88+
}
89+
}
90+
91+
return sources
92+
}
93+
94+
static func resolve(
95+
records: [BrowserCookieRecord],
96+
metadata: [CookieMetadata],
97+
logger: ((String) -> Void)? = nil) -> [ScopedCookie]
98+
{
99+
var resolutionByKey: [CookieKey: ScopeResolution] = [:]
100+
for item in metadata {
101+
let rawHost = item.hostKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
102+
let domain = self.normalizedDomain(rawHost)
103+
guard !domain.isEmpty else { continue }
104+
105+
let key = CookieKey(domain: domain, name: item.name, path: item.path)
106+
var resolution = resolutionByKey[key] ?? ScopeResolution()
107+
resolution.scopes.insert(rawHost.hasPrefix(".") ? .domain : .hostOnly)
108+
if !item.topFrameSiteKey.isEmpty {
109+
resolution.hasPartitionedRecord = true
110+
}
111+
resolutionByKey[key] = resolution
112+
}
113+
114+
var missing = 0
115+
var ambiguous = 0
116+
var partitioned = 0
117+
let resolved = records.compactMap { record -> ScopedCookie? in
118+
let key = CookieKey(
119+
domain: self.normalizedDomain(record.domain),
120+
name: record.name,
121+
path: record.path)
122+
guard let resolution = resolutionByKey[key] else {
123+
missing += 1
124+
return nil
125+
}
126+
guard !resolution.hasPartitionedRecord else {
127+
partitioned += 1
128+
return nil
129+
}
130+
guard resolution.scopes.count == 1, let scope = resolution.scopes.first else {
131+
ambiguous += 1
132+
return nil
133+
}
134+
return ScopedCookie(record: record, scope: scope)
135+
}
136+
137+
if missing > 0 || ambiguous > 0 || partitioned > 0 {
138+
logger?(
139+
"Dropped unresolved Chrome cookies " +
140+
"(missing: \(missing), ambiguous scope: \(ambiguous), partitioned: \(partitioned))")
141+
}
142+
return resolved
143+
}
144+
145+
private static func withSnapshot<T>(
146+
of store: BrowserCookieStore,
147+
operation: (BrowserCookieStore) throws -> T) throws -> T
148+
{
149+
guard let sourceDB = store.databaseURL else {
150+
throw ReadError.missingDatabase(label: store.label)
151+
}
152+
153+
let directory = FileManager.default.temporaryDirectory
154+
.appendingPathComponent("codexbar-zoommate-cookies-\(UUID().uuidString)", isDirectory: true)
155+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
156+
defer { try? FileManager.default.removeItem(at: directory) }
157+
158+
let copiedDB = directory.appendingPathComponent("Cookies")
159+
try FileManager.default.copyItem(at: sourceDB, to: copiedDB)
160+
for suffix in ["-wal", "-shm"] {
161+
let source = URL(fileURLWithPath: sourceDB.path + suffix)
162+
guard FileManager.default.fileExists(atPath: source.path) else { continue }
163+
try? FileManager.default.copyItem(
164+
at: source,
165+
to: URL(fileURLWithPath: copiedDB.path + suffix))
166+
}
167+
168+
let snapshotStore = BrowserCookieStore(
169+
browser: store.browser,
170+
profile: store.profile,
171+
kind: store.kind,
172+
label: store.label,
173+
databaseURL: copiedDB)
174+
return try operation(snapshotStore)
175+
}
176+
177+
private static func readMetadata(from store: BrowserCookieStore) throws -> [CookieMetadata] {
178+
guard let databaseURL = store.databaseURL else {
179+
throw ReadError.missingDatabase(label: store.label)
180+
}
181+
182+
var database: OpaquePointer?
183+
guard sqlite3_open_v2(databaseURL.path, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else {
184+
throw ReadError.sqliteFailed(label: store.label, details: self.sqliteMessage(database))
185+
}
186+
defer { sqlite3_close(database) }
187+
188+
let placeholders = Array(repeating: "?", count: self.rawDomains.count).joined(separator: ", ")
189+
let sql = """
190+
SELECT host_key, top_frame_site_key, name, path
191+
FROM cookies
192+
WHERE lower(host_key) IN (\(placeholders))
193+
"""
194+
var statement: OpaquePointer?
195+
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
196+
throw ReadError.sqliteFailed(label: store.label, details: self.sqliteMessage(database))
197+
}
198+
defer { sqlite3_finalize(statement) }
199+
200+
for (index, domain) in self.rawDomains.enumerated() {
201+
sqlite3_bind_text(statement, Int32(index + 1), domain, -1, zoomMateSQLiteTransient)
202+
}
203+
204+
var metadata: [CookieMetadata] = []
205+
var stepResult = sqlite3_step(statement)
206+
while stepResult == SQLITE_ROW {
207+
guard let hostKey = self.text(statement, column: 0),
208+
let topFrameSiteKey = self.text(statement, column: 1),
209+
let name = self.text(statement, column: 2),
210+
let path = self.text(statement, column: 3)
211+
else {
212+
stepResult = sqlite3_step(statement)
213+
continue
214+
}
215+
metadata.append(CookieMetadata(
216+
hostKey: hostKey,
217+
topFrameSiteKey: topFrameSiteKey,
218+
name: name,
219+
path: path))
220+
stepResult = sqlite3_step(statement)
221+
}
222+
guard stepResult == SQLITE_DONE else {
223+
throw ReadError.sqliteFailed(label: store.label, details: self.sqliteMessage(database))
224+
}
225+
return metadata
226+
}
227+
228+
private static func normalizedDomain(_ raw: String) -> String {
229+
let lowered = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
230+
return lowered.hasPrefix(".") ? String(lowered.dropFirst()) : lowered
231+
}
232+
233+
private static func text(_ statement: OpaquePointer?, column: Int32) -> String? {
234+
guard sqlite3_column_type(statement, column) != SQLITE_NULL,
235+
let value = sqlite3_column_text(statement, column)
236+
else {
237+
return nil
238+
}
239+
return String(cString: value)
240+
}
241+
242+
private static func sqliteMessage(_ database: OpaquePointer?) -> String {
243+
guard let database else { return "Unknown SQLite error." }
244+
return String(cString: sqlite3_errmsg(database))
245+
}
246+
}
247+
#endif

Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,9 @@ private let zoomMateCookieImportOrder: BrowserCookieImportOrder =
5252
/// ZoomMate's own cookie-to-token bootstrap endpoint). Modeled on `T3ChatCookieImporter`.
5353
public enum ZoomMateCookieImporter {
5454
private static let cookieClient = BrowserCookieClient()
55-
/// SweetCookieKit normalizes Chromium's `.zoom.us` domain cookies to `zoom.us` before
56-
/// constructing `HTTPCookie`. Treat that known parent SSO domain as shared; all leaf domains
57-
/// remain exact-host only below.
58-
private static let sharedParentCookieDomain = "zoom.us"
59-
/// Includes the parent "zoom.us" domain — ZoomMate's SSO session cookies (`_zm_*`,
60-
/// `cf_clearance`, etc.) are scoped to the shared parent domain, not the leaf subdomains, and
61-
/// domain matching here is substring-based (`.contains`), so this one pattern also matches the
62-
/// leaf domains below; both are kept for clarity. The over-broad `.contains("zoom.us")` read is
63-
/// then narrowed at send time by `isSendable(toSessionHosts:)`.
55+
/// Includes the parent "zoom.us" domain because ZoomMate's SSO session uses both parent-domain
56+
/// and leaf-host cookies. The reader recovers Chromium's host-only metadata before these
57+
/// candidates are narrowed to the two fixed request hosts.
6458
private static let cookieDomains = ["zoommate.zoom.us", "ai.zoom.us", "zoom.us"]
6559

6660
public struct SessionInfo: Sendable {
@@ -91,13 +85,13 @@ public enum ZoomMateCookieImporter {
9185
for browserSource in installed {
9286
do {
9387
let query = BrowserCookieQuery(domains: self.cookieDomains)
94-
let sources = try self.cookieClient.codexBarRecords(
88+
let sources = try ZoomMateChromiumCookieScopeReader.read(
9589
matching: query,
9690
in: browserSource,
91+
cookieClient: self.cookieClient,
9792
logger: log)
98-
for source in sources where !source.records.isEmpty {
99-
let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
100-
let cookieHeaders = Self.cookieHeaders(from: cookies)
93+
for source in sources where !source.cookies.isEmpty {
94+
let cookieHeaders = Self.cookieHeaders(from: source.cookies)
10195
guard !cookieHeaders.isEmpty else { continue }
10296
log("\(source.label): found host-scoped cookie headers")
10397
sessions.append(SessionInfo(cookieHeaders: cookieHeaders, sourceLabel: source.label))
@@ -112,31 +106,42 @@ public enum ZoomMateCookieImporter {
112106
return sessions
113107
}
114108

115-
/// Whether a browser would attach a cookie scoped to `cookieDomain` to a request to `host`, per
116-
/// Leaf-host cookies stay exact-host only. SweetCookieKit has already normalized the leading
117-
/// dot from Chromium's parent `.zoom.us` SSO cookies, so that one known shared domain must be
118-
/// handled before the exact-host check; otherwise every browser-imported parent cookie is
119-
/// discarded and no ZoomMate session can be created.
120-
static func isSendable(cookieDomain: String, toHost host: String) -> Bool {
109+
/// Whether a browser would attach a cookie to `host`, using Chromium's recovered host-only flag
110+
/// and RFC 6265 domain matching. Only root-path cookies are retained because the cached header
111+
/// is reused across ZoomMate's login, status, and history routes.
112+
static func isSendable(
113+
cookieDomain: String,
114+
hostOnly: Bool,
115+
path: String,
116+
toHost host: String) -> Bool
117+
{
121118
let normalizedDomain = cookieDomain.lowercased()
122119
let normalizedHost = host.lowercased()
123-
guard ZoomMateCookieHeaders.allowedHosts.contains(normalizedHost), !normalizedDomain.isEmpty else {
120+
guard ZoomMateCookieHeaders.allowedHosts.contains(normalizedHost),
121+
!normalizedDomain.isEmpty,
122+
path == "/"
123+
else {
124124
return false
125125
}
126-
if normalizedDomain == Self.sharedParentCookieDomain {
127-
return normalizedHost.hasSuffix("." + normalizedDomain)
126+
if hostOnly {
127+
return normalizedHost == normalizedDomain
128128
}
129-
guard normalizedDomain.hasPrefix(".") else { return normalizedHost == normalizedDomain }
130-
let bareDomain = String(normalizedDomain.dropFirst())
131-
guard !bareDomain.isEmpty else { return false }
132-
return normalizedHost == bareDomain || normalizedHost.hasSuffix("." + bareDomain)
129+
return normalizedHost == normalizedDomain || normalizedHost.hasSuffix("." + normalizedDomain)
133130
}
134131

135-
static func cookieHeaders(from cookies: [HTTPCookie]) -> ZoomMateCookieHeaders {
132+
static func cookieHeaders(
133+
from cookies: [ZoomMateChromiumCookieScopeReader.ScopedCookie]) -> ZoomMateCookieHeaders
134+
{
136135
let pairs: [(String, String)] = ZoomMateCookieHeaders.allowedHosts.compactMap { host in
137-
let sendable = cookies.filter { Self.isSendable(cookieDomain: $0.domain, toHost: host) }
136+
let sendable = cookies.filter {
137+
Self.isSendable(
138+
cookieDomain: $0.record.domain,
139+
hostOnly: $0.scope == .hostOnly,
140+
path: $0.record.path,
141+
toHost: host)
142+
}
138143
guard !sendable.isEmpty else { return nil }
139-
let header = sendable.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
144+
let header = sendable.map { "\($0.record.name)=\($0.record.value)" }.joined(separator: "; ")
140145
return (host, header)
141146
}
142147
return ZoomMateCookieHeaders(headersByHost: Dictionary(uniqueKeysWithValues: pairs))

0 commit comments

Comments
 (0)