DEV Community

Cover image for Chromium in Docker without --no-sandbox: what actually breaks
Vitalii Nemudryi
Vitalii Nemudryi

Posted on

Chromium in Docker without --no-sandbox: what actually breaks

Disclosure up front: I'm Vitalii, founder of PDFik, a hosted URL/HTML-to-PDF API. The product shows up once near the end, clearly marked. The rest is what running sandboxed Chromium in production actually looks like — including the part where our own security audit got it wrong.

If you have ever put headless Chromium in a container, you have probably met one of these two messages:

Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted
Enter fullscreen mode Exit fullscreen mode
FATAL:zygote_host_impl_linux.cc - No usable sandbox! […]
If you want to live dangerously and need an immediate workaround, you can try using --no-sandbox.
Enter fullscreen mode Exit fullscreen mode

And you have certainly seen the standard fix, repeated in countless Dockerfiles, quickstarts and accepted Stack Overflow answers: pass --no-sandbox. It makes the error go away immediately, so it spread everywhere — to the point where the flag reads like a required incantation for "Chrome in Docker" rather than what it actually is: switching off the browser's main defense against the content it renders.

Even our own security audit believed the myth

We run a PDF-rendering service: worker pods pull jobs from a queue and render customer-submitted URLs or HTML in headless Chromium via Playwright. The worker container is deliberately unfriendly — non-root, all Linux capabilities dropped, privilege escalation forbidden, read-only root filesystem.

During a security audit of that setup, the auditor looked at the pod spec and wrote the finding as fact: with a configuration this locked down, Chromium cannot even start without --no-sandbox.

Except the flag had already been removed in an earlier hardening round. The same locked-down pods had been rendering untrusted pages in production the whole time, sandbox on, end-to-end suite green.

I like this story because nobody in it is careless. The auditor pattern-matched the same folklore every tutorial repeats, and the folklore contains a real kernel of truth: in that container, one of Chromium's two sandboxes genuinely cannot work. The only mistake is not knowing there are two.

(The audit round still earned its keep: it is why the pod now pins a seccomp profile explicitly (RuntimeDefault) instead of running unconfined, which is what Kubernetes gives you by default when the field is absent. More on that below.)

The two sandboxes, in plain words

Chromium splits itself into a trusted browser process and untrusted renderer processes. The renderers execute whatever the page supplies — HTML parsing, JavaScript, images, fonts. Renderer bugs are found and patched all the time; the sandbox is the wall that decides what a successful exploit is worth. With the wall, code execution in a renderer lands in a process that has no filesystem, no network sockets of its own and no real user identity, and still needs a second, unrelated bug to get anywhere. Without the wall, it lands directly in your worker process: your environment variables, your tokens, your network position.

To build the wall, Chromium has two mechanisms.

The setuid sandbox is the legacy one: a small root-owned helper binary that briefly elevates to root to construct the isolation, then drops everything. In a hardened container this path is dead on arrival — and that is on purpose. allowPrivilegeEscalation: false sets the kernel's no_new_privs bit on the process, which means: nothing this process executes may ever gain privileges it does not already have. That bit exists precisely to neutralize setuid tricks. You did not want a root-elevating helper in your image anyway.

The user-namespace sandbox is the modern one, and it needs no privileges at all — when the environment permits it. An ordinary process asks the kernel for a new user namespace: a private view of the system in which the process is "root" while remaining a nobody outside. Chromium uses that namespace-root to strip its renderers of the filesystem, the network and everything else. Two switches decide whether this works:

  1. The kernel must allow unprivileged user namespaces. Many distributions enable this by default; some disable or restrict it (Debian historically gated it behind a sysctl, recent Ubuntu restricts it through AppArmor). Inside the container, cat /proc/sys/user/max_user_namespaces returning 0 means the kernel says no.
  2. The seccomp profile must allow the namespace syscalls. Seccomp is the allowlist of kernel calls a container may make. Docker's historic default profile blocked creating user namespaces — that block is exactly where Failed to move to new namespace … Operation not permitted comes from, and where the --no-sandbox cargo cult started.

Both switches vary by distribution, container runtime and version, and both have flipped over the years. So do not memorize a compatibility table — probe. Remove the flag, launch once, read the error: the message tells you which mechanism failed and why.

The pod spec that runs Chromium with its sandbox ON

This is the real production configuration of our render workers on Kubernetes (EKS; the node kernel ships with unprivileged user namespaces enabled — the default on Amazon Linux 2023):

spec:
  securityContext:          # pod-level
    runAsNonRoot: true
    runAsUser: 1000
    fsGroup: 1000
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: render-worker
      securityContext:      # container-level
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
Enter fullscreen mode Exit fullscreen mode

