also looking at this
fix(chrome): ship NSS's runtime providers and gate on a real render
#246
3 files
+114
-2
| @@ -26,6 +26,40 @@ async function run(name: string, args: string[], timeout = 30_000): Promise<stri | ||
| 26 | 26 | return output |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | /** | |
| 30 | * Run with nothing inherited but HOME and a PATH without the package, so only | |
| 31 | * its own layout resolves libraries. LD_DEBUG=files makes the dynamic loader | |
| 32 | * report each library it initialises (on stderr) without changing a lookup. | |
| 33 | */ | |
| 34 | async function runClean(name: string, args: string[], timeout = 60_000): Promise<{ stdout: string, stderr: string }> { | |
| 35 | const stdout = join(artifacts, `${name}.stdout.log`) | |
| 36 | const stderr = join(artifacts, `${name}.stderr.log`) | |
| 37 | const child = Bun.spawn(args, { cwd: root, env: { HOME: root, PATH: '/usr/bin:/bin', LD_DEBUG: 'files' }, stdout: Bun.file(stdout), stderr: Bun.file(stderr), signal: AbortSignal.timeout(timeout) }) | |
| 38 | const code = await child.exited | |
| 39 | const output = { stdout: await Bun.file(stdout).text(), stderr: await Bun.file(stderr).text() } | |
| 40 | assert.equal(code, 0, `${name}: ${output.stdout}${output.stderr.slice(-4000)}`) | |
| 41 | return output | |
| 42 | } | |
| 43 | ||
| 44 | /** | |
| 45 | * Ubuntu 23.10+ lets an unprivileged program create user namespaces - which | |
| 46 | * Chrome's sandbox needs - only under an AppArmor profile that allows it. | |
| 47 | * Ubuntu ships one for /opt/google/chrome/chrome; this grants the same, and | |
| 48 | * only that, to the installed binary. The browser keeps its sandbox: no | |
| 49 | * --no-sandbox, and the restriction stays on for everything else. | |
| 50 | */ | |
| 51 | async function allowSandbox(binary: string): Promise<string> { | |
| 52 | const restricted = await readFile('/proc/sys/kernel/apparmor_restrict_unprivileged_userns', 'utf8').then(value => value.trim() === '1').catch(() => false) | |
| 53 | if (!restricted) return 'not restricted' | |
| 54 | const profile = join(root, 'pantry-chrome-userns') | |
| 55 | await Bun.write(profile, `abi <abi/4.0>,\ninclude <tunables/global>\nprofile pantry-chrome-userns "${binary}" flags=(unconfined) {\n userns,\n}\n`) | |
| 56 | const load = Bun.spawnSync(['sudo', '-n', 'apparmor_parser', '-r', profile], { stdout: 'pipe', stderr: 'pipe' }) | |
| 57 | assert.equal(load.exitCode, 0, `Could not load the sandbox profile: ${load.stderr.toString()}`) | |
| 58 | loadedProfile = profile | |
| 59 | return 'pantry-chrome-userns' | |
| 60 | } | |
| 61 | let loadedProfile: string | undefined | |
| 62 | ||
| 29 | 63 | try { |
| 30 | 64 | proof.cliVersion = (await run('cli-version', [cli, '--version'])).trim() |
| 31 | 65 | // The exact published package and ordinary install path used by HQ's CI. |
| @@ -37,9 +71,37 @@ try { | ||
| 37 | 71 | proof.executableSha256 = Bun.SHA256.hash(await readFile(executable), 'hex') |
| 38 | 72 | proof.version = (await run('chrome-version', [entry, '--version'])).trim() |
| 39 | 73 | assert.match(String(proof.version), /\b155\.0\.8059\.84\b/) |
| 74 | ||
| 75 | // --version starts no browser: NSS, the sandbox and the renderer never load. | |
| 76 | // Render a page with the sandbox on, from a clean environment - no | |
| 77 | // LD_LIBRARY_PATH, nothing inherited - so every library has to come from the | |
| 78 | // package's own layout. This is what failed with nss_error=-8023 when the | |
| 79 | // package left out libfreeblpriv3, a provider NSS loads with dlopen. | |
| 80 | proof.sandboxProfile = await allowSandbox(executable) | |
| 81 | const page = join(root, 'render-profile') | |
| 82 | await mkdir(page) | |
| 83 | const rendered = await runClean('chrome-render', [entry, '--headless=new', '--disable-gpu', '--no-first-run', `--user-data-dir=${page}`, '--dump-dom', 'data:text/html,<h1 id="pantry">rendered by the installed package</h1>']) | |
| 84 | assert.match(rendered.stdout, /<h1 id="pantry">rendered by the installed package<\/h1>/, 'Chrome must render with its own libraries and its sandbox') | |
| 85 | proof.rendered = true | |
| 86 | ||
| 87 | // A render alone is not proof: a host NSS (GitHub's image ships one with its | |
| 88 | // own Chrome) answers NSS's dlopen fallback for a provider the package left | |
| 89 | // out, and the page renders anyway. Require the loader to have initialised | |
| 90 | // softokn and its crypto provider from the installed package itself. | |
| 91 | // Whole lines: the gate's root path has spaces in it, on purpose. | |
| 92 | const initialised = [...rendered.stderr.matchAll(/calling init: (.+)$/gm)].map(match => match[1]!.trim()) | |
| 93 | const nss = initialised.filter(path => /\/lib(?:softokn3|freeblpriv3)\.so$/.test(path)) | |
| 94 | proof.nssModules = [...new Set(nss)] | |
| 95 | for (const module of ['libsoftokn3.so', 'libfreeblpriv3.so']) { | |
| 96 | const loaded = nss.filter(path => path.endsWith(`/${module}`)) | |
| 97 | assert(loaded.length > 0, `Chrome never initialised ${module}`) | |
| 98 | for (const path of loaded) | |
| 99 | assert(path.startsWith(`${root}/pantry/`), `${module} came from ${path}, not the installed package`) | |
| 100 | } | |
| 40 | 101 | proof.passed = true |
| 41 | 102 | } |
| 42 | 103 | finally { |
| 104 | if (loadedProfile) Bun.spawnSync(['sudo', '-n', 'apparmor_parser', '-R', loadedProfile]) | |
| 43 | 105 | await rm(root, { recursive: true, force: true }) |
| 44 | 106 | proof.cleaned = true |
| 45 | 107 | await Bun.write(join(artifacts, 'proof.json'), JSON.stringify(proof, null, 2) + '\n') |