diff --git a/src/auth/firefox.ts b/src/auth/firefox.ts index d40e6ab..40b3d36 100644 --- a/src/auth/firefox.ts +++ b/src/auth/firefox.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; +import { snappyUncompress } from "hysnappy"; import { copySqliteForRead, @@ -19,6 +20,35 @@ export type FirefoxExtracted = { // eslint-disable-next-line no-control-regex const CONTROL_CHAR_RE = /[\u0000-\u001F]/g; +function decodeStorageValue(value: unknown, compressionType: number): unknown { + if (compressionType === 0) { + return value; + } + if (compressionType !== 1 || !(value instanceof Uint8Array)) { + return null; + } + + // Snappy starts with the uncompressed byte length as a uint32 varint. + let length = 0; + for (let i = 0; i < Math.min(value.length, 5); i++) { + const byte = value[i]!; + length += (byte & 0x7f) * 2 ** (7 * i); + if (byte & 0x80) { + continue; + } + // Bound allocations from corrupt profile data before invoking the decoder. + if (length > 64 * 1024 * 1024) { + return null; + } + try { + return snappyUncompress(value, length); + } catch { + return null; + } + } + return null; +} + function toStringValue(value: unknown): string { if (typeof value === "string") { return value; @@ -167,11 +197,15 @@ async function extractTeamsFromProfile( try { const rows = (await queryReadonlySqlite( copied.copyPath, - "select key, value from data where key in ('localConfig_v2', 'localConfig_v3') order by key desc", - )) as { key: string; value: unknown }[]; + "select key, value, compression_type from data where key in ('localConfig_v2', 'localConfig_v3') order by key desc", + )) as { key: string; value: unknown; compression_type: number }[]; for (const row of rows) { - const cfg = parseJsonObjectFromValue(row.value); + const value = decodeStorageValue(row.value, row.compression_type); + if (value === null) { + continue; + } + const cfg = parseJsonObjectFromValue(value); const teamsRaw = cfg && typeof cfg.teams === "object" && cfg.teams !== null ? cfg.teams : {}; const parsedTeams = Object.values(teamsRaw) @@ -181,7 +215,7 @@ async function extractTeamsFromProfile( return { teams: parsedTeams, sourcePath: dbPath }; } - const rawTeams = extractTeamsFromRawText(toStringValue(row.value)); + const rawTeams = extractTeamsFromRawText(toStringValue(value)); if (rawTeams.length > 0) { return { teams: rawTeams, sourcePath: dbPath }; } diff --git a/test/firefox-auth.test.ts b/test/firefox-auth.test.ts new file mode 100644 index 0000000..7dd86d0 --- /dev/null +++ b/test/firefox-auth.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir, platform } from "node:os"; +import { join } from "node:path"; +import { snappyCompress } from "hysnappy"; + +const team = { name: "Example", url: "https://example.slack.com/", token: "xoxc-test-token" }; +const config = JSON.stringify({ teams: { T123: team }, padding: "repeat ".repeat(100) }); + +async function extractFixture(value: string | Uint8Array, compressionType: number) { + const home = await mkdtemp(join(tmpdir(), "agent-slack-firefox-test-")); + try { + const base = + platform() === "darwin" + ? join(home, "Library", "Application Support", "Firefox") + : join(home, ".mozilla", "firefox"); + const profile = join(base, "fixture"); + const storage = join(profile, "storage", "default", "https+++app.slack.com", "ls"); + await mkdir(storage, { recursive: true }); + await writeFile( + join(base, "profiles.ini"), + "[Profile0]\nName=fixture\nIsRelative=1\nPath=fixture\nDefault=1\n", + ); + // Real SQLite files exercise discovery, snapshotting, querying and decoding together. + const db = new Database(join(storage, "data.sqlite")); + db.run("CREATE TABLE data (key TEXT, value BLOB, compression_type INTEGER)"); + db.run("INSERT INTO data VALUES (?, ?, ?)", ["localConfig_v2", value, compressionType]); + db.close(); + const cookies = new Database(join(profile, "cookies.sqlite")); + cookies.run("CREATE TABLE moz_cookies (host TEXT, name TEXT, value TEXT)"); + cookies.run("INSERT INTO moz_cookies VALUES ('.slack.com', 'd', 'xoxd-test-cookie')"); + cookies.close(); + // Avoid changing HOME or mocking shared modules in the test runner. + const proc = Bun.spawn( + [ + process.execPath, + "-e", + ` + import { extractFromFirefox } from "./src/auth/firefox.ts"; + const result = await extractFromFirefox(); + console.log(JSON.stringify(result && { teams: result.teams, cookie_d: result.cookie_d })); + `, + ], + { env: { ...process.env, HOME: home }, stdout: "pipe", stderr: "pipe" }, + ); + const output = await new Response(proc.stdout).text(); + const error = await new Response(proc.stderr).text(); + expect(await proc.exited, error).toBe(0); + return JSON.parse(output); + } finally { + await rm(home, { recursive: true, force: true }); + } +} + +describe("Firefox auth storage", () => { + for (const [name, value, compression] of [ + ["uncompressed text", config, 0], + ["uncompressed blob", Buffer.from(config), 0], + ["Snappy-compressed blob", snappyCompress(Buffer.from(config)), 1], + ] as const) { + test(`extracts ${name}`, async () => { + expect(await extractFixture(value, compression)).toEqual({ + teams: [team], + cookie_d: "xoxd-test-cookie", + }); + }); + } + + test("does not parse corrupt compressed data as plaintext", async () => { + expect(await extractFixture(Buffer.from(config), 1)).toBeNull(); + }); + + for (const [name, value] of [ + ["empty", Buffer.alloc(0)], + ["truncated length", Buffer.from([0x80])], + ["unterminated length", Buffer.from([0x80, 0x80, 0x80, 0x80, 0x80])], + ["oversized allocation", Buffer.from([0xff, 0xff, 0xff, 0xff, 0x0f])], + ] as const) { + test(`ignores ${name} compressed data`, async () => { + expect(await extractFixture(value, 1)).toBeNull(); + }); + } + + test("ignores unsupported compression types", async () => { + expect(await extractFixture(Buffer.from(config), 2)).toBeNull(); + }); +});