With a read-only root filesystem, Chromium still needs somewhere to write, so three emptyDir volumes are mounted: /tmp, the app's own scratch directory, and the browser user's home. The image creates a real non-root user (uid 1000) with a home directory — Chromium wants one for its profile. And here is the complete list of Chromium flags we pass — all two of them:

browser = await playwright.chromium.launch(
    headless=True,
    args=["--disable-dev-shm-usage", "--disable-gpu"],
)
Enter fullscreen mode Exit fullscreen mode

No --no-sandbox, no --disable-setuid-sandbox, no added capabilities, no custom seccomp JSON. --disable-dev-shm-usage is there because a container's /dev/shm is 64 MB by default and Chromium crashes on heavy pages when shared memory runs out; the flag makes it use ordinary temp files instead. (The alternative is mounting a memory-backed volume on /dev/shm.) --disable-gpu because there is no GPU to find.

One operational consequence to plan for: the namespace sandbox depends on the node's kernel settings, which you usually do not manage directly. A node-image upgrade that disables unprivileged user namespaces will not degrade your service politely — Chromium will simply refuse to launch. That is the right failure direction (closed), but it is still an outage, so make it loud and early. Our workers refuse to come up without the browser pool: if Chromium cannot launch, the process exits during startup and the replacement pods crash-loop, so a missing sandbox prerequisite reads as "the rollout never goes healthy" at deploy time, not as customer-facing errors an hour later. (A separate liveness probe catches the other failure mode: a browser pool or event loop that wedges after startup.)

What the sandbox does nothing about

Here is the part most "secure headless Chrome" writeups skip: a perfectly sandboxed browser still makes network requests on behalf of untrusted content, because fetching things is what a browser is for. If you render customer-submitted URLs from inside your infrastructure, the bigger everyday risk is not a renderer exploit — it is the page politely asking for things it should never reach: http://169.254.169.254/… (the cloud metadata service that hands out credentials), your database, internal dashboards. The sandbox is completely indifferent to all of that.

So the second half of rendering untrusted content responsibly is controlling what the browser can reach — and the obvious first tool, application-level URL filtering, has traps we have personally hit.

1. Validate-then-fetch is a race. You resolve invoice.example.com, get a public IP, approve the URL — and the browser resolves the name again at connect time, when the attacker's DNS can answer with a private address. This is DNS rebinding. The counter is to resolve once, validate every address the name resolves to, and pin the connection to a validated IP wherever your HTTP client allows it. Inside Chromium you effectively cannot pin — the browser re-resolves on its own. Hold that thought; it is why the network-policy section below exists.

2. The standard library's idea of "private" has a hole in it. Python shown, but the same trap exists in other stacks:

>>> import ipaddress
>>> ipaddress.ip_address("10.0.0.1").is_private
True
>>> ipaddress.ip_address("100.64.0.1").is_private
False
Enter fullscreen mode Exit fullscreen mode

100.64.0.0/10 is RFC 6598 shared address space (carrier-grade NAT), deliberately classified as neither private nor global — and inside cloud networks that range can be very much alive. A blocklist built on is_private alone waves it through. Block it explicitly, along with loopback, link-local, multicast, reserved and unspecified.

3. IPv6 can smuggle IPv4.

>>> ipaddress.ip_address("64:ff9b::169.254.169.254").is_private
False
Enter fullscreen mode Exit fullscreen mode

64:ff9b::/96 is the well-known NAT64 prefix: the last four bytes are an embedded IPv4 address, and a NAT64-capable egress path will deliver the packet to that inner address — here, the metadata service. To the standard library the outer address is ordinary global IPv6 space. The same wrapper trick exists in IPv4-mapped (::ffff:a.b.c.d) and IPv4-translated forms; newer language versions unwrap some of them in some checks. Do not memorize which: if an IPv6 address embeds an IPv4 address, extract the inner address and judge that.

