Skip to content

About

A browser that attempts to restrict users from removing data from the browser or otherwise persisting it locally in controlled environments.

Resources

Stars

1 star

Watchers

1 watching

Forks

Latest commit

 

History

234 Commits

Folders and files

Repository files navigation

Note: This project and its scaffolding were built with AI assistance (Claude, Anthropic). Review all generated code and patches carefully before use in production. The author of the repository has reviewed every change in detail, but does not have extensive history working with browsers, pingora, or rust professionally. If you find bugs or problems in implementation during peer review please do no hesitate to raise an issue.

DenBrowser

A set of patches for building a branded version of Firefox ESR browser that attempts to restrict users from removing data from the browser or otherwise persisting it locally. DenBrowser is not a complete solution, and really only makes sense when the deployment and operating environment is largely controlled and the user has no elevated privileges. The intended purpose is to provide a moderate approach to data loss prevention strategies with less intense external infrastructure requirements and less user impact on performance for use in controlled environments.

Currently tracking Firefox ESR 153.x (most recently built against 153.6.0esr; scripts/fetch-esr.sh pulls whatever ESR is current at fetch time).

As this project progresses tags will be cut and attempt to follow the latest ESR releases. Patches 015 and 017 are regenerated semantically (scripts/gen-015-patch.sh, scripts/gen-017-patch.sh) per ESR; the remaining patches use small enough context that they usually carry across point releases unmodified.


Prerequisites

  • Python 3, Rust, Node.js (required by Firefox build system — see Firefox build docs)

  • sccache — required compiler wrapper. Can disable by commenting out sccache values in config/mozconfig. config/mozconfig sets --with-compiler-wrapper=sccache, so the build will fail at configure if it's not on PATH. It also dramatically reduces incremental rebuild times (minutes instead of an hour+ on a warm cache).

    • macOS: brew install sccache
    • Linux: cargo install sccache (or your distro package)
    • Windows: cargo install sccache or choco install sccache

    The default 50 GB cache size is set via SCCACHE_CACHE_SIZE in config/mozconfig; adjust there if you're tight on disk.

  • ~20 GB free disk space for the source + build artifacts (plus up to 50 GB for the sccache cache, separately configurable)

Quick start

# 1. Bootstrap the Firefox build system (first time only)
./scripts/fetch-esr.sh            # downloads & verifies Firefox ESR source
cd src/firefox-$(cat src/.esr_version)
./mach bootstrap                  # installs Rust, cbindgen, etc.
cd ../..

# 2. Build
./build.sh # Attempts the full build process on the last esr version pulled by fetch-esr.sh

Project structure

DenBrowser/
├── build.sh                    # Full build orchestration
├── branding/                   # DenBrowser branding assets (icons, etc.)
├── config/
│   ├── mozconfig               # Firefox build flags + app identity
│   ├── policies.json           # Enterprise policy enforcement (loaded at startup)
│   ├── mozilla.cfg             # Source for compiled production locks; dev AutoConfig
│   ├── autoconfig.js           # Dev-only AutoConfig bootstrap
│   └── site-config.json        # Per-deployment whitelist / blacklist / clipboard sites /
│                               #   attestation proxies / bookmarks
├── patches/
│   ├── README.md               # Patch development guide
│   └── NNN-*.patch             # See "Patches" table below
├── proxy/
│   ├── Cargo.toml
│   ├── proxy.example.toml      # Example operational config (attestation key, rate limiting, …)
│   ├── audit/                  # JSON log schema (mapping) + an example of every audit event
│   └── src/
│       ├── main.rs             # Pingora-based attestation proxy entrypoint
│       ├── attest.rs           # ECIES verification + replay cache
│       ├── audit.rs            # Structured JSON audit records, reasons, severities
│       ├── logging.rs          # Log subscriber setup + JSON line formatter
│       ├── config.rs           # Operational TOML config loader
│       ├── ratelimit.rs        # Per-origin-IP request rate limiting
│       ├── mtls.rs             # Baseline mutual-TLS client authentication
│       ├── machine.rs          # Machine identity (workstation cert + forward-DNS check)
│       └── passthrough.rs      # Attestation bypass (IP + cert-subject allowlist)
└── scripts/
    ├── fetch-esr.sh            # Download + verify latest Firefox ESR source
    ├── apply-patches.sh        # Apply patches with dry-run validation
    ├── gen-attest-key.sh       # Generate a proxy's ECDSA P-256 attestation keypair
    ├── gen-proxy-tls.sh        # Generate a proxy's TLS cert + report its SPKI pin
    ├── gen-user-cert.sh        # Generate mTLS user (browser client) cert + CA
    ├── gen-machine-cert.sh     # Generate machine (workstation) cert + CA
    ├── register-den-protocol.ps1 # Register the den: link scheme on Windows
    ├── gen-newtab-shortcuts.py # Generate the new-tab shortcut tiles (build.sh Step 2.7)
    └── gen-015-patch.sh        # Regenerate patch 015 for the current ESR version

Defense layers

DenBrowser's protections are layered across four enforcement points so any single bypass still leaves the rest in place:

  1. Build-time flags (config/mozconfig) — --disable-crashreporter, --disable-updater, --disable-tests, --disable-parental-controls, --disable-profiling, --disable-accessibility, --disable-printing, --disable-pref-extensions, --enable-hardening, --enable-strip / --enable-install-strip. Code paths and symbols are never compiled in.
  2. Source patches — applied to the Firefox source tree before build and included in the build output.
  3. Enterprise policies (config/policies.json) — loaded at startup; lock UI surfaces and per-URL permissions. Cannot be overridden by profile state.
  4. Compiled preference locks (patch 017, generated from config/mozilla.cfg) — lock values against profile and user preferences. Production excludes AutoConfig and does not install its runtime files; development builds retain AutoConfig because they skip patch 017.

The categories below summarize what is protected and where the enforcement lives.

