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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the widget SwiftPM lockfile too

Because this manifest now requires SweetCookieKit 0.5.2, the separate Xcode widget workspace lockfile must move off 0.5.1 as well. Scripts/package_app.sh builds WidgetExtension/CodexBarWidgetExtension.xcodeproj with -skipPackageUpdates and -disableAutomaticPackageResolution, while that workspace's Package.resolved still pins SweetCookieKit 0.5.1, so a release/package build of the widget cannot resolve the local CodexBarCore package (or will compile against an API that lacks BrowserCookieScope). Please update WidgetExtension/.../Package.resolved with the same 0.5.2 pin.

Useful? React with 👍 / 👎.


let sqlite3LibDir = ProcessInfo.processInfo.environment["CODEXBAR_SQLITE3_LIB_DIR"]?
.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions Tests/CodexBarTests/Fixtures/ZoomMate/issue-2507-cookie-scope.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
86 changes: 59 additions & 27 deletions Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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

Expand Down