DEV Community

Shaarav Agarwal
Shaarav Agarwal

Posted on

Sandboxing Firefox with Firejail: a no-trace browser that can't read my files or reach my LAN

Sandboxing Firefox with Firejail: a no-trace browser that can't read my files or reach my LAN

Context

I wanted one Firefox profile for "dirty" browsing — opening links from sketchy sources — with a hard guarantee: malware or a malicious page in that profile cannot touch the rest of my system, nothing I do there survives the session, and the profile can't even read my real browsing data. But it also had to remember the things I deliberately set up: uBlock Origin, a theme, and a logged-in Gmail account. The setup is Pop!_OS 24.04, Firefox 152, firejail 0.9.72, a WiFi laptop with ufw active. The first symptom: firejail firefox opened a browser that "only seems to use its own default profile" — my extensions and logins were never there.

Approach

The root cause was a version skew in a default profile. Firefox 150+ moved its profile directory from ~/.mozilla/firefox/<profile> to the XDG path ~/.config/mozilla/firefox/<profile>. But the stock /etc/firejail/firefox.profile still whitelists only the legacy paths:

whitelist ${HOME}/.mozilla
whitelist ${HOME}/.cache/mozilla/firefox
Enter fullscreen mode Exit fullscreen mode

Inside the jail, ~/.config/mozilla is invisible — so Firefox can't see the real profiles and silently creates a brand-new throwaway profile every session. I found ~50 stale *.default cache dirs under ~/.cache/mozilla/firefox/, the exact fingerprint of that behavior.

The fix had a constraint that shaped everything: expose only the sandbox profile dir (whv2jdsv.sandbox), never the main profile (ggh3azgx.default-release). I tried three designs.

Attempt 1 — whitelist the whole ~/.config/mozilla: rejected immediately. It exposes default-release to the jail, violating the core separation requirement.

Attempt 2 — template/session snapshot model: a wrapper copies a saved config template into a fresh ephemeral session dir each launch, using firejail --private=<dir>. Crash-proof ephemerality by construction — the real home is never mounted at all. But it had a killer bug: Firefox's profile-lock files survive in the copied profile, so the next launch thinks the profile crashed and opens Safe Mode / Troubleshoot Mode — extensions disabled. Too many moving parts, and a genuinely confusing failure mode.

Attempt 3 (FINAL) — persistent profile + permanent private browsing. Keep the real sandbox profile persistent, force it into permanent private browsing with a user.js file (browser.privatebrowsing.autostart=true), and have firejail open that profile directly. Activity never persists because Firefox's own private mode handles it; config persists because extensions/themes/logins live in the profile. One command, no copying, no templates, no lock juggling — Firefox's native "keep my stuff, don't save my activity" mode.

Three artifacts make it work. ~/.config/firejail/firefox.local whitelists only the sandbox profile dir (survives package updates, unlike the /etc profile). The profile's user.js forces private browsing plus anti-fingerprinting prefs on every startup — re-applied each launch, so settings can't be accidentally toggled off. And ~/.local/bin/fsand is the single launch command: it refuses to run if the profile is already open, clears stale lock files (preventing Safe Mode), then runs firejail firefox -profile <sandbox> -no-remote. The -profile flag (not -P <name>) matters: the tight whitelist hides profiles.ini, so -P can't resolve.

Network hardening

The stock config (restricted-network yes) means firejail creates no network namespace — the sandbox shares the host's network stack and could reach the LAN: my router admin page at 192.168.29.1, other devices, everything. The netfilter ruleset meant to block this was silently not applied.

Flipping /etc/firejail/firejail.config to restricted-network no gave the sandbox its own virtual NIC on the only bridge — lxcbr0 (an LXC leftover, 10.0.3.1/24), sandbox at 10.0.3.200. Three problems surfaced in sequence:

Symptom Root cause Fix
No internet at all Bridge had DHCP but no NAT — traffic reached the bridge and stopped Host-side iptables MASQUERADE + FORWARD ACCEPT
"Can't open any link" while raw-IP tests passed DNS silently droppedufw's DEFAULT_INPUT_POLICY="DROP" with no rule for DNS from the bridge iptables -I INPUT -i lxcbr0 -p udp --dport 53 -j ACCEPT (TCP too)
LAN still reachable nolocal.net unreliable; no explicit LAN block DROP rules for 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12 at the top of FORWARD

The second row cost me an hour: raw TCP to 1.1.1.1:443 worked, so I concluded the network was fine — but raw-IP tests mask DNS failures, and the browser needs DNS for every link.

All of it is idempotent (iptables -C … || iptables -A …) in /usr/local/sbin/firejail-net-setup.sh, persisted by a systemd oneshot service. One gotcha: systemd does not support shell operators in ExecStart — the first version with inline || failed; the fix is a real bash script that systemd calls.

Architecture

graph LR
    A[fsand] --> B[firejail --profile=firefox]
    B --> C[whitelist: only whv2jdsv.sandbox]
    C --> D[default-release, ~/.ssh,<br/>~/Documents: invisible]
    B --> E[firefox -profile sandbox -no-remote]
    E --> F[user.js: permanent private browsing<br/>+ anti-fingerprinting + telemetry off]
