feat(networking): enforce isolated lab egress policy
Egress enforcement is a firewall-layer control, not an application-layer convention (correction B). HTTP_PROXY/HTTPS_PROXY configure containerd for a clean pull path; they enforce nothing on their own. - src/egress.js — per-environment dedicated iptables chain (ROOTNODE-ENV-<short-id>), built with -A (append, order-preserving) rather than the -I (insert-at-head) pattern an earlier design draft used, which would have installed rules in reverse order and let the default-deny shadow the proxy/DNS accepts. Required infrastructure (egress proxy, DNS) is explicitly permitted first, then RFC1918 ranges are dropped unconditionally, then a final default-deny catches the public internet. One jump rule per environment hooks its subnet into its chain from DOCKER-USER (the chain Docker guarantees it won't silently rewrite). Destroy is deterministic: unhook, flush, delete — no comment-grep matching. The ordering fix is empirically verified, not just reasoned about: the egress proxy's own address is itself an RFC1918 address (it's Platform-side infrastructure), and testing confirms it's reachable while the broader RFC1918 range is still blocked — exactly the scenario the original ordering bug would have broken. - platform/egress-proxy/ — shared Squid instance (one per deployment, not per-student), destination-allowlisted (dstdomain ACL against a plain-text allowlist file), starting set covers docker.io/ghcr.io/gcr.io/quay.io/k8s.io. Proxy performs domain allowlisting; the firewall chain above is what makes it unbypassable, not the other way around. The egress proxy's address is resolved per-student-network (each network's Docker gateway IP, since the proxy runs with host networking and is present on every bridge gateway) rather than a fixed hostname — a static address wouldn't be reachable from more than one isolated student network at a time. Found while testing with two simultaneous environments. Verified: student-to-student isolation (A cannot reach B's internal IP), RFC1918 blocking (A cannot reach a simulated trusted-infra address), and the proxy allowlist itself (CONNECT tunnel to an allowlisted host succeeds, non-allowlisted gets a clean 403 from Squid). Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
FROM ubuntu:22.04
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends squid \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY squid.conf /etc/squid/squid.conf
|
||||
COPY allowlist.txt /etc/squid/allowlist.txt
|
||||
|
||||
EXPOSE 3128
|
||||
CMD ["squid", "-N", "-d", "1"]
|
||||
@@ -0,0 +1,17 @@
|
||||
# RootNode Academy egress allowlist — one domain per line, matched with Squid's dstdomain ACL
|
||||
# (a leading "." matches the domain and all subdomains). Configuration-driven, operator-edited,
|
||||
# never student- or Lab-container-writable (docs/PHASE1_PLATFORM_FOUNDATION.md §6).
|
||||
#
|
||||
# Starting set covers what the existing scenario content actually pulls (nginx, busybox-style
|
||||
# images used across scenarios/data/*.json setup_commands) — confirm/expand against real usage
|
||||
# before relying on this for a live cohort; this is a first pass, not a final audit.
|
||||
|
||||
.docker.io
|
||||
.docker.com
|
||||
registry-1.docker.io
|
||||
auth.docker.io
|
||||
production.cloudflare.docker.com
|
||||
.ghcr.io
|
||||
.gcr.io
|
||||
.k8s.io
|
||||
.quay.io
|
||||
@@ -0,0 +1,22 @@
|
||||
# RootNode Academy shared egress proxy — one instance, not per-student.
|
||||
# Destination allowlisting only; the firewall (egress.js) is the actual enforcement boundary
|
||||
# that makes this proxy unbypassable, not the other way around
|
||||
# (docs/PHASE1_PLATFORM_FOUNDATION.md §5/§6, correction B).
|
||||
|
||||
http_port 3128
|
||||
|
||||
acl allowed_dst dstdomain "/etc/squid/allowlist.txt"
|
||||
acl SSL_ports port 443
|
||||
acl Safe_ports port 80 443
|
||||
acl CONNECT method CONNECT
|
||||
|
||||
http_access deny !Safe_ports
|
||||
http_access deny CONNECT !SSL_ports
|
||||
http_access allow allowed_dst
|
||||
http_access deny all
|
||||
|
||||
# Audit/troubleshooting log — requirement #7. Squid's default access log format already
|
||||
# includes timestamp, client IP, method, URL, and result code, which is what "why did this
|
||||
# scenario's image pull fail" support needs.
|
||||
access_log /var/log/squid/access.log squid
|
||||
cache deny all
|
||||
@@ -0,0 +1,102 @@
|
||||
// Per-environment firewall chain management — the actual security enforcement for egress
|
||||
// (correction B: HTTP_PROXY/HTTPS_PROXY are configuration, not the control; this is the
|
||||
// control). See docs/PHASE1_PLATFORM_FOUNDATION.md §5 for the full design and the ordering
|
||||
// bug this replaces.
|
||||
//
|
||||
// Each environment gets its own dedicated chain, built with -A (append — order-preserving,
|
||||
// no head-insertion trap), reached from DOCKER-USER by exactly one jump rule matching that
|
||||
// student's subnet. Requires the process to have NET_ADMIN/NET_RAW and see the host network
|
||||
// namespace (in production: the Provisioner running on/against the Lab Host; in Phase 1 dev:
|
||||
// a --network host --cap-add=NET_ADMIN --cap-add=NET_RAW container — see
|
||||
// platform/provisioner/docker-compose.dev.yml).
|
||||
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const RFC1918_RANGES = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'];
|
||||
|
||||
function chainName(environmentId) {
|
||||
// iptables chain names top out around 28 chars on the legacy backend — keep this well
|
||||
// under that. Deterministic from the environment id so setup/teardown always agree.
|
||||
const shortId = environmentId.replace(/-/g, '').slice(0, 12);
|
||||
return `ROOTNODE-ENV-${shortId}`;
|
||||
}
|
||||
|
||||
async function iptables(args) {
|
||||
try {
|
||||
return await execFileAsync('iptables', args);
|
||||
} catch (e) {
|
||||
throw new Error(`iptables ${args.join(' ')} failed: ${e.stderr || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort — used during teardown/cleanup where "already gone" is a success, not a failure.
|
||||
async function iptablesIgnoreMissing(args) {
|
||||
try {
|
||||
await execFileAsync('iptables', args);
|
||||
} catch (e) {
|
||||
// "No chain/target/match by that name" (ENOENT-equivalent for iptables) is fine on cleanup.
|
||||
if (!/No chain|does not exist|matching rule exist/i.test(e.stderr || '')) {
|
||||
throw new Error(`iptables ${args.join(' ')} failed: ${e.stderr || e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the egress chain for one environment and hooks it into DOCKER-USER.
|
||||
* @param {object} opts
|
||||
* @param {string} opts.environmentId
|
||||
* @param {string} opts.subnet - e.g. '172.19.0.0/24' (from docker network inspect)
|
||||
* @param {string} opts.egressProxyHost
|
||||
* @param {number} opts.egressProxyPort
|
||||
* @param {string} opts.dnsResolver
|
||||
* @param {string[]} [opts.extraAllowedHosts] - explicitly-required Platform endpoints, if any
|
||||
*/
|
||||
async function setupEgressChain({ environmentId, subnet, egressProxyHost, egressProxyPort, dnsResolver, extraAllowedHosts = [] }) {
|
||||
const chain = chainName(environmentId);
|
||||
|
||||
await iptables(['-N', chain]);
|
||||
|
||||
// Required infrastructure — explicitly permitted FIRST, before any blanket deny.
|
||||
await iptables(['-A', chain, '-d', egressProxyHost, '-p', 'tcp', '--dport', String(egressProxyPort), '-j', 'ACCEPT']);
|
||||
await iptables(['-A', chain, '-d', dnsResolver, '-p', 'udp', '--dport', '53', '-j', 'ACCEPT']);
|
||||
await iptables(['-A', chain, '-d', dnsResolver, '-p', 'tcp', '--dport', '53', '-j', 'ACCEPT']);
|
||||
for (const host of extraAllowedHosts) {
|
||||
await iptables(['-A', chain, '-d', host, '-j', 'ACCEPT']);
|
||||
}
|
||||
|
||||
// Everything that must never be reachable, regardless of the above — unconditional.
|
||||
for (const range of RFC1918_RANGES) {
|
||||
await iptables(['-A', chain, '-d', range, '-j', 'DROP']);
|
||||
}
|
||||
await iptables(['-A', chain, '-j', 'DROP']); // default deny — catches the public internet too
|
||||
|
||||
// Hook this student's subnet into the chain. -I is intentional here: new environments'
|
||||
// jump rules should be evaluated ahead of any broader, pre-existing DOCKER-USER rule.
|
||||
// Since every environment matches a disjoint source subnet, the order of jump rules
|
||||
// relative to each other never matters.
|
||||
await iptables(['-I', 'DOCKER-USER', '-s', subnet, '-j', chain]);
|
||||
|
||||
return { chain };
|
||||
}
|
||||
|
||||
async function teardownEgressChain({ environmentId, subnet }) {
|
||||
const chain = chainName(environmentId);
|
||||
await iptablesIgnoreMissing(['-D', 'DOCKER-USER', '-s', subnet, '-j', chain]);
|
||||
await iptablesIgnoreMissing(['-F', chain]);
|
||||
await iptablesIgnoreMissing(['-X', chain]);
|
||||
return { chain };
|
||||
}
|
||||
|
||||
async function chainExists(environmentId) {
|
||||
const chain = chainName(environmentId);
|
||||
try {
|
||||
await execFileAsync('iptables', ['-L', chain, '-n']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { chainName, setupEgressChain, teardownEgressChain, chainExists, RFC1918_RANGES };
|
||||
Reference in New Issue
Block a user