Concern Patch(es) Policy lockPref
Profile / on-disk content — SanitizeOnShutdown, DisableFormHistory, BrowserDataBackup: false browser.privatebrowsing.autostart, browser.cache.disk.*, media.cache_size, media.cache_size.cellular, browser.privatebrowsing.forceMediaMemoryCache, places.history.enabled, signon.rememberSignons, browser.formfill.enable, browser.sessionstore.*, browser.pagethumbnails.capturing_disabled, browser.shell.shortcutFavicons, dom.serviceWorkers.enabled, browser.backup.*, browser.profiles.enabled
Screenshots (built-in + OS capture) 001 DisableFirefoxScreenshots screenshots.browser.component.enabled
Screen / window / browser capture 002 — media.getdisplaymedia.enabled, media.getusermedia.browser.enabled, media.getusermedia.window.focus_source.enabled
Clipboard + drag-and-drop 003 — dom.allow_cut_copy, dom.event.clipboardevents.enabled
Downloads / Save As / wallpaper 004 — browser.download.useDownloadDir, browser.download.forbid_open_with, browser.download.always_ask_before_handling_new_types, browser.download.start_downloads_in_tmp_dir, browser.helperApps.deleteTempFileOnExit
Printing + print-to-PDF 005 PrintingEnabled=false print.enabled
Per-request attestation to proxy 006 — —
RAM-disk profile 007 (intentional stub — FDE on host instead) — —
Developer tools (incl. CLI debug flags) 008, 015 DisableDeveloperTools, BlockAboutConfig, BlockAboutProfiles, BlockAboutSupport, BlockAboutAddons devtools.policy.disabled, devtools.chrome.enabled, devtools.debugger.remote-enabled, devtools.debugger.prompt-connection
Branding 009 — —
Telemetry / diagnostics / remote rollouts 010 DisableTelemetry, DisableFirefoxStudies, DisableRemoteImprovements, DisableFeedbackCommands datareporting.policy.dataSubmissionEnabled, datareporting.healthreport.uploadEnabled, toolkit.telemetry.enabled, toolkit.telemetry.unified, app.normandy.enabled, app.shield.optoutstudies.enabled, nimbus.rollouts.enabled
Extensions (loading + install) 011 ExtensionSettings: {* blocked}, DisableSystemAddonUpdate xpinstall.enabled
Proxy TLS SPKI pinning 012 — —
Sync / Firefox Accounts 013 DisableFirefoxAccounts identity.fxaccounts.enabled (covered by policy)
Site whitelist / blacklist 014 — —
Argv + env-var stripping 015 — —
Command lines forwarded to a running browser 026 — —
Automatic restarts (crash safe mode, Windows Restart Manager) — — toolkit.startup.max_resumed_crashes, toolkit.startup.always_use_safe_mode, toolkit.winRegisterApplicationRestart
Window-title leak 016 — —
Camera / Microphone / Location / Notifications / VR — Permissions: Camera/Microphone/Location/Notifications/VirtualReality {BlockNewRequests: true, Locked: true}, Autoplay: block-audio-video geo.enabled, media.peerconnection.enabled, media.navigator.enabled
Cookies — in-memory, no third-party — Cookies: reject-foreign + Locked, SanitizeOnShutdown.Cookies (via policy)
DRM / EME — EncryptedMediaExtensions {Enabled: false, Locked: true} —
Password manager / form autofill — PasswordManagerEnabled: false, DisableMasterPasswordCreation, DisablePasswordReveal, DisableProfileImport, AutofillAddressEnabled: false, AutofillCreditCardEnabled: false signon.rememberSignons, signon.autofillForms, signon.generation.enabled, browser.contextual-password-manager.enabled, pref.privacy.disable_button.view_passwords, extensions.formautofill.addresses.*, extensions.formautofill.creditCards.*
Built-in AI / agent actions — AIControls: {Default: {Value: blocked, Locked: true}} browser.ai.control.*, browser.ml.chat.*, browser.ml.linkPreview.enabled, browser.ml.pageAssist.enabled, browser.smartwindow.enabled, browser.tabs.groups.smart.userEnabled
Bookmarks 019 NoDefaultBookmarks, DisplayBookmarksToolbar: never browser.urlbar.suggest.bookmark, browser.urlbar.shortcuts.bookmarks, browser.toolbars.bookmarks.visibility, browser.bookmarks.max_backups, browser.bookmarks.autoExportHTML
First-run / new-tab / stories / messaging — OverrideFirstRunPage, OverridePostUpdatePage, NoDefaultBookmarks, FirefoxHome, UserMessaging —
App + system-addon updates — DisableAppUpdate, DisableSystemAddonUpdate extensions.update.enabled, extensions.systemAddon.update.enabled, app.update.url.manual, app.update.url.details
HTTPS-only enforcement — HttpsOnlyMode: force_enabled —
Translation / search-suggest / DoH — AIControls.Default: blocked, SearchSuggestEnabled: false, DNSOverHTTPS {Enabled: false, Locked: true}, NetworkPrediction: false network.dns.disablePrefetch, network.dns.disablePrefetchFromHTTPS, network.prefetch-next, network.predictor.enable-hover-on-ssl, network.http.speculative-parallel-limit, browser.urlbar.suggest.history, browser.urlbar.suggest.bookmark
Fingerprinting + tracking protection — — privacy.resistFingerprinting, privacy.resistFingerprinting.block_mozAddonManager, privacy.trackingprotection.enabled, privacy.trackingprotection.socialtracking.enabled, privacy.trackingprotection.cryptomining.enabled, privacy.trackingprotection.fingerprinting.enabled
External protocol handlers (mailto/tel/sms/…) — — network.protocol-handler.external-default, network.protocol-handler.external.mailto, network.protocol-handler.external.tel, network.protocol-handler.external.sms
Share to OS share pane / Mozilla service — — browser.menu.share_url.allow, browser.toolbars.share-button.enabled, browser.contentsharing.enabled
Phone-home endpoints (Safe Browsing, captive-portal, Remote Settings, OCSP, blocklist, etc.) — DisableRemoteSettingsAndAcceptSecurityConsequences browser.safebrowsing.*, network.captive-portal-service.enabled, network.connectivity-service.enabled, browser.discovery.enabled, security.OCSP.*, security.remote_settings.*, extensions.blocklist.enabled, extensions.update.url, network.trr.confirmationNS
Built-in VPN / local-network access — IPProtectionAvailable: false, LocalNetworkAccess browser.ipProtection.enabled, network.lna.*
Visual Search — VisualSearchEnabled: false browser.search.visualSearch.featureGate
Context-menu search for selected/link text 023 — —
Windows process-memory dumps 025 — —
Web-platform capability APIs (Share / FS Access / Serial / MIDI / Payments / Push / Notifications / SpeechSynth / Battery / Sensors / WebTransport / file://) — — dom.webshare.enabled, dom.fs.enabled, dom.webserial.enabled, dom.webmidi.enabled, dom.payments.request.enabled, dom.push.enabled, dom.webnotifications.enabled, media.webspeech.synth.enabled, media.webspeech.recognition.enable, dom.battery.enabled, device.sensors.enabled, network.webtransport.enabled, network.protocol-handler.expose.file

Per-deployment configuration (config/site-config.json) drives the compile-time switches and the baked-in bookmarks:

  • clipboard_sites — hosts that may use the in-browser internal clipboard (patch 003). Empty list = no in-browser copy/paste anywhere.

  • site_whitelist — if non-empty, only these hosts (and subdomains) may be loaded over HTTP(S); everything else is blocked, with top-level navigation showing a DenBrowser error page (patch 014).

  • site_blacklist — used only when whitelist is empty.

  • proxies — the attestation proxies this build talks to, one entry per partner application (patches 006 + 012). See Attestation proxies below.

  • bookmarks — curated shortcuts shown as tiles on the custom new-tab and home page (patch 018; the home button and startup page are pointed at about:denbrowserhome via a browser.startup.homepage lock). Each entry is an object with required title and url; url must be http(s). At build time (build.sh Step 2.7) the list is rendered as static HTML tiles into denbrowser-newtab.html (titles/URLs are HTML-escaped, URLs restricted to http(s) since the tiles are injected as raw HTML, and each tile uses target="_self" so it opens in the current tab). An empty/absent list shows a "No shortcuts configured" placeholder. These are not Firefox bookmarks: the activity-stream new-tab page does not render in this permanent-private build, and the bookmark store itself is read-only (patch 019), so there is no editable bookmark UI. Example:

    "bookmarks": [
      { "title": "Intranet", "url": "https://intranet.example.com", "image": "intranet.png" },
      { "title": "Docs", "url": "https://docs.example.com" }
    ]

    An entry's optional image replaces the tile's letter badge; without one, or with a blank value, the badge is kept. An absolute path is used as is, a value containing a path separator is relative to the repo root, and a bare filename is read from config/shortcut-images/. Supported types are PNG, JPEG, GIF, WebP, ICO and SVG (detected from the file content), up to 256 KiB each; the image is embedded in the page as a data: URI.

Attestation proxies (site-config.json)

A deployment normally fronts several partner applications, each behind its own attestation proxy with its own attestation keypair and TLS cert — no partner should be able to verify another partner's tokens. The proxies array is the build-time map from domains to the proxy that fronts them, and it drives both compile-time proxy features at once:

  • the attestation public key used to encrypt a request's token (patch 006), and
  • the TLS SPKI pin enforced when connecting to those domains (patch 012).
"proxies": [
  {
    "name":       "partner-a",
    "domains":    ["app.partner-a.example.com", "cdn.partner-a.example.com"],
    "attest_key": "partner-a-public.der",
    "tls_cert":   "partner-a-tls.crt"
  },
  {
    "name":       "partner-b",
    "domains":    ["partner-b.example.com"],
    "attest_key": "partner-b-public.der",
    "tls_cert": "partner-b-tls.crt"
  }
]
  • name — label for this proxy; appears in browser log messages and names the key material. Letters/digits/._-, unique across entries.
  • domains — every hostname this proxy fronts. Matching is exact or a subdomain of a listed domain (partner-a.com also claims app.partner-a.com), the same rule site_whitelist uses.
  • attest_key — the proxy's attestation public key in DER (scripts/gen-attest-key.sh --name <name>). A bare filename is read from build/; a path is taken relative to the repo root.
  • tls_cert / tls_spki_sha256 — the TLS pin, given either as a cert file the build hashes for you (scripts/gen-proxy-tls.sh --name <name>) or as the 32-byte sha256 itself in hex or base64, for production hosts whose cert never lands on the build machine. Set at most one. An entry with neither is still attested but not pinned (the build warns).

Setting up one partner end-to-end:

./scripts/gen-attest-key.sh --name partner-a
./scripts/gen-proxy-tls.sh  --name partner-a --host app.partner-a.example.com
# add the printed entry to config/site-config.json, then:
./build.sh

# configure that partner's proxy in proxy/partner-a.toml:
# [proxy]
# listen   = "0.0.0.0:8443"
# upstream = "app-backend.partner-a.internal:443"
# tls_cert = "../build/partner-a-tls.crt"
# tls_key  = "../build/partner-a-tls.key"
#
# [attestation]
# private_key = "../build/partner-a-private.pem"
#
# then run it:
cd proxy && cargo run --release -- --config partner-a.toml

Each proxy instance is a separate process with its own listener, key, cert, and proxy.toml; the proxy binary itself is unchanged by the multi-proxy model. Rotating one partner's key or cert only touches that partner's entry, but it does still require a DenBrowser rebuild — the pin and the public key are compiled in by design.

Proxy runtime configuration (proxy/proxy.toml)

The attestation proxy loads a required TOML config file for operational settings tuned per deployment without rebuilding. It defaults to proxy.toml in the working directory (override with --config <path> or the DENBROWSER_CONFIG env var); the proxy exits on startup if the file is missing or unparseable, so it never comes up with unintended (e.g. mTLS-disabled) settings. Copy the annotated proxy/proxy.example.toml to proxy/proxy.toml and edit it. This config is separate from config/site-config.json (which feeds the browser build).

Any string value can take part or all of itself from an environment variable, written ${NAME}, so one file can serve several deployments:

[proxy]
upstream = "${UPSTREAM_HOST}:443"
tls_cert = "${DENBROWSER_CERTS}/partner-a-tls.crt"

Only the braced form is expanded. A bare $NAME, and any other $, stays as written; $${ is a literal ${. So only a value that contains ${ can read differently than it did before this feature existed. A name is ASCII letters, digits and _, not starting with a digit, so a Windows variable such as ProgramFiles(x86) cannot be referenced. On Windows, names ignore case, as they do everywhere on Windows: ${path} finds PATH. References are resolved in every section, including one switched off with enabled = false, so a disabled section still needs its variables set.

Startup aborts in any of these cases:

  • A variable is unset, empty, or not valid Unicode. An empty upstream_ca or a blank [logging].dir means "off", so a missing variable must not quietly become one. A setting that is meant to be empty is written as "" in the file.
  • A value begins or ends with whitespace or an invisible character. This is usually the trailing newline or byte-order mark of a file the value was read from, as with Kubernetes --from-file secrets. It is rejected rather than trimmed or kept, because the proxy cannot tell an accident from intent: a padded rate-limit pattern never matches, and a padded entry in a log directive list such as "${LEVEL},foo=debug" silences logging. A value that really begins or ends with a space, such as a passphrase, has to be written in the file (or, for the TLS key passphrase, in tls_key_passphrase_file).
  • A reference is malformed, such as ${NAME:-default}.

The error lists every setting that failed, each with the first variable that failed in it. Two cases name the setting only: a malformed reference, and a reference inside tls_key_passphrase, where the text between the braces could be part of a passphrase written inline.

Expansion happens after the file is parsed, so a value is never read as TOML. Quotes, backslashes and Windows paths in it need no escaping, and a ${ inside a value is not expanded again. A variable fills one string, so it cannot supply several list entries: write ["${A}", "${B}"], not a single variable holding a,b. Numbers and booleans cannot use a variable.

Core proxy settings ([proxy]) are required and select where this instance listens, where it forwards verified requests, and which TLS identity it presents:

[proxy]
listen = "0.0.0.0:8081"
upstream = "app-backend.partner-a.internal:443"
tls_cert = "/etc/denbrowser/partner-a-tls.crt"
tls_key = "/etc/denbrowser/partner-a-tls.key"

listen and upstream use host:port form. The upstream connection is TLS; tls_cert and tls_key are the PEM identity presented to the browser, and that certificate's SPKI must match the pin compiled into DenBrowser for this proxy.

Upstream certificate and hostname verification are enabled by default, using Pingora/OpenSSL's system CA trust store. To trust a private upstream CA, set upstream_ca in [proxy]:

[proxy]
upstream = "app-backend.partner-a.internal:443"
upstream_ca = "/run/secrets/partner-a-upstream-ca.pem"

The PEM bundle should include the upstream's root CA and may contain multiple CA certificates. It replaces system trust for upstream connections, and the upstream certificate must still match the hostname in upstream. Omit upstream_ca or set it to "" to retain the default trust store. This setting is independent of [mtls].client_ca and [machine_identity].machine_ca.

The bundle is read once at startup; restart the proxy after changing it. An unreadable, invalid, or certificate-free bundle aborts startup. Relative paths resolve against the proxy's working directory. In Docker, mount the bundle into the proxy container and use its container path. The development-only --insecure-upstream / DENBROWSER_INSECURE_UPSTREAM switch still disables both upstream certificate and hostname verification, even with a CA bundle configured.

The key may be passphrase-protected, in either PEM form OpenSSL writes (PKCS#8 ENCRYPTED PRIVATE KEY or the legacy Proc-Type: 4,ENCRYPTED). Supply the passphrase with one of two optional settings: tls_key_passphrase_file, a file whose first line is the passphrase, or tls_key_passphrase inline. Prefer the file — it keeps the secret out of the config and fits Docker or Kubernetes secrets and systemd credentials:

[proxy]
tls_key = "/etc/denbrowser/partner-a-tls.key"
tls_key_passphrase_file = "/run/secrets/partner-a-tls-key-passphrase"

Setting both is a startup error, and so is an encrypted key with neither: the proxy names the missing setting rather than stopping at OpenSSL's interactive passphrase prompt, which would hang a foreground run and abort a service.

Attestation key ([attestation]) is also required — it names this proxy's EC P-256 attestation private key, the private half of the public key baked into the browser build for it:

[attestation]
private_key = "/etc/denbrowser/partner-a-private.pem"

Startup aborts if it is unset, unreadable, or not a parseable EC key, rather than bringing up a proxy that would reject every attested request. Relative paths resolve against the proxy's working directory, as client_ca does. There is no CLI flag and no default path: each partner's proxy process names its own key in its own config file, so one partner can never decrypt another's tokens, and a deployment that forgets the key fails loudly instead of silently picking up whatever happens to sit at a default location.

Key rotation (previous_private_key) is optional. A new browser build carries a new public key; to keep the build before it working, also name that build's private key:

[attestation]
private_key          = "/etc/denbrowser/partner-a-private.pem"
previous_private_key = "/etc/denbrowser/partner-a-private-previous.pem"

The proxy tries private_key first and falls back to previous_private_key; a client IP whose last accepted token used the previous key has that key tried first. Unset or blank means a single key. When set, startup aborts if the file is unreadable, not a parseable EC key, or the same key as private_key. Each audit record's attestation.key (current or previous) says which key the token verified under, so the log shows when the previous build is no longer in use; remove the setting then to stop accepting it. Keep a copy of the old <name>-private.pem before regenerating a key with the same name (gen-attest-key.sh --force overwrites it). The previous build also pins the proxy's TLS key (patch 012), so it only keeps working while that key is unchanged.

Request bodies. A request whose token binds a body hash is buffered and hashed before the upstream is contacted, so it is capped at Pingora's 64 KiB retry buffer: a larger bound body is answered 413. The browser sends bigger uploads unbound (the token carries the unbound marker instead of a hash) and those stream straight through with no size cap. The 64 KiB boundary is pinned by test/attestation/test_roundtrip.py.

HTTP/2 to the browser. The listener offers HTTP/2 with HTTP/1.1 as the fallback (ALPN h2, http/1.1), the mirror image of the upstream leg, which prefers HTTP/2 and falls back to HTTP/1.1. The two legs negotiate independently and Pingora translates between them, so a browser on HTTP/2 is fronted for an HTTP/1.1-only backend, and vice versa, with nothing to configure. Attestation is protocol-agnostic: over HTTP/2 the host comes from :authority, which the browser derives from the same URL it signs. Two things differ from HTTP/1.1: an error response such as 403 resets only that stream instead of closing the connection, and Pingora's HTTP/2 server defaults apply, 100 concurrent streams per connection and a 64 KiB request-header list, which the machine certificate header fits comfortably. The HTTP/2 path is covered by test/attestation/test_roundtrip.py, which drives it with curl.

Pingora 0.9.0 behaviour. The proxy inherits a few defaults from Pingora 0.9.0 that were reviewed and accepted as-is:

  • Hop-by-hop request headers (Connection, TE, Keep-Alive, Upgrade, Proxy-*) and any header named in Connection: are stripped before the upstream sees the request. The attestation headers are end-to-end and are stripped by the proxy itself, as before.
  • Malformed requests (two Host headers, userinfo in Host, an absolute-form target whose authority differs from Host) are answered 400 before attestation or rate limiting run. A Connection header naming a protected header such as Host, or ten or more names, is answered 400 after attestation passed; that request's nonce is spent, and its audit record is a failed forward with reason request_malformed (risk high) and status 400.
  • A POST that fails on a reused upstream connection is answered 502 rather than retried, so the backend can never receive a side-effecting request twice. The browser mints a fresh token per request, so retrying is safe.
  • An absolute-form request target (GET https://host/p?q HTTP/1.1) is bound as /p?q, matching what the browser signs. Browsers send origin-form to an origin server, so this only matters to hand-written clients.

Rate limiting ([rate_limiting]) throttles requests per origin IP. A bad config aborts startup rather than starting unprotected, and a request over any applicable limit is answered 429 before attestation or the upstream is touched, so floods are shed cheaply.

  • enabled — master on/off switch for the whole feature.

  • window_secs / max_requests — the universal cap: at most max_requests per window_secs from any single client IP, across every request. Set either to 0 to disable the universal cap and rely on rules alone.

  • [[rate_limiting.rules]] — per-URL-pattern limits, each with its own window and cap and its own counter keyed by origin IP, so a burst against one pattern is throttled independently of the others and of the universal cap. pattern is a glob matched against "{host}{path}" (where * matches any run of characters, including /); rules are evaluated top-to-bottom, first match wins. Counting uses pingora-limits' sliding-window estimator.

    [rate_limiting]
    enabled = true
    window_secs = 1        # universal: 100 requests / second / IP
    max_requests = 100
    
    [[rate_limiting.rules]]
    pattern = "example.com/secrets/*"   # 5 requests / minute / IP
    window_secs = 60
    max_requests = 5

    Note: when clients share an egress IP (NAT), the per-IP counters are shared — size caps for the deployment's real per-IP concurrency.

Baseline mTLS ([mtls]) requires every client to authenticate with a certificate at the TLS handshake — one of four orthogonal layers, replacing none of the others:

  • mTLS authenticates the user to the proxy (client → proxy).

  • TLS SPKI pinning (patch 012) authenticates the proxy to the browser (proxy → client); the browser pins the proxy's cert, defeating MITM even by a validly-CA-issued cert. mTLS is the opposite direction and does not replace it.

  • Attestation (ECIES headers) attempts to prove the request came from a genuine DenBrowser build and binds it against replay/tampering — properties a client cert doesn't provide. mTLS proves who holds a key but not what software produced the request, so the two are complementary.

  • Machine identity ([machine_identity], below) names the workstation the request came from — which none of the other three establish.

    When [mtls] enabled = true, the listener is configured with SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT against client_ca, so a client with no certificate or one that doesn't chain to the CA is rejected at the handshake and never reaches the request path. Disabled by default (no client certificate requested; behaviour identical to before). A bad config (missing/unreadable/empty CA bundle) aborts startup.

    The listener also advertises client_ca in the TLS CertificateRequest, so a browser holding more than one client certificate can filter its store to the identity this proxy accepts. DenBrowser locks security.default_personal_cert to Select Automatically. Provision only one eligible certificate for each proxy: a CA list cannot distinguish two certificates issued by the same CA, and Firefox may choose either one. This preference applies to all allowed HTTPS sites, so any such site that requests client authentication may receive an eligible certificate without a picker.

    [mtls]
    enabled = true
    client_ca = "/etc/denbrowser/user-ca.pem"

    TLS sessions are resumable. The listener sets a session-ID context, which OpenSSL requires before it will resume a client-authenticated session; without one, every resumption attempt by a browser that cached a session failed its handshake and cost a second, full handshake. A resumed handshake does not re-verify the client certificate: the identity recorded for the connection is the one verified at the full handshake that created the session, and sessions live for OpenSSL's default of two hours. No 0-RTT data is accepted, and a proxy restart invalidates every session.

Machine identity ([machine_identity]) records which workstation a request came from. The browser sends its machine certificate as base64 DER in X-DenBrowser-Machine-Cert on every request claimed by the configured attestation proxy table; the proxy verifies it and writes the hostname into the audit trail.

On Windows, patch 024 obtains that certificate directly from the operating system certificate manager. It searches the managed and ordinary LocalMachine\MY and CurrentUser\MY (Personal) stores for a currently valid certificate whose Common Name exactly matches the workstation's FQDN, DNS hostname, or NetBIOS name. Managed stores and machine scope take precedence; during enrollment overlap, the matching certificate with the latest expiry is used. The selected public DER is cached until DenBrowser restarts. No private key is opened or used. Non-Windows builds omit the header.

Provisioning should leave only the intended issuer's certificate with a matching workstation CN in the highest-priority populated store. If unrelated issuers provide multiple matching certificates there, the browser cannot know which CA the proxy trusts; it chooses the latest-expiring certificate and the proxy safely rejects it if it chains to the wrong CA.

For a local Windows test, generate the certificate with the machine's real DNS name and import the public certificate into either Personal store:

# Current user (no elevation):
certutil -user -addstore My build\machine-cert.crt

# Or, from an elevated shell, local machine:
certutil -addstore My build\machine-cert.crt

Production certificates should normally be enrolled through enterprise policy. Restart DenBrowser after enrollment or renewal so it refreshes the cached DER.

It rides a header rather than the TLS handshake because a handshake carries exactly one client certificate chain and the user certificate already occupies it. There is no second handshake to use either — the proxy is a reverse proxy, so the browser opens one ordinary HTTPS connection to the origin.

Three checks, cheapest first, so a bad certificate never reaches the resolver:

  • the certificate chains to machine_ca (which must differ from [mtls].client_ca — the two identities are told apart by issuer, and startup rejects an overlap);

  • its Common Name matches an allowed_hostnames glob (empty accepts any name the CA signed);

  • the name forward-resolves to the connecting address.

  • What this proves: the name was issued by your CA and is being presented from an address that name points at.

  • What it does not prove: possession of the machine private key. A certificate is a public document and this layer performs no proof of possession, so it is weaker than attestation against a compromised endpoint. The hostname is therefore recorded, not used to grant anything.

    Deployment prerequisite: because the forward-DNS check is unconditional, clients must reach the proxy on their own addresses. Behind NAT, a VPN concentrator, or any shared egress the proxy sees the gateway address — which is in no workstation's A record — and every request is rejected. The Compose integration stack avoids its published-port NAT by running its test client directly on the proxy's ingress bridge with a Docker DNS alias.

    Lookups are cached per hostname, so the check costs roughly one query per workstation per TTL — about 0.07 queries/s for a 1,000-machine fleet at the four-hour default, not one query per request. Failures are cached on a shorter TTL, and a stale entry keeps covering a resolver outage for a bounded grace period; a cold cache with a failing resolver still rejects.

    Enabled by default. Omitting [machine_identity] therefore fails closed at startup until machine_ca is configured; deployments intentionally not using this layer must set enabled = false explicitly. A bad config (missing/unreadable/empty CA bundle, unparseable or uppercase hostname glob, CA overlapping the mTLS CA) aborts startup.

    [machine_identity]
    enabled           = true
    machine_ca        = "/etc/denbrowser/machine-ca.pem"
    allowed_hostnames = ["ws-*.corp.example.com"]

Attestation bypass ([proxy_bypass]) is an opt-in escape hatch that lets trusted infrastructure (health checks, internal automation) that can't run the DenBrowser attestation client reach the upstream. It builds on baseline mTLS (and requires [mtls] enabled): the client certificate is already verified at the handshake, so bypass adds only two conditions on top of that verified identity, and is default-deny:

  • enabled — master switch. When off, every request is attested.

  • allowed_ip_ranges — CIDR ranges; the client's source IP must be inside one.

  • allowed_subjects — the mTLS-verified cert's CN or a SAN DNS entry must be on this allowlist (so one CA can issue many user certs while only named identities may bypass).

    A request is forwarded straight upstream (skipping attestation) only when the source IP and the cert-subject checks both pass; any miss falls through to normal attestation. A bad config (bypass enabled without mTLS, empty ranges/allowlist, invalid CIDR) aborts startup.

    [proxy_bypass]
    enabled = true
    allowed_ip_ranges = ["10.0.0.0/8"]
    allowed_subjects = ["health-checker"]

Logging ([logging]) controls where the proxy records what it did. The browser build has telemetry and diagnostics patched out (patch 010), so the proxy is the only component that records anything and this output is the audit trail. The whole section is optional; omitting it logs at info to stderr only.

Every line, in the file and on stderr, is one JSON object. Each HTTP request produces exactly one audit record when it finishes, carrying the proxy and client IPs, the user and machine certificate DNs, the attestation nonce, an ISO 8601 UTC timestamp, the request and response body bytes, and, for a refusal, the pipeline stage it failed at, a reason code, and a risk level (low for a browser that sent no DenBrowser headers at all, up to critical for a token bound to a different request or body). URL paths and query strings are never recorded. Everything else (startup, pingora diagnostics) is a system record with the same envelope. The schema, an example of every event type, the risk table, and jq recipes are in proxy/audit/.

  • dir — directory for rotating log files. Empty (the default) means stderr only. Created at startup if absent; if it is set but cannot be created or written the proxy exits rather than run with no audit trail. Relative paths resolve against the working directory, as tls_cert does.

  • file_prefix — base filename; the rotation stamp is appended, giving denbrowser-proxy.log.2026-08-01. Default denbrowser-proxy.log.

  • level — RUST_LOG syntax: a bare level (trace/debug/info/warn/ error/off) or directives such as "info,denbrowser_proxy=debug". Default info, which records accepted and rejected requests; warn records only refusals and failed requests. Audit records use the target denbrowser_proxy::audit, so "warn,denbrowser_proxy::audit=info" keeps every audit record while quieting everything else.

  • rotation — "minutely", "hourly", "daily" (default), or "never".

  • max_files — rotated files to keep, oldest deleted first. Default 14. 0 keeps everything, leaving disk growth unbounded.

  • stderr — mirror to stderr as well (default true), for journald capture and local development. Both outputs carry the same JSON lines.

    [logging]
    dir = "/var/log/denbrowser"
    level = "info"
    rotation = "daily"
    max_files = 14

    Setting RUST_LOG in the environment overrides level for that run, which is the quick way to raise detail without editing the file:

    RUST_LOG=debug denbrowser-proxy --config /etc/denbrowser/proxy.toml

    Because the proxy logs through tracing and bridges the log facade into it, raising the level also surfaces Pingora's own internals in the same file.

    Requests that Pingora refuses before attestation runs (malformed request line, duplicate or ambiguous Host, bad framing) are answered 400 with no audit record and, since Pingora 0.9.0, only a debug system line, so at info they leave no trace. Do not enable pingora_proxy=debug in production to see them: that line carries the raw request, attestation token and machine certificate included.

Upgrade note. Earlier builds initialised env_logger with no default filter, so the effective level was error and — since the proxy emits nothing at that level — a stock deployment logged nothing at all. Existing config files keep working untouched, but they will start producing info output on stderr where they were previously silent.

Upgrade note (JSON audit log). The free-text accepted — … / rejected — … lines are replaced by JSON records; anything parsing them needs updating. proxy/audit/README.md maps each old line to its new reason code.


Opening links from other applications (den: links)

On Windows, other applications can open a page in DenBrowser with a den: link that wraps an ordinary web URL:

den:https://intranet.example.com/reports
den://https://intranet.example.com/reports

Prefer the first form. A standard (WHATWG) URL parser reads https as the host of den://https://… and rewrites the link to den://https//…, which DenBrowser rejects. Browsers that follow the URL standard parse links that way, so the second form only works from applications that hand the link to Windows unparsed.

How it is handled. Windows starts denbrowser.exe -osint -url "<link>". The patch 015 launcher strips the den: or den:// prefix once, then applies the same check as any other OS launch: the result must be an http:// or https:// URL with a host, no userinfo, at most 8192 characters, and none of ASCII space or control characters, ", <, > or \. Other characters, including non-ASCII ones, are left to the browser's URL parser. Anything else (den:file:…, den:about:…, nested den:den:…, a wrapped URL that is itself percent-encoded such as den:https%3A%2F%2F…, extra arguments) exits with code 127 and opens nothing. If DenBrowser is already running, the new instance hands the checked URL to it, and the running browser accepts that hand-off only in the same -osint -url http(s)://… shape (patch 026).

Two caller behaviours change the link before the launcher sees it:

  • Windows' command-line parsing removes double quotes, so a link such as den:https://ex"ample.com from a local application opens https://example.com. A quote followed by a space splits the link into extra arguments, which the launcher rejects. The URL opened can therefore differ from the link text another program displayed or filtered on.
  • Some hosts, such as Internet Explorer mode and applications that embed the WebBrowser control, percent-decode the link before handing it to Windows. A %20 then becomes a space and the launch is rejected.

The browser only ever receives the plain https:// URL, so the site whitelist/blacklist (patch 014) and attestation apply exactly as for a typed address. den: links inside web pages are not handled: the locked network.protocol-handler.external-default pref stops DenBrowser handing unknown schemes back to Windows.

Registering the scheme. Registration is Windows registry state, so it belongs in deployment rather than the build. From an elevated shell on each workstation, or as an Intune/SCCM script:

.\scripts\register-den-protocol.ps1 -BrowserPath 'C:\Program Files\DenBrowser\denbrowser.exe'

The script uses only registry cmdlets, so it also runs under Constrained Language Mode on WDAC-enforced machines. -Scope User writes HKCU instead (no elevation; for testing, and refused under the SYSTEM account), -Unregister removes the registration (run it when uninstalling DenBrowser), and -WhatIf previews either. An existing den registration that does not point at a denbrowser.exe is left alone unless you pass -Force. To deploy with Group Policy Registry Preferences instead, create these values:

Key under HKLM\SOFTWARE\Classes Value Data
den (Default) URL:DenBrowser Link
den URL Protocol (empty string)
den\DefaultIcon (Default) C:\Program Files\DenBrowser\denbrowser.exe,0
den\shell\open\command (Default) "C:\Program Files\DenBrowser\denbrowser.exe" -osint -url "%1"

The command must be exactly -osint -url "%1"; the launcher rejects every other argument shape. Test a registration from a normal, non-elevated user session with Start-Process 'den:https://example.com'. From a UAC-elevated shell nothing opens, because the launcher will not start the browser with an elevated token.

Things to know:

  • Windows prefers a per-user (HKCU) den registration over the machine-wide one, so a user can point their own den: links at another program. That does not change what DenBrowser accepts. The script only warns when the account running it has one, so it cannot see end users' overrides when run by deployment tooling.
  • Any local application, and any web page in another browser, can open DenBrowser at an arbitrary http(s) URL. Browsers ask before opening an external application, but the user can tick "always allow" for a site, and policies such as Chrome and Edge's AutoLaunchProtocolsFromOrigins can skip the prompt, after which that site opens den: links without asking. With a whitelist configured the URL must still be on it; in blacklist or no-filter mode it can be any site. If DenBrowser is already running and signed in, the link is a top-level GET carrying the user's session, the same exposure as clicking a link in any browser, so internal applications should not change state on GET.
  • Windows only. macOS would need a CFBundleURLTypes entry in the app's Info.plist plus unwrapping in BrowserContentHandler.sys.mjs, because the patch 015 launcher does not run there.

Patches

Patches apply in lexicographic order via scripts/apply-patches.sh. Each patch file's header has the full rationale; the summary below is intended for navigation.

# Name Summary
000 fix-bindgen-basic-string-view Upstream build fix for libclang/bindgen on recent toolchains. No security effect.
001 disable-screenshots Block built-in screenshot UI and exclude the window from OS screen-capture (NSWindowSharingNone on macOS, WDA_EXCLUDEFROMCAPTURE on Windows).
002 disable-screenshare Unconditionally reject getDisplayMedia() and legacy getUserMedia({video:{mediaSource:"screen"}}) at both JS and C++ layers.
003 restrict-clipboard Block clipboard read/write and drag-to-copy everywhere while retaining trusted internal tab transport for reordering; blocked drags complete Firefox's normal session cleanup, and native tab payloads contain no page URL. Includes an optional in-process clipboard for clipboard_sites and non-modal feedback for blocked user copy attempts.
004 restrict-downloads Cancel every file-save path before persistence or picker state is created; own the shared, deduplicated four-second notification bar for blocked save, print, copy, and DevTools actions.
005 disable-printing Compile printing out with Firefox's real --disable-printing option and retain eight source-level enforcement layers for custom printing-enabled builds, with feedback before dialog, clone, service, or IPC initialization.
006 attest-requests Inject per-request ECIES attestation headers (v2: binds nonce + ts + host + method + path + body hash) into outbound HTTP/HTTPS requests for the Pingora proxy to verify. Carries a build-time table of N proxies (domains → attestation key + TLS pin, generated from site-config.json's proxies) and attests with the key of the proxy claiming the request's host; hosts no proxy claims are left untouched.
007 ramdisk-profile Intentionally not implemented (STUB). The header documents why: PBM + locked disk-cache prefs + SanitizeOnShutdown already keep content off disk, and the residual swap/hibernation risk needs FDE on the host — see Deployment requirements.
008 disable-devtools Hardcode policy backstops, guard direct loader initialization, and turn blocked keyboard shortcuts into non-modal feedback before the full DevTools loader or toolbox starts.
009 denbrowser-branding DenBrowser branding directory (icons, brand strings, Windows installer assets, Visual Elements manifest).
010 disable-diagnostics Replace TelemetryReportingPolicy.dataSubmissionEnabled getter with return false; short-circuit TelemetryController.setupTelemetry to a no-op.
011 disable-extensions Filter addon install locations to SCOPE_APPLICATION only; throw on any AddonInstall.install() call regardless of state.
012 pin-proxy-tls Pin each attestation proxy's TLS SPKI (sha256) into the build — the pin column of patch 006's proxy table, so every partner proxy is pinned to its own cert; abort the handshake in AuthCertificateHook before any application data flows if the leaf cert doesn't match.
013 disable-sync Hardcode WeaveService.enabled and FXA_ENABLED to false; remove the Sync preferences pane and Synced-Tabs sidebar entries from the UI.
014 site-filter Compile-time whitelist/blacklist enforcement in nsDocShell::InternalLoad for the navigation error page and nsHttpChannel::AsyncOpen for all HTTP(S) requests; localize the "blocked page" message.
015 strip-blocked-args Strip security-sensitive CLI flags (--profile, --marionette, --remote-debugging-port, --screenshot, --headless, --safe-mode, --jsdebugger, …) and environment variables (MOZ_LOG, SSLKEYLOGFILE, MOZ_DISABLE_*_SANDBOX, MOZ_PROFILER_STARTUP*, MOZ_CRASHREPORTER*, …) from the process before any Firefox code reads them. Also unwraps registered den: links to the http(s) URL they carry (see den: links). Regenerate per-ESR via scripts/gen-015-patch.sh.
016 fixed-window-title Override nsCocoaWindow::SetTitle / Windows + GTK nsWindow::SetTitle to substitute the constant string "DenBrowser" for the page-supplied title — prevents page-title leakage through CGWindowListCopyWindowInfo, EnumWindows/GetWindowTextW, _NET_WM_NAME, etc.
017 compile-in-lockprefs Generate a C++ function (SetupDenBrowserLockdown) from config/mozilla.cfg and call it before default preference files are loaded. Locks every pref directly in libxul; production builds exclude AutoConfig and do not install its runtime files. Regenerate per-ESR (and on every mozilla.cfg edit) via scripts/gen-017-patch.sh. Skipped in --dev builds.
018 custom-newtab Replace the (blank-in-PBM) activity-stream new-tab page with a self-contained shortcuts page. about:denbrowserhome is registered as a plain chrome about: page via the C++ AboutRedirector (like about:robots/about:privatebrowsing), mapping to chrome://browser/content/denbrowser-newtab.{html,css}; it renders as untrusted content in a normal child process, sidestepping the newtab add-on entirely. AboutNewTab.newTabURL is pinned to it so every new tab loads it. Tiles are injected from site-config.json's bookmarks by build.sh Step 2.7.
019 readonly-bookmarks Make the bookmark store read-only: the seven public mutation methods of Bookmarks.sys.mjs (insert/insertTree/update/moveToFolder/remove/eraseEverything/reorder) reject before doing any work, so no caller can create, edit, or delete bookmarks.
020 about-dialog Updates the about or help dialog to reflect DenBrowser instead of Firefox. Also stamps the build id next to the version — 153.0esr (64-bit) (build a1b2c3d) — from the DenBrowser repo commit, injected into aboutDialog.js by build.sh Step 2.8 (-dirty suffix when the tree had uncommitted changes; hidden entirely when the build came from a non-git source tree).
022 clear-stale-movingtab Re-enable browser chrome synchronously when tab drag/drop terminates, preserve tabstrip-only drop animation, and recover malformed drags that would otherwise leave URL-bar, extension, and menu pointer events disabled.
023 disable-context-search Remove the normal and private "Search for..." content context-menu actions for both manually selected text and the visible label of an unselected link, using Firefox's source-level menu relevance logic rather than profile CSS or a runtime preference.
024 windows-machine-certificate-header Read the current workstation certificate's public DER from the Windows managed/ordinary Personal stores, require its CN to match the local Windows hostname, and attach it as X-DenBrowser-Machine-Cert only on requests claimed by the attestation proxy table. Caller-supplied values are stripped to prevent spoofing and redirect leakage; no private key is opened.
025 protect-process-memory Give the main browser and Firefox-owned Windows children a protected creation-time DACL that denies ordinary same-user PROCESS_VM_READ, DACL rewrite, and owner rewrite opens while preserving Firefox broker IPC. Blocks Task Manager/ordinary external minidumps; custom handle duplication, creator handles, enabled debug privilege, and kernel access remain out of scope. Skipped in --dev builds, which skip 015 that it builds on.
026 restrict-remote-commands Hold command lines that reach a running browser through its remote window (WM_COPYDATA, which any same-user process can send) to the launcher's grammar: the binary alone or -osint -url http(s)://…, checked with patch 015's URL rules. Legacy v2 messages and every other flag (-new-window, -chrome, -file, …) are refused, and the sender's working directory is ignored. Synthetic input, UI Automation, and a same-user process impersonating the remote window remain out of scope. Skipped in --dev builds, which skip 015 that it builds on.

About

A browser that attempts to restrict users from removing data from the browser or otherwise persisting it locally in controlled environments.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages