9. Try the doors
An app cannot open another app's files, and it cannot reach the server's own internal network.
Every app runs as its own unix user in a sandbox, behind a firewall. RC_DATA is the one place it can write; everything else on the box is someone else's.
This is the code serving the zoo right now: read from disk for this request, from commit
0c1c124a02 (live). The zoo's own self-check fetches this
page and compares it byte for byte with the file it runs.
exhibits/09-try-the-doors.php sha256 5cd3d235bc66 · raw
1<?php 2// Exhibit 9. Each app is its own unix user in its own sandbox, and a firewall keeps it off the 3// server's loopback and private networks. This tries the doors right now, as the zoo's own uid. 4// 5// Reading is tried twice: by PHP (stopped first by open_basedir, a second fence) and by a child 6// process, which open_basedir does not cover, so the uid itself is what is tested. A door that 7// does not exist is reported as "missing": a test that knocks on nothing proves nothing. 8 9function doors(): array { 10 $data = dirname(env("RC_DATA"), 2); // /srv/data, from our own RC_DATA 11 $apps = dirname(ZOO_ROOT, 2); // /srv/apps, from our own checkout 12 $env = env("RC_ENV"); 13 $reads = [ 14 "account's database" => "$data/account/$env/account.db", 15 "account's signing key" => "$data/account/$env/assert-key.pem", 16 "account's code" => "$apps/account/$env/public/index.php", 17 "account's secret for account" => dirname(env("RC_SECRETS"), 2) . "/account/$env/account", 18 "account's facts" => dirname(env("RC_FACTS")) . "/account/deploy.json", 19 "the platform's secrets" => "/etc/ratcloud/secrets", // a fixed platform path: the door, not a setting 20 "account's repository" => "/opt/account.git/config", 21 ]; 22 $connects = [ 23 "the server's own web server (127.0.0.1:80)" => ["127.0.0.1", 80], 24 "the cloud metadata service (169.254.169.254:80)" => ["169.254.169.254", 80], 25 "the private network (10.0.0.1:80)" => ["10.0.0.1", 80], 26 ]; 27 $out = []; 28 foreach ($reads as $what => $path) { 29 $php = @file_get_contents($path, false, null, 0, 1) !== false; 30 $p = proc_open(["/usr/bin/head", "-c", "1", "--", $path], [1 => ["pipe", "w"], 2 => ["pipe", "w"]], $pipes); 31 stream_get_contents($pipes[1]); $err = trim(stream_get_contents($pipes[2])); 32 $rc = proc_close($p); 33 $why = preg_replace('#^.*: #', "", $err) ?: "read"; 34 $state = $php || $rc === 0 ? "opened" : (str_contains($err, "Permission denied") ? "refused" : "missing"); 35 if (str_contains($err, "Is a directory")) { // a directory: try to list it instead 36 $p = proc_open(["/usr/bin/ls", "--", $path], [1 => ["pipe", "w"], 2 => ["pipe", "w"]], $pipes); 37 stream_get_contents($pipes[1]); $err = trim(stream_get_contents($pipes[2])); $rc = proc_close($p); 38 $why = preg_replace('#^.*: #', "", $err) ?: "listed"; 39 $state = $rc === 0 ? "opened" : (str_contains($err, "Permission denied") ? "refused" : "missing"); 40 } 41 $out[] = ["door" => "read $what", "target" => $path, "result" => $state, "why" => "as uid " . posix_getpwuid(posix_geteuid())["name"] . ": $why"]; 42 } 43 foreach ($connects as $what => [$ip, $port]) { 44 $s = @fsockopen($ip, $port, $no, $err, 2); 45 if ($s) fclose($s); 46 $out[] = ["door" => "connect to $what", "target" => "$ip:$port", "result" => $s ? "opened" : "refused", "why" => $s ? "connected" : ($err ?: "error $no")]; 47 } 48 return $out; 49} 50 51// The control: the same tries on doors that should open. If these fail, so would everything. 52function controls(): array { 53 $u = parse_url(env("RC_PUBLISH")); 54 $s = @fsockopen($u["host"], $u["port"], $no, $err, 2); 55 if ($s) fclose($s); 56 $p = proc_open(["/usr/bin/head", "-c", "1", "--", env("RC_DATA") . "/zoo.db"], [1 => ["pipe", "w"], 2 => ["pipe", "w"]], $pipes); 57 stream_get_contents($pipes[1]); stream_get_contents($pipes[2]); 58 return ["own data" => proc_close($p) === 0, "own publish port" => (bool)$s]; 59} 60 61return [ 62 "n" => 9, "wing" => "Isolation", 63 "title" => "Try the doors", 64 "promise" => "An app cannot open another app's files, and it cannot reach the server's own internal network.", 65 "block" => "Every app runs as its own unix user in a sandbox, behind a firewall. RC_DATA is the one place it can write; everything else on the box is someone else's.", 66 "show" => function (?array $me): string { 67 $root = ""; 68 foreach ((fact("platform.json")["check"]["checks"] ?? []) as $c) { 69 if (str_starts_with($c["line"], "(exhibit 9)") || str_starts_with($c["line"], "An app cannot read")) $root .= "<li><span class=\"tag " . ($c["status"] === "pass" ? "ok" : "bad") . "\">" . h($c["status"]) . "</span>" . h($c["detail"]) . "</li>"; 70 } 71 return '<p><button class="primary" id="doors-go">Try the doors now</button></p><ul class="list" id="doors"></ul><p class="muted out" id="doors-ctl"></p>' 72 . ($root ? '<p class="muted">Root tries them too, as every app, every hour:</p><ul class="list">' . $root . "</ul>" : ""); 73 }, 74 "api" => function (string $do, ?array $me, array $in, bool $post): ?array { 75 if ($do !== "try" || !$post) return null; 76 return ["doors" => doors(), "controls" => controls()]; 77 }, 78 "check" => function (): array { 79 $d = doors(); 80 $bad = array_filter($d, fn($x) => $x["result"] !== "refused"); 81 $c = controls(); 82 if ($bad) return [false, implode("; ", array_map(fn($x) => "{$x['door']}: {$x['result']}", $bad)), $d]; 83 if (in_array(false, $c, true)) return [false, "the control failed: the zoo could not open its own " . implode(", ", array_keys(array_filter($c, fn($v) => !$v))), $d]; 84 return [true, count($d) . " doors tried, all refused; its own data and publish port open", $d]; 85 }, 86 "script" => <<<'JS' 87document.getElementById("doors-go")?.addEventListener("click", async (ev) => { 88 ev.target.disabled = true; ev.target.textContent = "trying…"; 89 const j = await zoo.call("/api/9/try", {}); 90 ev.target.disabled = false; ev.target.textContent = "Try again"; 91 if (j.error) { document.getElementById("doors").textContent = j.error; return; } 92 document.getElementById("doors").innerHTML = j.doors.map(d => 93 '<li><span class="tag ' + d.result + '">' + d.result + '</span><span>' + zoo.esc(d.door) + ' <small class="muted">' + zoo.esc(d.why) + '</small></span></li>').join(""); 94 document.getElementById("doors-ctl").textContent = "Control, the zoo's own doors: " + 95 Object.entries(j.controls).map(([k, v]) => k + " " + (v ? "opened" : "FAILED")).join(", ") + "."; 96}); 97 98JS, 99];