1/**
2 * Safari cookie extraction.
3 *
4 * Safari does not encrypt its cookies; it stores them in a proprietary
5 * `Cookies.binarycookies` file inside its app container. The protection is
6 * TCC, not cryptography — the file lives under a path only apps with Full Disk
7 * Access may read, so the gate is a permission the user grants in System
8 * Settings rather than a key to obtain.
9 *
10 * The format, big-endian throughout except the page bodies:
11 *
12 * magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
13 * u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
14 * then each cookie:
15 * u32le size, u32le unknown, u32le flags, u32le unknown,
16 * u32le urlOffset, nameOffset, pathOffset, valueOffset,
17 * u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
18 * strings at the offsets above (relative to the cookie start).
19 *
20 * @module SafariCookies
21 */
22import * as Effect from "effect/Effect";
23import * as FileSystem from "effect/FileSystem";
24import * as PlatformError from "effect/PlatformError";
25import * as Schema from "effect/Schema";
26
27import { cookieScope, type ImportedCookie } from "./CookieDatabase.ts";
28
29/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
30const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
31
32/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
33const COOKIE_PAGE_HEADER_SIZE = 12;
34/** Through the `f64 creation` field; string bytes follow. */
35const COOKIE_RECORD_HEADER_SIZE = 56;
36
37const FLAG_SECURE = 0x1;
38const FLAG_HTTP_ONLY = 0x4;
39
40export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
41export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
42
43export class SafariCookieReadError extends Schema.TaggedError<SafariCookieReadError>()(
44 "SafariCookieReadError",
45 {
46 reason: SafariCookieReadFailure,
47 /**
48 * Which jar the read was for. The parser raises this before a path is in
49 * hand, so it is optional rather than required.
50 */
51 cookieDatabasePath: Schema.optional(Schema.String),
52 /** Kept for the log; never surfaced to the user. */
53 cause: Schema.optional(Schema.Defect()),
54 },
55) {
56 override get message(): string {
57 return this.cookieDatabasePath === undefined
58 ? `Could not read Safari cookies: ${this.reason}.`
59 : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
60 }
61}
62
63const isSafariCookieReadError = Schema.is(SafariCookieReadError);
64
65/** Reads a NUL-terminated ASCII string at an offset. */
66function readCString(buffer: Buffer, start: number): string {
67 const end = buffer.indexOf(0, start);
68 return buffer.toString("utf8", start, end === -1 ? buffer.length : end);
69}
70
71export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie> {
72 if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") {
73 throw new SafariCookieReadError({ reason: "readFailed" });
74 }
75
76 const pageCount = buffer.readUInt32BE(4);
77 // Every declared structure is bounds-checked against what the file actually
78 // contains, and a mismatch fails the read. `Buffer.subarray` clamps silently,
79 // so accepting a short page or an overlong record would return a cookie set
80 // that is quietly missing entries or carrying fields read out of the next
81 // record — a partial import the user has no way to notice.
82 if (8 + pageCount * 4 > buffer.length) {
83 throw new SafariCookieReadError({ reason: "readFailed" });
84 }
85 const pageSizes: number[] = [];
86 for (let index = 0; index < pageCount; index += 1) {
87 pageSizes.push(buffer.readUInt32BE(8 + index * 4));
88 }
89
90 const cookies: ImportedCookie[] = [];
91 let pageStart = 8 + pageCount * 4;
92
93 for (const pageSize of pageSizes) {
94 if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) {
95 throw new SafariCookieReadError({ reason: "readFailed" });
96 }
97 const page = buffer.subarray(pageStart, pageStart + pageSize);
98 pageStart += pageSize;
99
100 // Page bodies switch to little-endian after the big-endian header.
101 const cookieCount = page.readUInt32LE(4);
102 const offsetTableEnd = COOKIE_PAGE_HEADER_SIZE + cookieCount * 4;
103 if (offsetTableEnd > page.length) {
104 throw new SafariCookieReadError({ reason: "readFailed" });
105 }
106 // Every record accepted so far, so a later offset cannot point back into
107 // one of them: the page header, the offset table, and earlier records are
108 // all bytes that would otherwise parse as a fabricated cookie.
109 const accepted: Array<readonly [start: number, end: number]> = [];
110 for (let index = 0; index < cookieCount; index += 1) {
111 const cookieStart = page.readUInt32LE(8 + index * 4);
112 if (cookieStart < offsetTableEnd || cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) {
113 throw new SafariCookieReadError({ reason: "readFailed" });
114 }
115 // Bounded by the record's own length so a string offset cannot run past
116 // it into the following record's bytes.
117 const recordSize = page.readUInt32LE(cookieStart);
118 const cookieEnd = cookieStart + recordSize;
119 if (
120 recordSize < COOKIE_RECORD_HEADER_SIZE ||
121 cookieEnd > page.length ||
122 accepted.some(([start, end]) => cookieStart < end && cookieEnd > start)
123 ) {
124 throw new SafariCookieReadError({ reason: "readFailed" });
125 }
126 accepted.push([cookieStart, cookieEnd]);
127 const cookie = page.subarray(cookieStart, cookieEnd);
128
129 const flags = cookie.readUInt32LE(8);
130 const urlOffset = cookie.readUInt32LE(16);
131 const nameOffset = cookie.readUInt32LE(20);
132 const pathOffset = cookie.readUInt32LE(24);
133 const valueOffset = cookie.readUInt32LE(28);
134 const expiry = cookie.readDoubleLE(40);
135
136 // Offsets are relative to the record; one pointing outside it would
137 // otherwise read a neighbouring cookie's bytes as this one's value.
138 if (
139 [urlOffset, nameOffset, pathOffset, valueOffset].some(
140 (offset) => offset < COOKIE_RECORD_HEADER_SIZE || offset >= cookie.length,
141 )
142 ) {
143 throw new SafariCookieReadError({ reason: "readFailed" });
144 }
145 const domain = readCString(cookie, urlOffset);
146 const name = readCString(cookie, nameOffset);
147 const path = readCString(cookie, pathOffset);
148 const value = readCString(cookie, valueOffset);
149 if (domain === "" || name === "") continue;
150
151 const secure = (flags & FLAG_SECURE) !== 0;
152 const expirationDate =
153 expiry > 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined;
154
155 cookies.push({
156 // Safari marks domain cookies with a leading dot like the other
157 // engines, so the shared scope rule applies: host-only cookies keep
158 // `domain` undefined, or Electron widens them to every subdomain.
159 ...cookieScope(domain, path || "/", secure),
160 name,
161 value,
162 path: path || "/",
163 secure,
164 httpOnly: (flags & FLAG_HTTP_ONLY) !== 0,
165 expirationDate,
166 // Bits 3–5 of the flags carry something SameSite-shaped, but no public
167 // description of them agrees and real jars do not match any of them
168 // cleanly. Lax is the modern browser default; claiming "none" would
169 // widen every imported cookie's scope.
170 sameSite: "lax",
171 });
172 }
173 }
174
175 // Safari writes an 8-byte checksum after the pages, then an optional
176 // length-prefixed property list. Anything else past the declared pages —
177 // in particular whole extra pages — means the page table does not describe
178 // the file, and a jar the header lies about is refused rather than
179 // imported with cookies silently missing.
180 const trailer = buffer.length - pageStart;
181 // Legal shapes: nothing, the 8-byte checksum alone, or checksum + u32
182 // length + exactly that many property-list bytes.
183 const validTrailer =
184 trailer === 0 ||
185 trailer === 8 ||
186 (trailer >= 12 && trailer === 8 + 4 + buffer.readUInt32BE(pageStart + 8));
187 if (!validTrailer) {
188 throw new SafariCookieReadError({ reason: "readFailed" });
189 }
190
191 return cookies;
192}
193
194/**
195 * Whether a filesystem error is the OS refusing access.
196 *
197 * A TCC denial arrives as EPERM, which Effect tags `Unknown` rather than
198 * `PermissionDenied` (reserved for EACCES), so the underlying errno is checked
199 * too — otherwise a Full Disk Access refusal is reported as a generic read
200 * failure and the user is never told what to grant.
201 */
202export const isPermissionDenied = (error: PlatformError.PlatformError): boolean => {
203 // TCC denies with EPERM, which Effect tags `Unknown` rather than
204 // `PermissionDenied` — so the errno is what identifies it. EACCES (and the
205 // `PermissionDenied` tag it maps to) is an ordinary POSIX permission or
206 // ACL failure that granting Full Disk Access cannot fix, so it stays a plain
207 // read failure rather than sending the user to a grant that won't help.
208 const code = (error.reason as { cause?: { code?: unknown } }).cause?.code;
209 return code === "EPERM";
210};
211
212/**
213 * Whether reading the jar is refused by TCC. `stat` succeeds on the jar
214 * inside Safari's container even without Full Disk Access — that is what lets
215 * the listing find it — so presence alone cannot tell granted from denied.
216 * Opening it for read is what TCC gates: EPERM means the grant is missing.
217 * Anything else (including a missing jar) is not a permission answer.
218 */
219export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: string) {
220 const fileSystem = yield* FileSystem.FileSystem;
221 return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe(
222 Effect.as(false),
223 Effect.catch((cause) => Effect.succeed(isPermissionDenied(cause))),
224 Effect.scoped,
225 );
226});
227
228/** A missing or unreadable jar is never evidence that access was granted. */
229export const safariAccessGranted = Effect.fnUntraced(function* (cookiePath: string) {
230 const fileSystem = yield* FileSystem.FileSystem;
231 return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe(
232 Effect.as(true),
233 Effect.orElseSucceed(() => false),
234 Effect.scoped,
235 );
236});
237
238export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* (
239 cookiePath: string,
240) {
241 const fileSystem = yield* FileSystem.FileSystem;
242 const contents = yield* fileSystem.readFile(cookiePath).pipe(
243 Effect.mapError((cause) => {
244 // TCC denies the read even though the file exists — a permission the user
245 // grants in System Settings rather than a missing browser. macOS never
246 // prompts for Full Disk Access, so there is no dialog to wait on; the
247 // read just fails, and it fails with EPERM, which Effect surfaces as an
248 // `Unknown` system error rather than `PermissionDenied` (that is EACCES).
249 return new SafariCookieReadError({
250 reason: isPermissionDenied(cause) ? "needsFullDiskAccess" : "readFailed",
251 cookieDatabasePath: cookiePath,
252 cause,
253 });
254 }),
255 );
256 // The parser throws on a malformed jar; catch it here so callers see a typed
257 // failure rather than a defect.
258 return yield* Effect.try({
259 try: () => parseBinaryCookies(Buffer.from(contents)),
260 catch: (cause) =>
261 isSafariCookieReadError(cause)
262 ? new SafariCookieReadError({
263 reason: cause.reason,
264 cookieDatabasePath: cookiePath,
265 cause,
266 })
267 : new SafariCookieReadError({
268 reason: "readFailed",
269 cookieDatabasePath: cookiePath,
270 cause,
271 }),
272 });
273});