Skip to content

Commit 79df949

Browse files
fix(desktop): reject a malformed Safari jar instead of importing part of it
`Buffer.subarray` clamps rather than throwing, so every declared structure in the binary format was taken on trust. An overlong page swallowed the following page's bytes and pushed the cursor past the end, dropping every cookie after the boundary from an import that still reported success. A record whose declared size overran its page left its string offsets free to read the next record's bytes as this cookie's value. Pages, records, and string offsets are now bounds-checked against what the file actually contains, and a mismatch fails the read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 27b322e commit 79df949

2 files changed

Lines changed: 74 additions & 3 deletions

File tree

apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,44 @@ describe("parseBinaryCookies", () => {
147147
expect(parsed.map((cookie) => cookie.name)).toEqual(["one", "two"]);
148148
});
149149

150+
it("rejects a page that runs past the end of the file", () => {
151+
// `Buffer.subarray` clamps rather than throwing, so an overlong first page
152+
// swallows the second one's bytes and advances the cursor past the end.
153+
// Every cookie after the boundary then vanishes from a "successful" import.
154+
const first = encodeBinaryCookies([
155+
{ domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 },
156+
]);
157+
const second = encodeBinaryCookies([
158+
{ domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 },
159+
]);
160+
const firstPage = first.subarray(12);
161+
const secondPage = second.subarray(12);
162+
const header = Buffer.alloc(16);
163+
header.write("cook", 0, "latin1");
164+
header.writeUInt32BE(2, 4);
165+
// Declares more bytes for page one than the file holds in total.
166+
header.writeUInt32BE(firstPage.length + secondPage.length + 32, 8);
167+
header.writeUInt32BE(secondPage.length, 12);
168+
169+
expect(() => parseBinaryCookies(Buffer.concat([header, firstPage, secondPage]))).toThrow(
170+
SafariCookieReadError,
171+
);
172+
});
173+
174+
it("rejects a record whose declared size runs past its page", () => {
175+
const valid = encodeBinaryCookies([
176+
{ domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 },
177+
]);
178+
// The record's own length is what bounds its string offsets; an inflated
179+
// one lets them read the following record's bytes as this cookie's value.
180+
const pageStart = 8 + 4;
181+
const recordStart = pageStart + valid.readUInt32LE(pageStart + 8);
182+
const corrupt = Buffer.from(valid);
183+
corrupt.writeUInt32LE(0xffff, recordStart);
184+
185+
expect(() => parseBinaryCookies(corrupt)).toThrow(SafariCookieReadError);
186+
});
187+
150188
it("rejects a file that is not binarycookies", () => {
151189
expect(() => parseBinaryCookies(Buffer.from("not a cookie jar"))).toThrow(
152190
SafariCookieReadError,

apps/desktop/src/preview/BrowserImport/SafariCookies.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ import type { ImportedCookie } from "./CookieDatabase.ts";
2828
/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
2929
const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
3030

31+
/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
32+
const COOKIE_PAGE_HEADER_SIZE = 12;
33+
/** Through the `f64 creation` field; string bytes follow. */
34+
const COOKIE_RECORD_HEADER_SIZE = 48;
35+
3136
const FLAG_SECURE = 0x1;
3237
const FLAG_HTTP_ONLY = 0x4;
3338

@@ -61,6 +66,14 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie
6166
}
6267

6368
const pageCount = buffer.readUInt32BE(4);
69+
// Every declared structure is bounds-checked against what the file actually
70+
// contains, and a mismatch fails the read. `Buffer.subarray` clamps silently,
71+
// so accepting a short page or an overlong record would return a cookie set
72+
// that is quietly missing entries or carrying fields read out of the next
73+
// record — a partial import the user has no way to notice.
74+
if (8 + pageCount * 4 > buffer.length) {
75+
throw new SafariCookieReadError({ reason: "readFailed" });
76+
}
6477
const pageSizes: number[] = [];
6578
for (let index = 0; index < pageCount; index += 1) {
6679
pageSizes.push(buffer.readUInt32BE(8 + index * 4));
@@ -70,16 +83,29 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie
7083
let pageStart = 8 + pageCount * 4;
7184

7285
for (const pageSize of pageSizes) {
86+
if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) {
87+
throw new SafariCookieReadError({ reason: "readFailed" });
88+
}
7389
const page = buffer.subarray(pageStart, pageStart + pageSize);
7490
pageStart += pageSize;
75-
if (page.length < 12) continue;
7691

7792
// Page bodies switch to little-endian after the big-endian header.
7893
const cookieCount = page.readUInt32LE(4);
94+
if (COOKIE_PAGE_HEADER_SIZE + cookieCount * 4 > page.length) {
95+
throw new SafariCookieReadError({ reason: "readFailed" });
96+
}
7997
for (let index = 0; index < cookieCount; index += 1) {
8098
const cookieStart = page.readUInt32LE(8 + index * 4);
81-
if (cookieStart + 48 > page.length) continue;
82-
const cookie = page.subarray(cookieStart);
99+
if (cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) {
100+
throw new SafariCookieReadError({ reason: "readFailed" });
101+
}
102+
// Bounded by the record's own length so a string offset cannot run past
103+
// it into the following record's bytes.
104+
const recordSize = page.readUInt32LE(cookieStart);
105+
if (recordSize < COOKIE_RECORD_HEADER_SIZE || cookieStart + recordSize > page.length) {
106+
throw new SafariCookieReadError({ reason: "readFailed" });
107+
}
108+
const cookie = page.subarray(cookieStart, cookieStart + recordSize);
83109

84110
const flags = cookie.readUInt32LE(8);
85111
const urlOffset = cookie.readUInt32LE(16);
@@ -88,6 +114,13 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie
88114
const valueOffset = cookie.readUInt32LE(28);
89115
const expiry = cookie.readDoubleLE(40);
90116

117+
// Offsets are relative to the record; one pointing outside it would
118+
// otherwise read a neighbouring cookie's bytes as this one's value.
119+
if (
120+
[urlOffset, nameOffset, pathOffset, valueOffset].some((offset) => offset >= cookie.length)
121+
) {
122+
throw new SafariCookieReadError({ reason: "readFailed" });
123+
}
91124
const domain = readCString(cookie, urlOffset);
92125
const name = readCString(cookie, nameOffset);
93126
const path = readCString(cookie, pathOffset);

0 commit comments

Comments
 (0)