/** * Firefox cookie extraction. * * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key * to fetch and no consent prompt — the file is readable by anything running as * the user. That is Mozilla's design choice, not a control being circumvented, * which is why this path works identically on macOS, Windows, and Linux while * the Chromium one needs a per-platform credential store. * * @module FirefoxCookies */ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts"; /** * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error * the service can tell apart, rather than one of them widening the channel to * an anonymous shape. * * No `reason` field: unlike Chromium there is only one way this fails — the * plaintext database would not open — and the tag already says which engine it * was. `BrowserImport` supplies the user-facing reason when it maps the union. */ export class FirefoxCookieReadError extends Schema.TaggedError()( "FirefoxCookieReadError", { /** * Which database the read was for. Firefox keeps one per profile, so * without it a failure cannot be traced back to the profile that caused * it. */ cookieDatabasePath: Schema.String, /** Always present: every construction site wraps a real failure. */ cause: Schema.Defect(), }, ) { override get message(): string { return `Could not read Firefox cookies at ${this.cookieDatabasePath}.`; } } /** * `moz_cookies.sameSite` holds nsICookie's constants: 0 = None, 1 = Lax, * 2 = Strict, and 256 = Unset for a cookie that carried no SameSite attribute * at all. Unset is not the same thing as None — None is an explicit opt-in to * cross-site use — so it is imported as Electron's `unspecified`, which lets * the target browser apply its own default exactly as Firefox did. Anything * unrecognised also lands there rather than on `no_restriction`, since * guessing "none" would widen a cookie's scope on import. */ const SAMESITE_NONE = 0; const SAMESITE_LAX = 1; const SAMESITE_STRICT = 2; /** * Schemas 10–14 carried a second column, `rawSameSite`: the value the cookie * actually declared, beside a `sameSite` that Firefox had already defaulted to * Lax. The schema-15 migration folded them back together with * `sameSite = UNSET where sameSite = LAX and rawSameSite = NONE`, i.e. a row * that "is Lax" only because nothing was declared. Reading such a database * before Firefox has migrated it must apply the same rule, or an undeclared * cookie is imported as an explicit Lax. */ const FIREFOX_RAW_SAMESITE_FIRST_SCHEMA = 10; const FIREFOX_RAW_SAMESITE_LAST_SCHEMA = 14; const sameSiteFromColumn = ( value: number | null, rawValue: number | null, ): ImportedCookie["sameSite"] => { // Schema 9 added the column with no default, so older rows carry NULL. if (value === null) return "unspecified"; if (value === SAMESITE_LAX && rawValue === SAMESITE_NONE) return "unspecified"; if (value === SAMESITE_NONE) return "no_restriction"; if (value === SAMESITE_LAX) return "lax"; if (value === SAMESITE_STRICT) return "strict"; return "unspecified"; }; const CookieRow = Schema.Struct({ host: Schema.String, name: Schema.String, value: Schema.String, path: Schema.String, // UNIX-epoch based, unlike Chromium's 1601-based microseconds — but the // unit depends on the schema version; see `expiryToSeconds`. expiry: Schema.Number, isSecure: Schema.Number, isHttpOnly: Schema.Number, sameSite: Schema.NullOr(Schema.Number), // Present only for schemas 10–14; selected as NULL elsewhere. rawSameSite: Schema.NullOr(Schema.Number), }); const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow)); /** * Firefox schema 16 (Firefox 129) moved `expiry` from seconds to milliseconds * — the migration is `UPDATE moz_cookies SET expiry = expiry * 1000`. Electron * wants seconds, so the unit is decided by `PRAGMA user_version` rather than * assumed: importing a pre-16 profile as milliseconds would expire every cookie * at once, and a post-16 one as seconds would keep them for ~1000× too long. */ const FIREFOX_EXPIRY_MILLISECONDS_SCHEMA = 16; const UserVersionRow = Schema.Struct({ user_version: Schema.Number }); const decodeUserVersion = Schema.decodeUnknownEffect(Schema.Array(UserVersionRow)); const expiryToSeconds = (expiry: number, schemaVersion: number): number | undefined => { if (expiry <= 0) return undefined; return schemaVersion >= FIREFOX_EXPIRY_MILLISECONDS_SCHEMA ? Math.floor(expiry / 1000) : expiry; }; export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")(function* ( cookieDatabasePath: string, ) { const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath).pipe( Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), ); const { rows, schemaVersion } = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const [versionRow] = yield* decodeUserVersion(yield* sql`pragma user_version`); const schemaVersion = versionRow?.user_version ?? 0; const hasRawSameSite = schemaVersion >= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA && schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA; // Only the default container. Firefox isolates cookies per container and // per private window via `originAttributes` (`^userContextId=2`, // `^privateBrowsingId=1`); Electron has no equivalent, so importing them // all would collapse several identities onto one host/name/path and hand // the profile an arbitrary container's session. const raw = hasRawSameSite ? yield* sql` select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, rawSameSite from moz_cookies where originAttributes = '' ` : yield* sql` select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, null as rawSameSite from moz_cookies where originAttributes = '' `; return { rows: yield* decodeCookieRows(raw), schemaVersion }; }).pipe( Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })), Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), ); return rows.map((row) => { const secure = row.isSecure === 1; const scope = cookieScope(row.host, row.path, secure); return { url: scope.url, name: row.name, value: row.value, domain: scope.domain, path: row.path, secure, httpOnly: row.isHttpOnly === 1, expirationDate: expiryToSeconds(row.expiry, schemaVersion), sameSite: sameSiteFromColumn(row.sameSite, row.rawSameSite), } satisfies ImportedCookie; }); });