Zoo

staging

← back to the zoo

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 77ff74344d (staging). 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];