Skip to content
Source
Sign inSign up
Open History to see the last commit

SafariCookies.test.ts

254 lines8.5 KB

1// @effect-diagnostics nodeBuiltinImport:off - Hand-builds Safari's binary jar
2// format byte by byte.
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { assert, describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as PlatformError from "effect/PlatformError";
8​
9import {
10 isPermissionDenied,
11 parseBinaryCookies,
12 readSafariCookies,
13 safariAccessDenied,
14 SafariCookieReadError,
15} from "./SafariCookies.ts";
16​
17const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
18​
19interface FixtureCookie {
20 readonly domain: string;
21 readonly name: string;
22 readonly path: string;
23 readonly value: string;
24 readonly flags: number;
25 readonly expiry: number;
27​
28function encodeCookie(cookie: FixtureCookie): Buffer {
29 const strings = [cookie.domain, cookie.name, cookie.path, cookie.value];
30 const headerSize = 56;
31 const offsets: number[] = [];
32 let cursor = headerSize;
33 for (const value of strings) {
34 offsets.push(cursor);
35 cursor += Buffer.byteLength(value) + 1;
36 }
37 const size = cursor;
38​
39 const buffer = Buffer.alloc(size);
40 buffer.writeUInt32LE(size, 0);
41 buffer.writeUInt32LE(0, 4);
42 buffer.writeUInt32LE(cookie.flags, 8);
43 buffer.writeUInt32LE(0, 12);
44 buffer.writeUInt32LE(offsets[0]!, 16);
45 buffer.writeUInt32LE(offsets[1]!, 20);
46 buffer.writeUInt32LE(offsets[2]!, 24);
47 buffer.writeUInt32LE(offsets[3]!, 28);
48 buffer.writeUInt32LE(0, 32);
49 buffer.writeUInt32LE(0, 36);
50 buffer.writeDoubleLE(cookie.expiry, 40);
51 buffer.writeDoubleLE(0, 48);
52 strings.forEach((value, index) => {
53 buffer.write(value, offsets[index]!, "utf8");
54 });
55 return buffer;
57​
58function encodeBinaryCookies(cookies: ReadonlyArray<FixtureCookie>): Buffer {
59 const encoded = cookies.map(encodeCookie);
60 const headerSize = 12 + encoded.length * 4;
61 const offsets: number[] = [];
62 let cursor = headerSize;
63 for (const cookie of encoded) {
64 offsets.push(cursor);
65 cursor += cookie.length;
66 }
67​
68 const page = Buffer.alloc(cursor);
69 page.writeUInt32BE(0x0000_0100, 0);
70 page.writeUInt32LE(encoded.length, 4);
71 offsets.forEach((offset, index) => page.writeUInt32LE(offset, 8 + index * 4));
72 encoded.forEach((cookie, index) => cookie.copy(page, offsets[index]!));
73​
74 const header = Buffer.alloc(8 + 4);
75 header.write("cook", 0, "latin1");
76 header.writeUInt32BE(1, 4);
77 header.writeUInt32BE(page.length, 8);
78 return Buffer.concat([header, page]);
80​
81describe("parseBinaryCookies", () => {
82 it("reads Safari's format and rebases its 2001 epoch", () => {
83 const file = encodeBinaryCookies([
84 {
85 domain: ".apple.com",
86 name: "session",
87 path: "/",
88 value: "abc",
89 flags: 0x1 | 0x4,
90 expiry: 800_000_000,
91 },
92 {
93 domain: "example.test",
94 name: "plain",
95 path: "/app",
96 value: "v",
97 flags: 0,
98 expiry: 0,
99 },
100 ]);
101​
102 expect(parseBinaryCookies(file)).toEqual([
103 {
104 url: "https://apple.com/",
105 name: "session",
106 value: "abc",
107 domain: ".apple.com",
108 path: "/",
109 secure: true,
110 httpOnly: true,
111 expirationDate: 800_000_000 + APPLE_EPOCH_OFFSET_SECONDS,
112 sameSite: "lax",
113 },
114 {
115 url: "http://example.test/app",
116 name: "plain",
117 value: "v",
118 domain: undefined,
119 path: "/app",
120 secure: false,
121 httpOnly: false,
122 expirationDate: undefined,
123 sameSite: "lax",
124 },
125 ]);
126 });
127​
128 it("keeps __Host- cookies host-only so Electron accepts them", () => {
129 const file = encodeBinaryCookies([
130 { domain: "example.test", name: "__Host-id", path: "/", value: "v", flags: 0x1, expiry: 0 },
131 ]);
132​
133 expect(parseBinaryCookies(file)[0]).toMatchObject({
134 url: "https://example.test/",
135 name: "__Host-id",
136 domain: undefined,
137 });
138 });
139​
140 it("reads cookies spread across multiple pages", () => {
141 const first = encodeBinaryCookies([
142 { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 },
143 ]);
144 const second = encodeBinaryCookies([
145 { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 },
146 ]);
147 const firstPage = first.subarray(12);
148 const secondPage = second.subarray(12);
149 const header = Buffer.alloc(16);
150 header.write("cook", 0, "latin1");
151 header.writeUInt32BE(2, 4);
152 header.writeUInt32BE(firstPage.length, 8);
153 header.writeUInt32BE(secondPage.length, 12);
154​
155 const parsed = parseBinaryCookies(Buffer.concat([header, firstPage, secondPage]));
156​
157 expect(parsed.map((cookie) => cookie.name)).toEqual(["one", "two"]);
158 });
159​
160 it("rejects a page that runs past the end of the file", () => {
161 const first = encodeBinaryCookies([
162 { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 },
163 ]);
164 const second = encodeBinaryCookies([
165 { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 },
166 ]);
167 const firstPage = first.subarray(12);
168 const secondPage = second.subarray(12);
169 const header = Buffer.alloc(16);
170 header.write("cook", 0, "latin1");
171 header.writeUInt32BE(2, 4);
172 header.writeUInt32BE(firstPage.length + secondPage.length + 32, 8);
173 header.writeUInt32BE(secondPage.length, 12);
174​
175 expect(() => parseBinaryCookies(Buffer.concat([header, firstPage, secondPage]))).toThrow(
176 SafariCookieReadError,
177 );
178 });
179​
180 it("rejects record offsets that point into the page header or an earlier record", () => {
181 const valid = encodeBinaryCookies([
182 { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 },
183 { domain: "b.test", name: "m", path: "/", value: "w", expiry: 1_000, flags: 0 },
184 ]);
185 const pageStart = 8 + 4;
186 const firstRecord = valid.readUInt32LE(pageStart + 8);
187​
188 const intoTable = Buffer.from(valid);
189 intoTable.writeUInt32LE(4, pageStart + 12);
190 expect(() => parseBinaryCookies(intoTable)).toThrow(SafariCookieReadError);
191​
192 const overlapping = Buffer.from(valid);
193 overlapping.writeUInt32LE(firstRecord, pageStart + 12);
194 expect(() => parseBinaryCookies(overlapping)).toThrow(SafariCookieReadError);
195​
196 expect(parseBinaryCookies(valid)).toHaveLength(2);
197 });
198​
199 it("accepts the checksum and property-list trailer Safari writes", () => {
200 const file = encodeBinaryCookies([
201 { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 },
202 ]);
203 const checksum = Buffer.alloc(8);
204 const plist = Buffer.from("bplist00 stub");
205 const plistLength = Buffer.alloc(4);
206 plistLength.writeUInt32BE(plist.length, 0);
207​
208 expect(parseBinaryCookies(Buffer.concat([file, checksum]))).toHaveLength(1);
209 expect(parseBinaryCookies(Buffer.concat([file, checksum, plistLength, plist]))).toHaveLength(1);
210 });
211});
212​
213describe("readSafariCookies", () => {
214 it.effect("reports a TCC denial as a permission the user can grant", () =>
215 Effect.gen(function* () {
216 const denied = PlatformError.systemError({
217 _tag: "Unknown",
218 module: "FileSystem",
219 method: "readFile",
220 pathOrDescriptor: "/protected/Cookies.binarycookies",
221 cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }),
222 });
223​
224 const error = yield* readSafariCookies("/protected/Cookies.binarycookies").pipe(
225 Effect.flip,
226 Effect.provide(FileSystem.layerNoop({ readFile: () => Effect.fail(denied) })),
227 );
228​
229 assert.equal(error.reason, "needsFullDiskAccess");
230 }),
231 );
232});
233​
234describe("safariAccessDenied", () => {
235 it.effect("does not read a readable jar, or any other failure, as denied", () =>
236 Effect.gen(function* () {
237 const fileSystem = yield* FileSystem.FileSystem;
238 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" });
239 const jar = `${directory}/Cookies.binarycookies`;
240 yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b]));
241 assert.isFalse(yield* safariAccessDenied(jar));
242 assert.isFalse(yield* safariAccessDenied(`${directory}/absent.binarycookies`));
243 }).pipe(Effect.provide(NodeServices.layer)),
244 );
245});
246​
247describe("isPermissionDenied", () => {
248 const platformError = (reasonTag: string, code: string): PlatformError.PlatformError =>
249 ({ _tag: "PlatformError", reason: { _tag: reasonTag, cause: { code } } }) as never;
250​
251 it("does not send an ordinary EACCES failure to the Full Disk Access grant", () => {
252 expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(false);
253 });
254});