mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
The way through the extension sandbox's deny rules is an allowlist, and an allowlist names addresses. The machine's own LAN address is not a fact anybody can pin: a new DHCP lease can turn a peer's address on some package's list into the machine's, and with it open the Grbl port or forgectrl's listeners to that package. ffx.nft now refuses it structurally. Everything a host sends to one of its own addresses, the LAN one included, leaves through lo, so chain pool refuses `oifname "lo"` before it looks at the allow map; the two refusals (a reset for TCP, a drop for the rest, both counted) move into chain refuse, which pool jumps to from both places. No kernel option is new: oifname is in the nf_tables core. scripts/sandbox-rules-test.py gets a destination that is not the machine: a second network namespace joined by a veth pair, with listeners of its own. A pool uid is refused on loopback, IPv6 loopback, its own LAN address, and the peer; an allow chain opens one port of the peer to one uid and nothing else; with loopback, IPv6 loopback, and the machine's own address added to that list the uid is still refused at all three while the peer still answers; a reload closes it. It needs ip and nsenter now. exthost.platform reads its counters from chain refuse, holds the rule's place ahead of the map, and adds the case on the machine: an allow chain for the last pool uid that names forgectrl on loopback and on the LAN address opens neither, and the chain is removed. Proven. The rules test passes with nft 1.0.9, and four controls each fail it: the range one uid short, the TCP reject turned to accept, the delete-table line removed, and the lo rule removed (the uid then reaches all three of the machine's addresses). On the bench reference, image 20260920211625, this rule file loaded from /tmp with nft -f and this suite file mounted: exthost.platform PASS, uid 831 refused at 127.0.0.1:443 and 172.16.1.97:443 with both on its allowlist, the counters [0, 0] to [12, 4]. Against the image's own rules the same test fails on the rule's absence, which is the control. The image's rules were reloaded after. The unit suite passes (422). Acceptance. exthost.platform gates the rule on the machine; sandbox-ci gates the file. The rule file is layer content, in the platform identity of every fingerprint.
281 lines
12 KiB
Python
281 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
# Written by Scott Wiederhold
|
|
# https://community.openglow.org
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""Host test of the extension sandbox's deny rules.
|
|
|
|
Loads meta-forgefirm's ffx.nft into a network namespace of its own, joins a
|
|
second namespace to it with a veth pair (a peer that is not this machine),
|
|
and sends real traffic from real uids. An extension account is refused
|
|
everywhere: on loopback, IPv4 and IPv6, at its own LAN address, and at the
|
|
peer, TCP at once and UDP with EPERM. The uids on either side of the pool,
|
|
and root, are not touched. An allowlist chain lets one uid reach one port of
|
|
the peer and nothing else. The machine itself is never a destination: with
|
|
loopback and the machine's own LAN address on its allowlist, a pool uid is
|
|
still refused at both. And loading the file again takes the allowlist away.
|
|
|
|
Needs root, nft, ip, nsenter, and a kernel with nf_tables; exits 77 when one
|
|
is missing, 0 on a pass, 1 on a failure.
|
|
|
|
sudo python3 scripts/sandbox-rules-test.py [--nft /path/to/nft] [--rules FILE]
|
|
"""
|
|
import argparse
|
|
import errno
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
RULES = os.path.join(HERE, "..", "meta-forgefirm", "recipes-forgefirm", "forgefirm-sandbox", "files", "ffx.nft")
|
|
POOL_FIRST, POOL_LAST = 800, 831
|
|
HERE_ADDR, PEER_ADDR = "10.99.0.1", "10.99.0.2"
|
|
SKIP = 77
|
|
|
|
# The peer: in its own network namespace, it waits to be given its interface,
|
|
# then listens on two TCP ports and one UDP port and says which.
|
|
PEER = r'''
|
|
import socket, sys
|
|
print("pid", flush=True)
|
|
sys.stdin.readline()
|
|
tcp = []
|
|
for _ in range(2):
|
|
s = socket.socket()
|
|
s.bind(("%s", 0))
|
|
s.listen(128)
|
|
tcp.append(s)
|
|
u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
u.bind(("%s", 0))
|
|
print(tcp[0].getsockname()[1], tcp[1].getsockname()[1], u.getsockname()[1], flush=True)
|
|
sys.stdin.readline()
|
|
''' % (PEER_ADDR, PEER_ADDR)
|
|
|
|
failures = []
|
|
|
|
|
|
def check(ok, what, *args):
|
|
text = what % args if args else what
|
|
print(" %s %s" % ("ok " if ok else "FAIL", text), flush=True)
|
|
if not ok:
|
|
failures.append(text)
|
|
|
|
|
|
def lo_up():
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
ifr = struct.pack("16sH14x", b"lo", 0)
|
|
flags = struct.unpack("16sH14x", fcntl.ioctl(s, 0x8913, ifr))[1] # SIOCGIFFLAGS
|
|
fcntl.ioctl(s, 0x8914, struct.pack("16sH14x", b"lo", flags | 1)) # SIOCSIFFLAGS, IFF_UP
|
|
finally:
|
|
s.close()
|
|
|
|
|
|
def tcp_listener(family, addr):
|
|
s = socket.socket(family, socket.SOCK_STREAM)
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
s.bind((addr, 0))
|
|
s.listen(128) # nobody accepts: the kernel completes each connect into the backlog
|
|
return s, s.getsockname()[1]
|
|
|
|
|
|
def attempt(uid, family, addr, port, udp=False):
|
|
"""What a process of this uid gets when it sends to addr:port: 'ok',
|
|
'refused', 'eperm', 'timeout', or the errno's name; and how long it took."""
|
|
r, w = os.pipe()
|
|
t0 = time.time()
|
|
pid = os.fork()
|
|
if pid == 0:
|
|
os.close(r)
|
|
word = "ok"
|
|
try:
|
|
if uid:
|
|
os.setgroups([])
|
|
os.setgid(uid)
|
|
os.setuid(uid)
|
|
s = socket.socket(family, socket.SOCK_DGRAM if udp else socket.SOCK_STREAM)
|
|
s.settimeout(4.0)
|
|
if udp:
|
|
s.sendto(b"x", (addr, port))
|
|
else:
|
|
s.connect((addr, port))
|
|
s.close()
|
|
except socket.timeout:
|
|
word = "timeout"
|
|
except OSError as e:
|
|
word = {errno.ECONNREFUSED: "refused", errno.EPERM: "eperm"}.get(
|
|
e.errno, errno.errorcode.get(e.errno, str(e.errno)))
|
|
os.write(w, word.encode())
|
|
os._exit(0)
|
|
os.close(w)
|
|
word = os.read(r, 64).decode()
|
|
os.close(r)
|
|
os.waitpid(pid, 0)
|
|
return word, time.time() - t0
|
|
|
|
|
|
def join_peer():
|
|
"""Start the peer in its own namespace and join it with a veth pair.
|
|
Returns (process, tcp port, second tcp port, udp port)."""
|
|
peer = subprocess.Popen(["unshare", "-n", sys.executable, "-u", "-c", PEER], stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE, text=True)
|
|
peer.stdout.readline()
|
|
ns = ["nsenter", "-t", str(peer.pid), "-n"]
|
|
for cmd in (["ip", "link", "add", "ffxt0", "type", "veth", "peer", "name", "ffxt1"],
|
|
["ip", "link", "set", "ffxt1", "netns", str(peer.pid)],
|
|
["ip", "addr", "add", HERE_ADDR + "/24", "dev", "ffxt0"],
|
|
["ip", "link", "set", "ffxt0", "up"],
|
|
ns + ["ip", "addr", "add", PEER_ADDR + "/24", "dev", "ffxt1"],
|
|
ns + ["ip", "link", "set", "ffxt1", "up"],
|
|
ns + ["ip", "link", "set", "lo", "up"]):
|
|
subprocess.run(cmd, check=True, capture_output=True)
|
|
peer.stdin.write("go\n")
|
|
peer.stdin.flush()
|
|
ports = [int(x) for x in peer.stdout.readline().split()]
|
|
return peer, ports[0], ports[1], ports[2]
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--nft", default=shutil.which("nft") or "nft")
|
|
ap.add_argument("--rules", default=RULES)
|
|
args = ap.parse_args()
|
|
|
|
if os.environ.get("FFX_RULES_NS") != "1":
|
|
if os.geteuid() != 0:
|
|
print("skipped: needs root (it makes network namespaces and changes uid)")
|
|
return SKIP
|
|
missing = [t for t in ("unshare", "nsenter", "ip") if not shutil.which(t)]
|
|
if missing or not (os.path.isfile(args.nft) or shutil.which(args.nft)):
|
|
print("skipped: needs nft, unshare, nsenter, and ip (missing: %s)" % (", ".join(missing) or "nft"))
|
|
return SKIP
|
|
env = dict(os.environ, FFX_RULES_NS="1")
|
|
os.execvpe("unshare", ["unshare", "-n", sys.executable, os.path.abspath(__file__),
|
|
"--nft", args.nft, "--rules", os.path.abspath(args.rules)], env)
|
|
|
|
def nft(*a, check_rc=True):
|
|
p = subprocess.run([args.nft] + list(a), capture_output=True, text=True)
|
|
if check_rc and p.returncode != 0:
|
|
raise RuntimeError("nft %s -> %d %s" % (" ".join(a), p.returncode, p.stderr.strip()))
|
|
return p
|
|
|
|
lo_up()
|
|
probe = nft("list", "tables", check_rc=False)
|
|
if probe.returncode != 0:
|
|
print("skipped: this kernel has no nf_tables (%s)" % probe.stderr.strip())
|
|
return SKIP
|
|
peer, pport, pport2, pudp = join_peer()
|
|
for _ in range(50): # the veth pair's carrier
|
|
if attempt(0, socket.AF_INET, PEER_ADDR, pport)[0] == "ok":
|
|
break
|
|
time.sleep(0.1)
|
|
|
|
print("the file loads, twice")
|
|
for n in (1, 2):
|
|
p = nft("-f", args.rules, check_rc=False)
|
|
check(p.returncode == 0, "load %d -> %d %s", n, p.returncode, p.stderr.strip()[:300])
|
|
if failures:
|
|
return 1
|
|
table = json.loads(nft("-j", "list", "table", "inet", "ffx").stdout)["nftables"]
|
|
chains = {c["chain"]["name"]: c["chain"] for c in table if "chain" in c}
|
|
maps = [m["map"]["name"] for m in table if "map" in m]
|
|
out = chains.get("output", {})
|
|
check(out.get("hook") == "output" and out.get("policy") == "accept" and out.get("type") == "filter",
|
|
"chain output is a filter on the output hook with policy accept: %s", out)
|
|
check({"pool", "refuse"} <= set(chains) and maps == ["allow"], "chains pool and refuse and map allow exist: %s %s",
|
|
sorted(chains), maps)
|
|
text = nft("list", "table", "inet", "ffx").stdout
|
|
check("meta skuid %d-%d jump pool" % (POOL_FIRST, POOL_LAST) in text, "the jump names the pool's range, %d-%d",
|
|
POOL_FIRST, POOL_LAST)
|
|
pool = text[text.find("chain pool"):].split("}")[0]
|
|
check(0 <= pool.find('oifname "lo" jump refuse') < pool.find("vmap @allow"),
|
|
"the machine itself is refused ahead of the allow map")
|
|
|
|
l4, port4 = tcp_listener(socket.AF_INET, "127.0.0.1")
|
|
l6, port6 = tcp_listener(socket.AF_INET6, "::1")
|
|
lself, portself = tcp_listener(socket.AF_INET, HERE_ADDR)
|
|
u4 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
u4.bind(("127.0.0.1", 0))
|
|
uport = u4.getsockname()[1]
|
|
everywhere = ((socket.AF_INET, "127.0.0.1", port4, "loopback"), (socket.AF_INET6, "::1", port6, "IPv6 loopback"),
|
|
(socket.AF_INET, HERE_ADDR, portself, "its own LAN address"), (socket.AF_INET, PEER_ADDR, pport, "the peer"))
|
|
|
|
print("outside the pool nothing is touched")
|
|
for uid in (0, POOL_FIRST - 1, POOL_LAST + 1):
|
|
for fam, addr, port, what in everywhere:
|
|
word, _ = attempt(uid, fam, addr, port)
|
|
check(word == "ok", "uid %d TCP to %s -> %s", uid, what, word)
|
|
for addr, port in (("127.0.0.1", uport), (PEER_ADDR, pudp)):
|
|
word, _ = attempt(uid, socket.AF_INET, addr, port, udp=True)
|
|
check(word == "ok", "uid %d UDP to %s -> %s", uid, addr, word)
|
|
|
|
print("a pool uid is refused everywhere, and at once")
|
|
for uid in (POOL_FIRST, (POOL_FIRST + POOL_LAST) // 2, POOL_LAST):
|
|
for fam, addr, port, what in everywhere:
|
|
word, took = attempt(uid, fam, addr, port)
|
|
check(word == "refused" and took < 1.5, "uid %d TCP to %s -> %s in %.2f s", uid, what, word, took)
|
|
for addr, port in (("127.0.0.1", uport), (PEER_ADDR, pudp)):
|
|
word, _ = attempt(uid, socket.AF_INET, addr, port, udp=True)
|
|
check(word == "eperm", "uid %d UDP to %s -> %s", uid, addr, word)
|
|
u4.settimeout(0.3)
|
|
got = 0
|
|
try:
|
|
while True:
|
|
u4.recvfrom(16)
|
|
got += 1
|
|
except socket.timeout:
|
|
pass
|
|
check(got == 3, "the UDP receiver heard the three senders outside the pool and nobody else: %d", got)
|
|
counted = [e["counter"]["packets"] for c in json.loads(nft("-j", "list", "chain", "inet", "ffx", "refuse").stdout)["nftables"]
|
|
if "rule" in c for e in c["rule"]["expr"] if "counter" in e]
|
|
check(len(counted) == 2 and all(n > 0 for n in counted), "both refusals counted packets: %s", counted)
|
|
|
|
print("an allowlist opens one destination to one uid")
|
|
chain = "u%d" % POOL_FIRST
|
|
nft("add", "chain", "inet", "ffx", chain)
|
|
nft("add", "rule", "inet", "ffx", chain, "ip", "daddr", PEER_ADDR, "tcp", "dport", str(pport), "accept")
|
|
nft("add", "element", "inet", "ffx", "allow", "{ %d : jump %s }" % (POOL_FIRST, chain))
|
|
for uid, addr, port, want, why in (
|
|
(POOL_FIRST, PEER_ADDR, pport, "ok", "the listed destination"),
|
|
(POOL_FIRST, PEER_ADDR, pport2, "refused", "another port of the peer"),
|
|
(POOL_FIRST, "127.0.0.1", port4, "refused", "loopback"),
|
|
(POOL_FIRST + 1, PEER_ADDR, pport, "refused", "another uid")):
|
|
word, _ = attempt(uid, socket.AF_INET, addr, port)
|
|
check(word == want, "uid %d to %s port %d (%s) -> %s", uid, addr, port, why, word)
|
|
word, _ = attempt(POOL_FIRST, socket.AF_INET, PEER_ADDR, pudp, udp=True)
|
|
check(word == "eperm", "uid %d UDP to the peer, which its list does not name -> %s", POOL_FIRST, word)
|
|
|
|
print("the machine itself is never a destination")
|
|
nft("add", "rule", "inet", "ffx", chain, "ip", "daddr", "127.0.0.1", "tcp", "dport", str(port4), "accept")
|
|
nft("add", "rule", "inet", "ffx", chain, "ip", "daddr", HERE_ADDR, "tcp", "dport", str(portself), "accept")
|
|
nft("add", "rule", "inet", "ffx", chain, "ip6", "daddr", "::1", "tcp", "dport", str(port6), "accept")
|
|
for fam, addr, port, what in everywhere[:3]:
|
|
word, _ = attempt(POOL_FIRST, fam, addr, port)
|
|
check(word == "refused", "uid %d to %s, which is on its allowlist -> %s", POOL_FIRST, what, word)
|
|
word, _ = attempt(POOL_FIRST, socket.AF_INET, PEER_ADDR, pport)
|
|
check(word == "ok", "and the peer still answers it -> %s", word)
|
|
|
|
print("loading the file again fails closed")
|
|
nft("-f", args.rules)
|
|
word, _ = attempt(POOL_FIRST, socket.AF_INET, PEER_ADDR, pport)
|
|
check(word == "refused", "uid %d to its old destination -> %s", POOL_FIRST, word)
|
|
gone = nft("list", "chain", "inet", "ffx", chain, check_rc=False)
|
|
check(gone.returncode != 0, "the allowlist chain is gone")
|
|
|
|
for s in (l4, l6, lself, u4):
|
|
s.close()
|
|
peer.stdin.close()
|
|
peer.wait(timeout=5)
|
|
print("%s: %d failure%s" % ("FAIL" if failures else "PASS", len(failures), "" if len(failures) == 1 else "s"))
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|