4. The first URL is not the last URL. Validating what the customer submitted misses everything that happens next: redirects (302 → http://169.254.169.254/…) and every subresource the page pulls in — images, iframes, stylesheets, fonts. Subresources you can catch by hooking the browser's network layer: in Playwright, a route handler sees every request the page makes and can re-validate each one. Redirect hops you cannot: Playwright's documented behavior is that the handler "will only be called for the first url if the response is a redirect" — and that holds for context.route too — so the browser follows a 302 without your handler ever seeing the new URL. To police redirects at this layer you have to take over the fetch inside the handler (route.fetch(max_redirects=0), validate each Location target yourself, then route.fulfill()), or refuse redirects on render targets outright. Otherwise a redirect to the metadata service passes the hook untouched — one more reason the next section exists. In Playwright:

async def interceptor(route, request):
    try:
        # Resolve-once + blocklist check on every request the page makes.
        # NOTE: Playwright does NOT call this handler for redirect hops —
        # only for the first URL of a chain. Police redirects with
        # route.fetch(max_redirects=0) here, or leave them to the
        # egress policy below.
        await validate_url_for_ssrf(request.url)
    except SSRFError:
        await route.abort("accessdenied")
        return
    await route.continue_()

await context.route("**/*", interceptor)
Enter fullscreen mode Exit fullscreen mode

(If your jobs can carry customer credentials for authenticated rendering, this hook is also the place to attach them — per request, only when the request's origin matches the target's origin. Never install a credential context-wide: every third-party subresource on the page would receive it. And the redirect caveat applies here too: your handler does not see redirect hops, so verify with a live test what your browser and interception layer actually do with an injected header across a cross-origin redirect, rather than assuming the hook will strip it.)

The layer we actually trust

Everything in the previous section runs inside the same workload that renders hostile content, and one of its counters — IP pinning — is not even fully available in a browser. So the honest architecture statement is: application-level SSRF filtering is best-effort, and the control we actually rely on sits one level down — a kernel-enforced egress policy on the render pods. In Kubernetes terms:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: render-workers-egress
spec:
  podSelector:
    matchLabels:
      app: render-workers
  policyTypes: ["Egress"]
  egress:
    # DNS
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
      ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }
    # The database — and no other private destination
    - to:
        - ipBlock: { cidr: <your-db-subnet> }
      ports:
        - { protocol: TCP, port: 5432 }
    # Public HTTP/HTTPS: render targets, object storage, queues
    - ports:
        - { protocol: TCP, port: 80 }
        - { protocol: TCP, port: 443 }
      to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 10.0.0.0/8
              - 100.64.0.0/10
              - 172.16.0.0/12
              - 192.168.0.0/16
              - 169.254.0.0/16
Enter fullscreen mode Exit fullscreen mode

With this policy enforced, a page that gets past every software check above finds its packets to the metadata service or the internal network dropped by infrastructure the rendered content has no influence over.

Two scars to save you time here.

A NetworkPolicy is a request, not a fact. Kubernetes happily accepts NetworkPolicy objects on clusters where nothing enforces them — on EKS with the AWS VPC CNI, enforcement is a flag you must explicitly turn on. Ours sat in exactly that state for a while: accepted, visible in kubectl, doing nothing.

An unenforced policy accumulates bugs invisibly. When enforcement was finally switched on, probing from a live worker pod found three defects that had been sitting in that green-looking YAML the whole time: the blanket private-range block also covered the range our own database lives in, so workers could not record job results; only port 443 had been allowed, so any plain-http:// render target would hang; and there was an allow rule for a Redis connection the workers never actually make. The policy had reviewed well and meant nothing. So: after enabling enforcement, prove both directions from inside a pod — the metadata request must fail, the database connection must succeed, an http:// fetch must succeed — and grant only what you can demonstrate the workload uses.

The checklist

If you render untrusted URLs or HTML with headless Chromium, in rough order of value:

  • Run the sandbox. Do not pass --no-sandbox; when launch fails, fix the switch the error names instead — kernel user-namespace sysctl, seccomp profile, non-root user with a writable home. If your runtime's default seccomp profile blocks the namespace syscalls, prefer a tailored profile over running unconfined.
  • Harden the container anyway: runAsNonRoot, allowPrivilegeEscalation: false, capabilities: drop ALL, readOnlyRootFilesystem with emptyDir for /tmp and the home directory, an explicit seccomp profile.
  • Prove at startup that the browser actually launches, and fail the rollout loudly — a node-image change can silently remove a sandbox prerequisite.
  • One browser context per job, downloads off, popups closed, context torn down in finally.
  • Validate URLs with resolve-once semantics; block CGNAT explicitly; unwrap IPv4-embedding IPv6 forms; re-validate every request in a network hook — subresources included — and know your tool's redirect behavior: Playwright's route handler does not see redirect hops, so police redirects separately or leave them to the egress layer below.
  • Put the real guarantee below the application: a kernel-enforced egress policy that denies private ranges, CGNAT and link-local, allows only DNS, your database and public 80/443 — then prove it empirically from inside a pod.
  • Assume each layer fails. The useful design question is never "is this isolated?" — it is "what does the next layer catch?"

The plug, as promised

PDFik is roughly this article as a service: a hosted URL/HTML-to-PDF API where the posture above is the default — Chromium with its sandbox on, a fresh browser context per job, per-request SSRF re-validation, and network-level egress restrictions on the render fleet — all running on AWS in the EU (Frankfurt). There is a free plan and it does not ask for a credit card: pdfik.net.

If you spot a hole in any of this, I genuinely want to know — that is half the reason to write it up.

Top comments (0)