graph LR
    A[Sandbox netns<br/>10.0.3.200] --> B[lxcbr0<br/>10.0.3.1]
    B --> C[NAT MASQUERADE → wlo1]
    C --> D[Internet]
    B -.->|DROP 192.168/16, 10/8, 172.16/12| E[LAN blocked]

Evidence

Verified from the host shell — the jail sees exactly one profile directory:

$ firejail --profile=firefox ls ~/.config/mozilla/firefox/
whv2jdsv.sandbox
$ firejail --profile=firefox ls ~/.ssh        # fails — invisible
Enter fullscreen mode Exit fullscreen mode

Network verification (final state): internet from the sandbox works (/dev/tcp/1.1.1.1/443 OK, DNS OK), the router at 192.168.29.1 is unreachable, and the host's normal Firefox is unaffected. The resulting FORWARD chain order: DROP 172.16 → DROP 10.0.0.0/8 → DROP 192.168 → ACCEPT (bridge→WiFi) → ACCEPT (RELATED, ESTABLISHED).

On maintenance burden: firefox.local lives in ~/.config/firejail/, so it survives firejail package updates by construction (apt never touches user config). I haven't hit a real update since setting this up, so that's reasoning, not a tested claim — a future firejail or Firefox change could shift behavior.

What went wrong

  • Throwaway profiles every session — the original symptom, from Firefox's XDG profile move vs the stale stock profile.
  • Safe Mode on every launch — stale lock files from killed sessions; the fsand wrapper clears them only when no live instance is using the profile.
  • "IP address 10.0.3.200 is already in use" — DHCP conflict when a lingering sandbox process held the lease.
  • iptables-persistent install failed — a Pop!_OS repo bug (pop-container-interactive depends on ufw; the resolver breaks under held packages). Bypassed with a systemd service instead of apt tooling.

One nice evolution: the session notes list "static IP for the sandbox" as unresolved — the --ip= CLI attempt failed with "cannot configure the IP address twice". The final firefox.local solved it via firejail profile options (net lxcbr0, ip 10.0.3.200, defaultgw, dns) plus ignore netfilter — the fix came from understanding why the first attempt failed.

Security vs. anonymity (the important caveat)

This setup is security + local privacy, not anonymity — and I verified that distinction explicitly. The sandbox uses the same public IP as the host (NAT), websites can fingerprint the browser even with zero cookies, and logging into Gmail identifies me to Google regardless of IP tricks. A VPN is a trust transfer, not anonymity: the provider sees your real IP and all traffic, and "no-log" is self-reported. True anonymity is Tor Browser — and logging into personal accounts defeats even that. The user.js closes one real leak: media.peerconnection.enabled=false blocks WebRTC, which can expose your real IP via STUN even behind a VPN. privacy.resistFingerprinting.pbmode=true spoofs the timezone to UTC — a privacy win with a visible cost (Gmail shows UTC times).

Known limitations

  • Rules are hardcoded to the WiFi interface (wlo1) — switching to ethernet requires editing the script.
  • The sandbox borrows LXC's bridge; running LXC containers later would share it (and the NAT).
  • ufw restarts can clear the iptables rules mid-session; the systemd service re-applies them at next boot.
  • A planned WireGuard split tunnel (sandbox-only VPN) is not yet implemented.

Lessons learned

  • Stock security profiles are documentation debt. firejail firefox.profile predates Firefox's XDG move; the symptom — "it uses its own default profile" — looked like a firejail bug until I read the actual whitelist.
  • The simplest architecture that meets the requirements wins. Attempt 2 (template + --private) was clever and fragile; Attempt 3 (persistent profile + native private mode) was boring and correct.
  • Test the failure you care about, not the one that's convenient. Raw-IP tests passed while DNS was silently dead.
  • Order matters in firewalls. ACCEPT before DROP means the DROP never runs.
  • Separate "security" from "anonymity" in your head and in your writing. Knowing what you built — and didn't — is what survives scrutiny.

Links

I'm open to Security and SecDevOps roles.

Top comments (1)

Collapse
 
dhruv_malaviya_cdcc71e595 profile image
Dhruv Malaviya

The XDG path migration breaking the stock profile whitelist is a great catch, and ~50 stale *.default cache dirs as the fingerprint is exactly how you'd confirm it rather than guess.

Worth naming what the jail is and isn't though: Firejail gives you namespaces and seccomp, which means the browser still shares your host kernel. For keeping a profile from reading your files, that's the right tool and a microVM would be absurd overkill.

The boundary only becomes thin if your threat model is actual malware rather than a nosy page , one kernel bug reaches everything in the namespace set. That's the case where a real VM earns its weight, which is what we run at Krova Cloud for workloads that shouldn't share a kernel with anything.

For daily dirty browsing on a laptop, your setup is the correct answer and I wouldn't change it.

Did you end up forking the profile, or whitelisting the new path?