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, not a security certification. The backend it describes is Supabase (ADR-009); the Firebase backend this document used to describe was removed and no longer exists.

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 a DEVICE CREDENTIAL, entitlement cache, desired state
        |                                  |
        | chrome.tabCapture / offscreen    | HTTPS (redeem-link-code, mint-entitlement)
        v                                  v
offscreen / content script (DSP graph)    Supabase Edge Functions <-- service-role key, Postgres
        |
        v
autolevel-worklet.js (AudioWorkletProcessor, bundled classic script, no DOM)

hosted pages (/link, /buy, /account — static, separate origin) -- hold the USER session

The extension holds no user session at all (ADR-009). It carries an opaque random string the server issued to that one installation, and it does not learn the account's email address or user id. Identity lives on the website. This is the single most load-bearing property in this document: a stolen extension profile yields a revocable per-install credential, not an account.

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 signs the user in against Supabase Auth over plain fetch.
  3. Hosted page calls issue-link-code with the user's JWT (verify_jwt = true, so the gateway rejects anonymous callers before the function runs). It writes link_codes = { code, user_id, install_id, challenge_s256, expires_at }. 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 redeem-link-code with { code, verifier, installId } (verify_jwt = false — the code plus verifier is the credential; the background holds no session). The function burns the code first, with an atomic update … where code = $1 and used_at is null … returning, so only the first caller gets a row back. It then checks expiry, install_id equality, and base64url(SHA-256(verifier)) === challenge_s256 using a constant-time compare. Because the burn happens before the proof, a failed proof still consumes the code — verifier guessing gets exactly one attempt, not unlimited attempts against a live code.
  5. On success the function issues an opaque random device credential, stores only its SHA-256 hash in device_credentials, and returns the plaintext once. Re-linking the same install revokes the previous credential (enforced by a unique partial index, not by application logic).
  6. Background stores that credential and sends it in x-autolevel-device-token on every mint-entitlement call. It never receives a user JWT, a refresh token, or an email address.

The hosted page 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. An unknown or already-used code returns exactly the same 404 invalid-code as a wrong one, so redemption failures do not tell an attacker which codes exist.

Entitlement token contract

The client (src/core/entitlements/{types,verify,policy}.ts) and the server (supabase/functions/_shared/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.

Row-level security (summary)

Every table has RLS enabled and no policy is the default, which in Postgres means deny-all to anon and authenticated alike. A table appears in this list only to say what, if anything, was deliberately opened. The Edge Functions reach these tables with the service-role key, which bypasses RLS — that is precisely why the client-facing grants are this narrow.

Table Client access Notes
profiles select own Populated by a trigger on auth.users.
installs none Rate-limit bookkeeping; service-role only.
device_credentials none Stores only sha256(token). The plaintext exists once, in the redeem response.
link_codes none 60 s, single-use, bound to install_id + PKCE challenge.
customers none The Stripe customer id is never readable by the user it belongs to.
subscriptions select own The account page reads status and period end to show a plan.
purchases select own Same, for one-time purchases.
stripe_events none Idempotency ledger: the webhook inserts here first, so a replayed event fails on the primary key before any state changes.

Checkout and billing-portal sessions are created by Edge Functions from the user's JWT. The client never names a price id or an amount — it names a tier, and the server resolves what that costs. A client that can state its own price is a client that can state its own price of zero.

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.
activeTab yes yes START is a user action from the extension's own UI. This grants that one tab's URL for a single site-rule lookup, instead of the broad tabs permission or a host permission.
scripting yes no Registers the page-media content script on Chromium only when the user has granted the optional <all_urls> permission. Without it, Compatibility Mode cannot be offered at all on Chromium.
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 not in the manifest — Chromium registers one at runtime via scripting, and only after the user grants the optional host permission yes, declared in the manifest, 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
Device credential storage.local (background only) Opaque random string, bound to this install, revocable server-side. Not a user session: it carries no email address, user id, or refresh token.
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 (public.installs) Postgres, server-side RLS deny-all; service-role only.
Device credential hashes (public.device_credentials) Postgres, server-side SHA-256 only. A database dump does not yield a usable credential.
One-time link codes (public.link_codes) Postgres, server-side 60s TTL, single-use, burned by an atomic compare-and-swap; never exposed to the client.
Telemetry (opt-in only) Nowhere. In-memory buffer inside the extension, discarded on browser close There is no telemetry transport in the shipping build; the sink takes an injected one and none is wired. See PRIVACY.md.

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

Email support@compressr.io. It is a monitored address, not a placeholder. There is no bug bounty and no formal disclosure timeline; what is offered is that a report will be read by the person who can fix it. A dedicated security@ alias and a published disclosure policy remain outstanding.

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.