Files
nix-config-ryan4yin/hardening/tests/verify-chromium.py
T
Ryan Yin 87b7a23cf3 docs(nixpak): consolidate runtime validation notes
Merge the three runtime-validation files into one concise record, update the interactive browser and Zoom results, and document the intentional Chromium host-loopback and Brave/Edge no-media exceptions. Tighten the remaining cookie-crypto workaround and the smoke-test prerequisites.

Assisted-by: DeepSeek Harness (deepseek-flash)
2026-11-01 09:24:23 +08:00

418 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""Runtime smoke test: verify a built nixpak Chromium without existing profiles.
Usage: python3 hardening/tests/verify-chromium.py /nix/store/.../bin/chromium
Requires Linux procfs and an active user D-Bus session. Pasta-wrapped browsers also
need /dev/net/tun. Uses a disposable HOME under /var/tmp and only the standard library.
"""
import argparse
import http.server
import json
import os
import signal
import subprocess
import tempfile
import threading
import time
import urllib.request
from pathlib import Path
def processes():
result = {}
for path in Path("/proc").glob("[0-9]*"):
try:
status = dict(
line.split(":", 1)
for line in (path / "status").read_text().splitlines()
if ":" in line
)
args = (path / "cmdline").read_bytes().split(b"\0")
result[int(path.name)] = (int(status["PPid"]), status, args)
except (OSError, ValueError):
pass
return result
def descendants(root, table):
found = {root}
while True:
more = {pid for pid, (parent, _, _) in table.items() if parent in found}
if more <= found:
return found
found |= more
def run_browser(command, env):
try:
return subprocess.run(
command, check=False, env=env, capture_output=True, timeout=35
)
except subprocess.TimeoutExpired as error:
detail = (error.stderr or b"").decode(errors="replace")[-6000:]
raise AssertionError(
"Disposable headless command timed out:\n" + detail
) from None
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("browser", type=Path)
args = parser.parse_args()
browser = str(args.browser.absolute())
profile = {
"chromium": "chromium",
"google-chrome": "google-chrome",
"microsoft-edge": "microsoft-edge",
"brave-origin": "BraveSoftware/Brave-Origin",
}[args.browser.name]
media = args.browser.name in {"chromium", "google-chrome"}
report = {}
with tempfile.TemporaryDirectory(
prefix="nixpak-chromium-test-", dir="/var/tmp"
) as tmp:
home = Path(tmp)
for directory in [
f".config/{profile}",
"Downloads",
".cache",
".local/share",
".local/state",
]:
(home / directory).mkdir(parents=True, exist_ok=True)
(home / ".config/user-dirs.dirs").write_text(
'XDG_DOWNLOAD_DIR="$HOME/Downloads"\n'
)
env = {
key: os.environ[key]
for key in [
"PATH",
"USER",
"XDG_RUNTIME_DIR",
"DBUS_SESSION_BUS_ADDRESS",
"WAYLAND_DISPLAY",
"LANG",
]
if key in os.environ
}
canaries = [
"NIXPAK_VALIDATION_CANARY",
"SSH_AUTH_SOCK",
"GPG_AGENT_INFO",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
]
env.update({name: "disposable-test-value" for name in canaries})
env.update(
HOME=tmp,
XDG_CONFIG_HOME=f"{tmp}/.config",
XDG_CACHE_HOME=f"{tmp}/.cache",
XDG_DATA_HOME=f"{tmp}/.local/share",
XDG_STATE_HOME=f"{tmp}/.local/state",
NIXPAK_VALIDATION_CANARY="disposable-test-value",
)
base = [
browser,
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
f"--user-data-dir={tmp}/.config/{profile}/validation-profile",
]
with (home / "browser.log").open("w+") as log:
proc = subprocess.Popen(
base + ["--remote-debugging-port=0", "about:blank"],
env=env,
stdout=log,
stderr=log,
start_new_session=True,
)
try:
deadline = time.monotonic() + 35
renderers = []
while time.monotonic() < deadline:
table = processes()
owned = descendants(proc.pid, table)
renderers = [
pid
for pid in owned
if pid in table
and b"--type=renderer" in b" ".join(table[pid][2]).split()
]
if renderers or proc.poll() is not None:
break
time.sleep(0.2)
if not renderers:
log.seek(0)
# This log belongs only to the disposable browser, never a host environment dump.
raise AssertionError(
"No live renderer; disposable browser log:\n"
+ log.read()[-6000:]
)
report["renderers"] = [
{
"pid": pid,
"Seccomp": table[pid][1]["Seccomp"].strip(),
"NoNewPrivs": table[pid][1]["NoNewPrivs"].strip(),
}
for pid in renderers
]
assert all(
row["Seccomp"] == "2" and row["NoNewPrivs"] == "1"
for row in report["renderers"]
), report
bwrap = [
row[2]
for pid, row in table.items()
if pid in owned and Path(os.fsdecode(row[2][0])).name == "bwrap"
]
required = [
b"--unshare-user-try",
b"--unshare-pid",
b"--unshare-net",
b"--clearenv",
b"--new-session",
b"--die-with-parent",
]
assert any(
all(flag in command for flag in required) for command in bwrap
), "Missing bwrap isolation arguments"
report["bwrap_required_flags"] = [
os.fsdecode(flag) for flag in required
]
browser_pids = set()
for renderer in renderers:
pid = table[renderer][0]
while pid in owned and pid in table:
command = b" ".join(table[pid][2]).split()
if (
any(arg.startswith(b"--user-data-dir=") for arg in command)
and not any(arg.startswith(b"--type=") for arg in command)
and b"--clearenv" not in command
):
browser_pids.add(pid)
break
pid = table[pid][0]
checked = 0
for pid in browser_pids:
command = b" ".join(table[pid][2]).split()
assert b"--no-sandbox" not in command, "Chromium sandbox disabled"
features = [
arg.split(b"=", 1)[1].split(b",")
for arg in command
if arg.startswith(b"--enable-features=")
]
assert features and b"WaylandWindowDecorations" in features[-1], (
"Missing Wayland feature flag"
)
assert (b"WebRtcPipeWireCamera" in features[-1]) == media, (
"Camera feature does not match browser media policy"
)
assert all(
b"--no-sandbox" not in b" ".join(table[pid][2]).split()
for pid in renderers
), "Renderer sandbox disabled"
report["camera_and_wayland_features"] = {
"WaylandWindowDecorations": True,
"WebRtcPipeWireCamera": media,
}
report["chromium_sandbox_flags"] = (
"--no-sandbox absent; renderer Seccomp=2/NoNewPrivs=1"
)
for pid in browser_pids:
try:
entries = Path(f"/proc/{pid}/environ").read_bytes().split(b"\0")
except PermissionError:
continue
assert not any(
entry.startswith(name.encode() + b"=")
for entry in entries
for name in canaries
), "Canary leaked into browser"
checked += 1
assert checked, "Could not inspect browser environment"
report["browser_environments_without_canary"] = checked
report["absent_canary_variables"] = canaries
command = next(command for command in bwrap if b"--clearenv" in command)
network_flags = [
arg
for arg in command[: command.index(b"--")]
if arg in {b"--unshare-net", b"--share-net"}
]
expected_network = (
b"--share-net"
if args.browser.name == "chromium"
else b"--unshare-net"
)
assert network_flags[-1] == expected_network, (
"Unexpected effective network namespace policy"
)
report["effective_network_namespace_flag"] = os.fsdecode(
expected_network
)
for forbidden in [b"/dev/video0", b"/tmp/.X11-unix"]:
assert forbidden not in command, "Unexpected device/socket bind"
assert not any(
b"/pipewire-0" in arg or b"/gvfsd" in arg for arg in command
), "Unexpected direct desktop socket bind"
assert any(
command[i : i + 2] == [b"--tmpfs", b"/dev/shm"]
for i in range(len(command) - 1)
), "Missing private shared memory"
report["private_shm_and_no_direct_pipewire_gvfs_camera_x11"] = True
# Inspect bind operands, not synthetic --dev /dev or tmpfs mounts.
bind_options = {
b"--bind",
b"--bind-try",
b"--ro-bind",
b"--ro-bind-try",
b"--dev-bind",
b"--dev-bind-try",
}
options = command[: command.index(b"--")]
binds = [
(options[i + 1], options[i + 2])
for i, option in enumerate(options)
if option in bind_options
]
broad_paths = {
b"/",
b"/home",
b"/dev",
os.fsencode(tmp),
os.fsencode(os.environ["HOME"]),
}
for source, destination in binds:
for path in (source, destination):
assert path.rstrip(b"/") not in broad_paths and path != b"/", (
"Broad host filesystem bind"
)
parts = path.split(b"/")
if not media:
assert b"pulse" not in parts and not any(
part.startswith(b"pipewire-") for part in parts
), (
"Media-disabled browser has an audio/PipeWire socket bind"
)
assert b".ssh" not in parts, "SSH directory bind"
assert not (
path == b"/run/dbus" or path.startswith(b"/run/dbus/")
), "System D-Bus bind"
assert not (
path.startswith((b"/dev/video", b"/tmp/.X11-unix"))
), "Raw camera or X11 bind"
if args.browser.name in {"brave-origin", "microsoft-edge"}:
assert not (
{b".gnupg", b"gnupg", b"password-store"} & set(parts)
), "Unexpected browserpass credentials bind"
report["no_broad_home_dev_ssh_or_system_bus_bind"] = True
report["media_socket_policy"] = (
"PulseAudio allowed; direct PipeWire absent"
if media
else "PulseAudio and PipeWire binds absent"
)
report["browserpass_bind_scope"] = (
"allowed only for Chromium/Chrome; absent for Brave/Edge"
)
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
print(json.dumps(report, indent=2), flush=True)
render_file = home / ".config" / profile / "render-canary.html"
render_file.write_text("<html><body>NIXPAK_RENDER_OK</body></html>")
positive = run_browser(
base
+ [
"--timeout=10000",
"--dump-dom",
render_file.as_uri(),
],
env,
)
assert positive.returncode == 0 and b"NIXPAK_RENDER_OK" in positive.stdout, (
"Disposable page failed to render"
)
assert b"Fontconfig error" not in positive.stderr, (
"Fontconfig could not load sandbox configuration"
)
report["headless_page_render"] = (
"host-prepared file rendered; no Fontconfig errors"
)
state_file = home / ".config" / profile / "validation-profile" / "Local State"
assert state_file.is_file() and state_file.stat().st_size > 0, (
"Browser profile state did not persist to disposable host HOME"
)
report["disposable_profile_persistence"] = (
"nonempty Local State exists in host HOME"
)
class Handler(http.server.BaseHTTPRequestHandler):
hits = 0
def do_GET(self):
type(self).hits += 1
self.send_response(200)
self.end_headers()
self.wfile.write(
b"<html><body>NIXPAK_HOST_LOOPBACK_CANARY</body></html>"
)
def log_message(self, *args):
pass
with http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server:
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
url = f"http://127.0.0.1:{server.server_port}/"
assert (
b"NIXPAK_HOST_LOOPBACK_CANARY"
in urllib.request.urlopen(url, timeout=5).read()
)
Handler.hits = 0
try:
result = run_browser(base + ["--timeout=10000", "--dump-dom", url], env)
assert result.returncode == 0, "Loopback browser command failed"
if args.browser.name == "chromium":
assert (
b"NIXPAK_HOST_LOOPBACK_CANARY" in result.stdout
and Handler.hits > 0
), "Chromium must reach host loopback"
report["host_loopback"] = (
"allowed: host and Chromium fetched the listener marker"
)
else:
assert b"ERR_CONNECTION_REFUSED" in result.stdout + result.stderr, (
"Expected explicit browser connection refusal"
)
assert (
b"NIXPAK_HOST_LOOPBACK_CANARY" not in result.stdout
and Handler.hits == 0
), "Browser reached host loopback"
report["host_loopback"] = (
"blocked: host positive control succeeded; browser ERR_CONNECTION_REFUSED; zero server requests"
)
finally:
server.shutdown()
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()