Skip to content

Commit f745fc3

Browse files
committed
fix: scope ZoomMate cookies by host
1 parent 979fd6c commit f745fc3

7 files changed

Lines changed: 334 additions & 103 deletions

Sources/CodexBarCore/Providers/ZoomMate/ZoomMateBearerTokenCache.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import Crypto
1212
/// lets a still-valid token be reused across refreshes instead.
1313
///
1414
/// Safety properties (why reuse can't serve a bad token):
15-
/// - Entries are keyed by a non-reversible SHA-256 of the originating cookie header, so distinct
15+
/// - Entries are keyed by a non-reversible SHA-256 of the originating host-scoped cookie headers, so distinct
1616
/// browser sessions / accounts never collide and the raw cookies are never stored as a key.
1717
/// - A token is cached *only* when its JWT carries a decodable `exp` claim, and is served only
1818
/// while `now < exp - refreshSkew`. A token whose expiry cannot be determined is never cached
@@ -36,9 +36,10 @@ actor ZoomMateBearerTokenCache {
3636

3737
private var entries: [String: Entry] = [:]
3838

39-
/// Non-reversible cache key for a cookie session. SHA-256 hex of the raw cookie header.
40-
static func key(forCookieHeader cookieHeader: String) -> String {
41-
let digest = SHA256.hash(data: Data(cookieHeader.utf8))
39+
/// Non-reversible cache key for a cookie session. SHA-256 hex of its canonical host map.
40+
static func key(forCookieHeaders cookieHeaders: ZoomMateCookieHeaders) -> String {
41+
let canonical = cookieHeaders.encodedForStorage() ?? ""
42+
let digest = SHA256.hash(data: Data(canonical.utf8))
4243
return digest.map { String(format: "%02x", $0) }.joined()
4344
}
4445

Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift

Lines changed: 70 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,46 @@ import Foundation
33
import SweetCookieKit
44
#endif
55

6+
/// Cookie headers narrowed to ZoomMate's fixed request hosts. Keeping the destination in the
7+
/// credential value makes it impossible for host failover to reuse a leaf-host cookie on its
8+
/// sibling host.
9+
public struct ZoomMateCookieHeaders: Codable, Equatable, Sendable {
10+
static let allowedHosts = ["ai.zoom.us", "zoommate.zoom.us"]
11+
12+
private let headersByHost: [String: String]
13+
14+
public init(headersByHost: [String: String]) {
15+
self.headersByHost = Dictionary(uniqueKeysWithValues: Self.allowedHosts.compactMap { host in
16+
guard let header = headersByHost[host]?.trimmingCharacters(in: .whitespacesAndNewlines),
17+
!header.isEmpty
18+
else {
19+
return nil
20+
}
21+
return (host, header)
22+
})
23+
}
24+
25+
public func header(forHost host: String) -> String? {
26+
self.headersByHost[host.lowercased()]
27+
}
28+
29+
public var isEmpty: Bool {
30+
self.headersByHost.isEmpty
31+
}
32+
33+
func encodedForStorage() -> String? {
34+
let encoder = JSONEncoder()
35+
encoder.outputFormatting = [.sortedKeys]
36+
guard let data = try? encoder.encode(self) else { return nil }
37+
return String(data: data, encoding: .utf8)
38+
}
39+
40+
static func decodeFromStorage(_ value: String) -> Self? {
41+
guard let data = value.data(using: .utf8) else { return nil }
42+
return try? JSONDecoder().decode(Self.self, from: data)
43+
}
44+
}
45+
646
#if os(macOS)
747
private let zoomMateCookieImportOrder: BrowserCookieImportOrder =
848
ProviderDefaults.metadata[.zoommate]?.browserCookieOrder ?? Browser.defaultImportOrder
@@ -19,17 +59,12 @@ public enum ZoomMateCookieImporter {
1959
/// then narrowed at send time by `isSendable(toSessionHosts:)`.
2060
private static let cookieDomains = ["zoommate.zoom.us", "ai.zoom.us", "zoom.us"]
2161

22-
/// Hosts whose cookies the fetchers actually transmit (the login-bootstrap and credits calls
23-
/// hit `ai.zoom.us`; the browser session lives on both). Used to drop cookies that a browser
24-
/// would never attach to these requests — see `isSendable(cookieDomain:)`.
25-
private static let sessionHosts = ["ai.zoom.us", "zoommate.zoom.us"]
26-
2762
public struct SessionInfo: Sendable {
28-
public let cookieHeader: String
63+
public let cookieHeaders: ZoomMateCookieHeaders
2964
public let sourceLabel: String
3065

31-
public init(cookieHeader: String, sourceLabel: String) {
32-
self.cookieHeader = cookieHeader
66+
public init(cookieHeaders: ZoomMateCookieHeaders, sourceLabel: String) {
67+
self.cookieHeaders = cookieHeaders
3368
self.sourceLabel = sourceLabel
3469
}
3570
}
@@ -58,11 +93,10 @@ public enum ZoomMateCookieImporter {
5893
logger: log)
5994
for source in sources where !source.records.isEmpty {
6095
let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
61-
.filter { Self.isSendable(cookieDomain: $0.domain) }
62-
guard !cookies.isEmpty else { continue }
63-
log("\(source.label): found \(cookies.count) matching cookies")
64-
let header = cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
65-
sessions.append(SessionInfo(cookieHeader: header, sourceLabel: source.label))
96+
let cookieHeaders = Self.cookieHeaders(from: cookies)
97+
guard !cookieHeaders.isEmpty else { continue }
98+
log("\(source.label): found host-scoped cookie headers")
99+
sessions.append(SessionInfo(cookieHeaders: cookieHeaders, sourceLabel: source.label))
66100
}
67101
} catch {
68102
BrowserCookieAccessGate.recordIfNeeded(error)
@@ -74,19 +108,31 @@ public enum ZoomMateCookieImporter {
74108
return sessions
75109
}
76110

77-
/// Whether a browser would attach a cookie scoped to `cookieDomain` to a request to one of
78-
/// `sessionHosts`, per RFC 6265 domain-matching: a host-only cookie matches its exact host; a
111+
/// Whether a browser would attach a cookie scoped to `cookieDomain` to a request to `host`, per
112+
/// RFC 6265 domain-matching: a host-only cookie matches its exact host; a
79113
/// domain cookie (stored with a leading dot) matches that host and all of its subdomains. This
80-
/// keeps the parent `.zoom.us` SSO cookies the endpoints need while dropping cookies host-scoped
81-
/// to unrelated `*.zoom.us` siblings (marketing/support/web) swept in by the coarse `.contains`
82-
/// domain read above — cookies those endpoints would never receive.
83-
static func isSendable(cookieDomain: String) -> Bool {
84-
let bare = cookieDomain.hasPrefix(".") ? String(cookieDomain.dropFirst()) : cookieDomain
85-
let normalized = bare.lowercased()
86-
guard !normalized.isEmpty else { return false }
87-
return self.sessionHosts.contains { host in
88-
host == normalized || host.hasSuffix("." + normalized)
114+
/// keeps parent `.zoom.us` SSO cookies while preventing an `ai.zoom.us` host-only cookie from
115+
/// reaching `zoommate.zoom.us` (and vice versa).
116+
static func isSendable(cookieDomain: String, toHost host: String) -> Bool {
117+
let normalizedDomain = cookieDomain.lowercased()
118+
let normalizedHost = host.lowercased()
119+
guard ZoomMateCookieHeaders.allowedHosts.contains(normalizedHost), !normalizedDomain.isEmpty else {
120+
return false
121+
}
122+
guard normalizedDomain.hasPrefix(".") else { return normalizedHost == normalizedDomain }
123+
let bareDomain = String(normalizedDomain.dropFirst())
124+
guard !bareDomain.isEmpty else { return false }
125+
return normalizedHost == bareDomain || normalizedHost.hasSuffix("." + bareDomain)
126+
}
127+
128+
static func cookieHeaders(from cookies: [HTTPCookie]) -> ZoomMateCookieHeaders {
129+
let pairs: [(String, String)] = ZoomMateCookieHeaders.allowedHosts.compactMap { host in
130+
let sendable = cookies.filter { Self.isSendable(cookieDomain: $0.domain, toHost: host) }
131+
guard !sendable.isEmpty else { return nil }
132+
let header = sendable.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
133+
return (host, header)
89134
}
135+
return ZoomMateCookieHeaders(headersByHost: Dictionary(uniqueKeysWithValues: pairs))
90136
}
91137
}
92138
#endif

Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ public struct ZoomMateCreditsHistoryFetcher: Sendable {
110110
{
111111
// The whole pagination loop fails over as a unit so all pages of one snapshot come from
112112
// the same host.
113-
try await ZoomMateUsageFetcher.withAPIHostFailover { host in
113+
try await ZoomMateUsageFetcher.withAPIHostFailover(
114+
hosts: ZoomMateUsageFetcher.hosts(preferred: context.preferredHost))
115+
{ host in
114116
var allRecords: [ZoomMateCreditHistoryRecord] = []
115117
var page = 0
116118
var total = Int.max
@@ -184,6 +186,9 @@ public struct ZoomMateCreditsHistoryFetcher: Sendable {
184186
for (name, value) in pageRequest.context.headers {
185187
request.setValue(value, forHTTPHeaderField: name)
186188
}
189+
request.setValue(
190+
pageRequest.context.cookieHeaders.header(forHost: pageRequest.host),
191+
forHTTPHeaderField: "Cookie")
187192
request.setValue(pageRequest.context.authorization, forHTTPHeaderField: "Authorization")
188193
request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Origin")
189194
request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer")
@@ -217,7 +222,9 @@ public struct ZoomMateCreditsHistoryFetcher: Sendable {
217222
private static func parseRecordTime(_ text: String) -> Date? {
218223
let withFractional = ISO8601DateFormatter()
219224
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
220-
if let date = withFractional.date(from: text) { return date }
225+
if let date = withFractional.date(from: text) {
226+
return date
227+
}
221228
let plain = ISO8601DateFormatter()
222229
plain.formatOptions = [.withInternetDateTime]
223230
return plain.date(from: text)

0 commit comments

Comments
 (0)