Security

This document describes Compressr's threat model, trust boundaries, the auth and entitlement mechanisms, permissions, storage, and known gaps. It is a factual description of what the code does today (commit 85f7500), not a security certification.

Threat model

Compressr is a browser extension. The extension is an untrusted client from the server's point of view: anyone can unpack, patch, or re-sign a copy of it, intercept its network calls, or run a modified build against the real backend. The design goal is therefore:

Accepted residual risk: a determined local attacker who controls their own machine can patch the extension binary to bypass client-side gating entirely (e.g. hard-code plan: 'pro_plus' in memory). This is explicitly accepted (ARCHITECTURE.md §9) — the design targets casual sharing and server-side revocability, not DRM-grade protection against a fully compromised client. No mitigation in this document changes that.

The complementary promise is audio privacy: tab/page audio, PCM, video frames, DOM/page content, and browsing history never leave the device under any code path (see PRIVACY.md). That promise does not depend on trusting the client with secrets — there simply is no code path that transmits audio, so there is nothing for a hostile client to exfiltrate on that axis.

Trust boundaries

popup / options (React, UI only)
        |  runtime messaging (src/adapters/runtime)
        v
background (service worker / event page)  <-- holds Firebase Auth, entitlement cache, desired state
        |                                  |
        | chrome.tabCapture / offscreen    | HTTPS callables (issueLinkCode, redeemLinkCode, mintEntitlement)
        v                                  v
offscreen / content script (DSP graph)    Cloud Functions (functions/src) <-- admin SDK, Firestore
        |
        v
autolevel-worklet.js (AudioWorkletProcessor, bundled classic script, no DOM)

hosted page (/link, static, separate origin) -- signs the user in, mints a one-time code

Runtime-message sender validation (SEC-06)

Privileged Cmd and AuthMsg requests are accepted only when sender.id equals this extension's runtime ID and sender.url has the extension origin returned by browser.runtime.getURL('/'). This intentionally does not depend on the presence of sender.tab: an extension document can be the action popup without a tab, or popup.html#/pricing, a success route, or options opened in an ordinary browser tab. Content scripts remain limited to PM_* page-media events and must also carry this extension's runtime ID; their web-page sender.url cannot satisfy the privileged extension-page check. Other senders receive no response, and the background logs the rejected boundary once per worker lifetime.

Auth handoff (ADR-001)

