| 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 | */ |
| 22 | import * as Effect from "effect/Effect"; |
| 23 | import * as FileSystem from "effect/FileSystem"; |
| 24 | import * as PlatformError from "effect/PlatformError"; |
| 25 | import * as Schema from "effect/Schema"; |
| 26 | |
| 27 | import { cookieScope, type ImportedCookie } from "./CookieDatabase.ts"; |
| 28 | |
| 29 | /** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */ |
| 30 | const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; |
| 31 | |
| 32 | /** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */ |
| 33 | const COOKIE_PAGE_HEADER_SIZE = 12; |
| 34 | /** Through the `f64 creation` field; string bytes follow. */ |
| 35 | const COOKIE_RECORD_HEADER_SIZE = 56; |
| 36 | |
| 37 | const FLAG_SECURE = 0x1; |
| 38 | const FLAG_HTTP_ONLY = 0x4; |
| 39 | |
| 40 | export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]); |
| 41 | export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type; |
| 42 | |
| 43 | export 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 | |
| 63 | const isSafariCookieReadError = Schema.is(SafariCookieReadError); |
| 64 | |
| 65 | /** Reads a NUL-terminated ASCII string at an offset. */ |
| 66 | function 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 | |
| 71 | export 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 | */ |
| 202 | export 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 | */ |
| 219 | export 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. */ |
| 229 | export 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 | |
| 238 | export 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 | }); |