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.
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.
-
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/mozconfigsets--with-compiler-wrapper=sccache, so the build will fail at configure if it's not onPATH. 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 sccacheorchoco install sccache
The default 50 GB cache size is set via
SCCACHE_CACHE_SIZEinconfig/mozconfig; adjust there if you're tight on disk. - macOS:
-
~20 GB free disk space for the source + build artifacts (plus up to 50 GB for the sccache cache, separately configurable)
# 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.shDenBrowser/
├── 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
DenBrowser's protections are layered across four enforcement points so any single bypass still leaves the rest in place:
- 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. - Source patches — applied to the Firefox source tree before build and included in the build output.
- Enterprise policies (
config/policies.json) — loaded at startup; lock UI surfaces and per-URL permissions. Cannot be overridden by profile state. - 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 atabout:denbrowserhomevia abrowser.startup.homepagelock). Each entry is an object with requiredtitleandurl;urlmust behttp(s). At build time (build.shStep 2.7) the list is rendered as static HTML tiles intodenbrowser-newtab.html(titles/URLs are HTML-escaped, URLs restricted tohttp(s)since the tiles are injected as raw HTML, and each tile usestarget="_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
imagereplaces 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 fromconfig/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 adata:URI.
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.comalso claimsapp.partner-a.com), the same rulesite_whitelistuses.attest_key— the proxy's attestation public key in DER (scripts/gen-attest-key.sh --name <name>). A bare filename is read frombuild/; 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.tomlEach 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.
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_caor a blank[logging].dirmeans "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-filesecrets. 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, intls_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 inConnection: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
Hostheaders, userinfo inHost, an absolute-form target whose authority differs fromHost) are answered400before attestation or rate limiting run. AConnectionheader naming a protected header such asHost, or ten or more names, is answered400after attestation passed; that request's nonce is spent, and its audit record is a failedforwardwith reasonrequest_malformed(riskhigh) and status400. - A
POSTthat fails on a reused upstream connection is answered502rather 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 mostmax_requestsperwindow_secsfrom any single client IP, across every request. Set either to0to disable the universal cap and rely onrulesalone. -
[[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.patternis a glob matched against"{host}{path}"(where*matches any run of characters, including/); rules are evaluated top-to-bottom, first match wins. Counting usespingora-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 withSSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERTagainstclient_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_cain the TLSCertificateRequest, so a browser holding more than one client certificate can filter its store to the identity this proxy accepts. DenBrowser lockssecurity.default_personal_certtoSelect 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.crtProduction 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_hostnamesglob (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 untilmachine_cais configured; deployments intentionally not using this layer must setenabled = falseexplicitly. 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, astls_certdoes. -
file_prefix— base filename; the rotation stamp is appended, givingdenbrowser-proxy.log.2026-08-01. Defaultdenbrowser-proxy.log. -
level—RUST_LOGsyntax: a bare level (trace/debug/info/warn/error/off) or directives such as"info,denbrowser_proxy=debug". Defaultinfo, which records accepted and rejected requests;warnrecords only refusals and failed requests. Audit records use the targetdenbrowser_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.0keeps everything, leaving disk growth unbounded. -
stderr— mirror to stderr as well (defaulttrue), 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_LOGin the environment overrideslevelfor 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
tracingand bridges thelogfacade 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 answered400with no audit record and, since Pingora 0.9.0, only adebugsystem line, so atinfothey leave no trace. Do not enablepingora_proxy=debugin production to see them: that line carries the raw request, attestation token and machine certificate included.
Upgrade note. Earlier builds initialised
env_loggerwith no default filter, so the effective level waserrorand — 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 producinginfooutput 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.mdmaps each old line to its new reason code.
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.comfrom a local application openshttps://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
%20then 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)
denregistration over the machine-wide one, so a user can point their ownden: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
AutoLaunchProtocolsFromOriginscan skip the prompt, after which that site opensden: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
CFBundleURLTypesentry in the app'sInfo.plistplus unwrapping inBrowserContentHandler.sys.mjs, because the patch 015 launcher does not run there.
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. |