The extension background never runs its own OAuth flow and never imports chrome.identity. Flow (ARCHITECTURE.md §8, AUTH_AND_BILLING.md §1.1):

  1. Popup opens a tab: {HOSTED_LOGIN_URL}/link?challenge=<S256(verifier)>&install_id=<install_id> — PKCE with the S256 method (SHA-256 of a locally generated verifier, base64url-encoded).
  2. The hosted page performs standard Firebase web sign-in (Google/email/Apple).
  3. Hosted page calls issueLinkCode({ installId, challenge }) (requires request.auth), which writes linkCodes/{code} = { uid, installId, challengeS256, expiresAt, used: false }. The code is 24 characters, Crockford base32 alphabet, 60 second TTL, and is bound to the install_id and PKCE challenge presented at mint.
  4. Extension background calls redeemLinkCode({ code, verifier, installId }) (no auth required — the background isn't signed in yet). The Cloud Function reads and validates the code inside a Firestore transaction: not used, not expired, install_id matches, and base64url(SHA-256(verifier)) === challengeS256. It marks the record used inside the same transaction, so a code can never be redeemed twice concurrently — this is fail-closed by construction, not by a follow-up check.
  5. On success, the function returns admin.auth().createCustomToken(uid, { install_id }).
  6. Background signs in with signInWithCustomToken, using initializeAuth + indexedDBLocalPersistence (firebase/auth/web-extension). Only the background holds Firebase Auth — offscreen never imports Firebase, and popup/options only ask background.

The hosted page itself never sees an extension identifier beyond the opaque install_id string passed in the URL, and the extension never runs code from the hosted page's origin.

Entitlement token contract

The client (src/core/entitlements/{types,verify,policy}.ts) and the server (functions/src/lib/entitlement.ts) must agree on this byte-for-byte; a mismatch fails silently as malformed/invalid-signature, not a crash.

Pro gating enforcement point

Pro features are gated in background/runtime code, not in the UI. The client's hasAccess computed in the UI layer is a hint only (to render an upgrade prompt, etc.); background applies the verified entitlement gate before it sends DSP controls, and offscreen/content validate and clamp those controls before applying them. A patched popup that always renders "Pro" does not, by itself, unlock Pro DSP behavior — the token still has to verify. A locally patched runtime remains the explicitly accepted residual risk documented below.

Firestore rules (summary)

firestore.rules denies by default; nothing is readable or writable except what is explicitly opened:

Path Client access Notes
customers/{uid} none Never read/written directly.
customers/{uid}/checkout_sessions/{id} create + read (owner only) create requires owner auth, exactly price/success_url/cancel_url/mode, a price_… identifier, an approved redirect origin, and mode in ['payment','subscription']. Update/delete always denied — only the "Run Payments with Stripe" extension writes back url/error.
customers/{uid}/subscriptions/{id} none Read only by mintEntitlement via the admin SDK.
customers/{uid}/payments/{id} none Same as above.
users/{uid} read/create/update (owner only) Client profile writes are limited to email, createdAt, and updatedAt; install bookkeeping remains server-only.
users/{uid}/installs/{installId} none Rate-limit bookkeeping; admin SDK only.
linkCodes/{code} none Read/written only by issueLinkCode/redeemLinkCode via the admin SDK, which bypasses rules entirely — the explicit false rule exists to fail closed if a future refactor ever routes a client SDK call here.

Content Security Policy

wxt.config.ts builds the manifest CSP per browser/manifest-version:

Permissions, per browser, and why

wxt.config.ts's manifest() function computes permissions per target browser — nothing is requested "just in case":

Permission Chrome (+ Chromium family) Firefox / Safari Why
storage yes yes Entitlement cache, settings, presets, site/device profiles — all local.
alarms yes yes Periodic entitlement refresh (12h) without keeping the service worker alive continuously.
tabCapture yes no Full-tab audio capture via chrome.tabCapture.getMediaStreamId. No documented Firefox/Safari equivalent (FIREFOX.md, SAFARI.md).
offscreen yes no Hosts the persistent AudioContext/worklet graph outside the ephemeral service worker (Chromium-only API).
<all_urls> content script no (Chromium uses tab capture as the primary path; content script is a fallback) yes, for Compatibility Mode Firefox/Safari have no full-tab capture API, so the content script must be able to discover <audio>/<video> elements on any page to offer PageMediaCapture at all. This is page-media mode — labeled "Compatibility Mode" in the UI, never "Full Tab Processing."

No oauth2 manifest key and no chrome.identity permission are used anywhere — the PKCE hosted handoff (above) replaces them on every browser (ADR-001).

Storage inventory

What Where Notes
Firebase Auth session IndexedDB (via indexedDBLocalPersistence, background only) Never audio.
Entitlement token (cached) storage.local Verified ES256 JWT; refreshed per the cadence above.
Settings, presets, per-site/per-device profiles storage.local / storage.sync as appropriate User-authored data only; never derived from browsing history (PRIVACY.md).
Rate-limit bookkeeping (users/{uid}/installs) Firestore, server-side Never exposed to the client (rules deny read).
One-time link codes Firestore (linkCodes/{code}), server-side 60s TTL, single-use, deleted-in-effect by the used flag; never exposed to the client.
Telemetry (opt-in only) Sent to the configured analytics endpoint (allow-listed event names only, no payloads) See PRIVACY.md for the exact 10-event allow-list.

Nothing above is audio. No storage location, client- or server-side, ever holds captured PCM, tab audio, microphone input, or video frames — see PRIVACY.md for the full never-transmitted list, which is a structural invariant (src/core/dsp cannot import browser/network APIs), not a configuration toggle.

Known gaps

These remaining limitations are explicit rather than silently accepted:

Dependency policy

CI runs npm audit --audit-level=high after npm ci; high and critical findings fail the build. It is a dependency gate, not a guarantee that every transitive issue has a safe automatic upgrade.

Reporting a vulnerability

Contact the support address configured in src/data/info.ts (support_email). This is a placeholder pending a dedicated security contact/process (e.g. a security@ address and/or a disclosure policy page) — treat the current contact as provisional.

Dependency audit status (2026-09-07)

Production dependencies: npm audit --omit=dev --audit-level=high → 0 vulnerabilities (blocking in CI). Full audit still reports 4 high, all dev-only and all rooted in web-ext's addons-linter → image-size (DoS in ICNS/JXL/HEIF parsers); web-ext is only used for npm run lint:firefox on trusted local files and never ships. Tracked as an accepted dev-only exception; re-check on each web-ext release. The full audit runs in CI as non-blocking.

This page is generated from the project's SECURITY.md. Questions: support@compressr.io.