fix(security): enforce audited account, network and resource boundaries

Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
This commit is contained in:
cesnimda
2026-09-09 18:21:47 +02:00
parent 7d641edc7b
commit 0954876295
116 changed files with 10101 additions and 122 deletions
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Bounded, synthetic nginx topology; uses an installed image, never pulls/deploys."""
import argparse
import json
from pathlib import Path
import subprocess
import tempfile
import urllib.error
import urllib.request
import uuid
ROOT = Path(__file__).resolve().parents[1]
def docker(*args):
result = subprocess.run(["docker", *args], capture_output=True, text=True, timeout=30)
if result.returncode:
raise RuntimeError(result.stderr.strip())
return result.stdout.strip()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--image", default="nginx:1.29.8-alpine", help="Already installed nginx image with realip and wget")
parser.add_argument("--baseline", action="store_true", help="Use HEAD's pre-B06 nginx sources and demonstrate the old bypass")
args = parser.parse_args()
docker("info", "--format", "{{.ServerVersion}}")
docker("image", "inspect", args.image) # Fail before resource creation if unavailable.
prefix = "b06-" + uuid.uuid4().hex[:10]
containers, networks = [], []
artifact_root = ROOT / ".artifacts" / "ingress-topology"
artifact_root.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=prefix, dir=artifact_root) as directory:
directory = Path(directory).resolve()
assert directory.is_relative_to(artifact_root.resolve())
def create(name, *options):
name = prefix + "-" + name
docker("create", "--pull=never", "--name", name, *options)
containers.append(name)
return name
try:
edge = prefix + "-edge"
private = prefix + "-private"
docker("network", "create", edge)
networks.append(edge)
docker("network", "create", "--internal", private)
networks.append(private)
peer = create("peer", "--network", edge, "--entrypoint", "sleep", args.image, "300")
docker("start", peer)
peer_state = json.loads(docker("inspect", peer))[0]
peer_ip = peer_state["NetworkSettings"]["Networks"][edge]["IPAddress"]
for source, target in [("nginx.conf.template", "template"), ("configure-nginx-origin.sh", "configure.sh")]:
content = (subprocess.check_output(["git", "show", f"HEAD:job-tracker-ui/{source}"], cwd=ROOT).decode()
if args.baseline else (ROOT / "job-tracker-ui" / source).read_text())
(directory / target).write_text(content, newline="\n")
(directory / "backend.conf").write_text('''server {
listen 8080;
location / { default_type application/json;
return 200 '{"ip":"$http_x_forwarded_for","scheme":"$http_x_forwarded_proto","host":"$http_host","forwardedHost":"$http_x_forwarded_host","forwarded":"$http_forwarded","session":"$http_x_session_context"}'; }
}
''', newline="\n")
backend = create("backend", "--network", private, "--network-alias", "backend-web",
"-v", f"{directory / 'backend.conf'}:/etc/nginx/conf.d/default.conf:ro", args.image)
docker("start", backend)
frontend = create("frontend", "--network", edge, "--network-alias", "frontend",
"-p", "127.0.0.1::80", "-e", "APP_PUBLIC_BASE_URL=https://jobs.example.test",
"-e", "INGRESS_MODE=proxy", "-e", f"INGRESS_PROXY_CIDRS={peer_ip}/32",
"-v", f"{directory / 'template'}:/etc/nginx/jobtracker.conf.template:ro",
"-v", f"{directory / 'configure.sh'}:/docker-entrypoint.d/15-jobtracker-origin.sh:ro",
# Invoke through sh so mounted Windows execute bits do not determine the result.
"--entrypoint", "sh", args.image, "-c",
"sh /docker-entrypoint.d/15-jobtracker-origin.sh && exec nginx -g 'daemon off;'")
docker("network", "connect", private, frontend)
docker("start", frontend)
docker("exec", frontend, "sh", "-c", "for i in 1 2 3 4 5; do wget -q -O /dev/null http://127.0.0.1/health && exit 0; sleep 1; done; exit 1")
headers = {"Host": "jobs.example.test", "X-Forwarded-For": "198.51.100.10", "X-Forwarded-Proto": "https",
"X-Forwarded-Host": "attacker.example.test", "Forwarded": "host=attacker.example.test", "X-Session-Context": "synthetic-session"}
def proxied(extra=None):
values = {**headers, **(extra or {})}
command = ["docker", "exec", peer, "wget", "-S", "-O", "-"]
for name, value in values.items():
command.append(f"--header={name}: {value}")
command.append("http://frontend/api/probe")
return subprocess.run(command, capture_output=True, text=True, timeout=10)
binding = docker("port", frontend, "80/tcp")
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
request = urllib.request.Request(f"http://{binding}/api/probe", headers=headers)
try:
with opener.open(request, timeout=10) as response:
direct_status, direct_body = response.status, response.read().decode()
except urllib.error.HTTPError as error:
direct_status, direct_body = error.code, error.read().decode()
if args.baseline:
assert direct_status == 200 and json.loads(direct_body)["ip"] == "198.51.100.10"
print(json.dumps({"baselineDirect": direct_status, "forgedIpAccepted": True}))
return
assert direct_status == 403, (direct_status, direct_body)
successful = []
for client in ["198.51.100.10", "198.51.100.11", "2001:db8::10"]:
result = proxied({"X-Forwarded-For": client})
assert result.returncode == 0, result.stderr
body = json.loads(result.stdout)
assert body == {"ip": client, "scheme": "https", "host": "jobs.example.test", "forwardedHost": "", "forwarded": "", "session": "synthetic-session"}, body
successful.append(client)
for extra in [{"X-Forwarded-For": "203.0.113.9, 198.51.100.10"}, {"X-Forwarded-Proto": "http"}, {"X-Forwarded-Proto": "https,http"}]:
result = proxied(extra)
assert result.returncode != 0 and "400" in result.stderr, result.stderr
malformed = json.loads(proxied({"X-Forwarded-For": "invalid-address"}).stdout)
assert malformed["ip"] == peer_ip # realip fails closed to the real peer, not forged data.
backend_state = json.loads(docker("inspect", backend))[0]
assert not any(backend_state["NetworkSettings"]["Ports"].values())
backend_ip = backend_state["NetworkSettings"]["Networks"][private]["IPAddress"]
# A peer attached only to the edge network cannot route directly to the internal backend.
denied = subprocess.run(["docker", "exec", peer, "wget", "-T", "2", "-qO-", f"http://{backend_ip}:8080/api/probe"], capture_output=True, timeout=5)
assert denied.returncode != 0
print(json.dumps({"directNginx": direct_status, "trustedClientIps": successful, "chainsAndBadScheme": 400,
"malformedIp": "socket-peer fallback", "directBackend": "unreachable", "backendPublishedPorts": 0}))
finally:
for name in reversed(containers):
subprocess.run(["docker", "rm", "-f", name], capture_output=True, timeout=30)
for name in reversed(networks):
subprocess.run(["docker", "network", "rm", name], capture_output=True, timeout=30)
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
"""Real Compose rendering plus adversarial production-config mutations; stdlib only."""
import copy
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location("ingress", ROOT / "scripts/validate-ingress.py")
ingress = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ingress)
class IngressConfigTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
env = dict(os.environ, AI_SERVICE_TOKEN="synthetic-b06", APP_PUBLIC_BASE_URL="https://jobs.example.test",
INGRESS_PROXY_CIDRS="192.0.2.10/32", WEB_PROXY_SUBNET="172.31.250.0/29",
NEXT_PUBLIC_API_BASE_URL="/api")
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as file:
file.write("# No repository secrets are read by this fixture.\n")
path = file.name
try:
base = ["docker", "compose", "--env-file", path, "-f", "docker-compose.yml"]
def render(extra):
result = subprocess.run(base + extra + ["--profile", "bundled-ollama", "config", "--format", "json"],
cwd=ROOT, env=env, capture_output=True, text=True, timeout=30, check=True)
return json.loads(result.stdout)
cls.production = render([])
cls.development = render(["-f", "docker-compose.dev.yml"])
finally:
Path(path).unlink()
def test_effective_production_is_safe(self):
ingress.validate(self.production)
def test_development_remains_explicit_and_cannot_pass_production_preflight(self):
self.assertEqual(self.development["services"]["backend"]["ports"][0]["published"], "5202")
with self.assertRaises(ValueError):
ingress.validate(self.development)
def test_public_internal_ports_and_trust_bypasses_fail_closed(self):
def change(config, service, key, value):
config["services"][service][key] = value
mutations = [
lambda c: change(c, "backend", "ports", [{"target": 8080, "published": "5202"}]),
lambda c: change(c, "ai-service", "ports", [{"target": 8001, "published": "8001"}]),
lambda c: change(c, "ollama", "network_mode", "host"),
lambda c: c["services"]["frontend"]["ports"].append({"target": 80, "published": "8081"}),
lambda c: c["services"]["frontend"]["environment"].update(INGRESS_MODE="direct"),
lambda c: c["services"]["frontend"]["environment"].update(INGRESS_PROXY_CIDRS=""),
lambda c: c["services"]["frontend"]["environment"].update(INGRESS_PROXY_CIDRS="0.0.0.0/0"),
lambda c: c["services"]["frontend"]["environment"].update(INGRESS_PROXY_CIDRS="::/0"),
lambda c: c["services"]["frontend"]["environment"].update(INGRESS_PROXY_CIDRS="::/64"),
lambda c: c["services"]["frontend"]["environment"].update(INGRESS_PROXY_CIDRS="::ffff:0:0/96"),
lambda c: c["services"]["backend"]["environment"].update(ASPNETCORE_ENVIRONMENT="Development"),
lambda c: c["services"]["backend"]["environment"].update(DOTNET_ENVIRONMENT="Development"),
lambda c: c["services"]["backend"]["environment"].update(ASPNETCORE_FORWARDEDHEADERS_ENABLED="true"),
lambda c: c["services"]["backend"]["environment"].update(Proxy__KnownNetworks__0="0.0.0.0/0"),
lambda c: c["networks"]["web_proxy"].update(internal=False),
lambda c: c["services"]["ai-service"]["networks"].update(web_proxy={}),
lambda c: c["services"]["frontend"]["build"]["args"].update(NEXT_PUBLIC_API_BASE_URL="http://localhost:5202/api"),
]
for index, mutation in enumerate(mutations):
with self.subTest(mutation=index):
config = copy.deepcopy(self.production)
mutation(config)
with self.assertRaises(ValueError):
ingress.validate(config)
if __name__ == "__main__":
unittest.main()
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Validate rendered production Compose, never print its environment/secrets."""
import ipaddress
import json
import sys
from urllib.parse import urlsplit
def proxy_network(value):
try:
network = ipaddress.ip_network(value, strict=True)
except ValueError:
raise ValueError("Invalid ingress proxy CIDR") from None
private_ranges = (ipaddress.ip_network(value) for value in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"))
private = any(network.version == item.version and network.subnet_of(item) for item in private_ranges)
minimum = (24 if network.version == 4 else 64) if private else network.max_prefixlen
if network.prefixlen < minimum:
raise ValueError("Ingress proxy CIDRs must be narrow private networks or individual public proxy addresses")
return network
def validate(config):
services = config["services"]
for name, service in services.items():
if service.get("network_mode") == "host":
raise ValueError("Production services must not use host networking")
if name != "frontend" and service.get("ports"):
raise ValueError("Only frontend ingress may publish a production port")
front = services["frontend"]
ports = front.get("ports", [])
if len(ports) != 1 or ports[0].get("target") != 80 or str(ports[0].get("published")) != "3000" or ports[0].get("protocol") != "tcp":
raise ValueError("Production ingress must publish only the documented frontend 3000-to-80 mapping")
env = front["environment"]
if env.get("INGRESS_MODE") != "proxy":
raise ValueError("Production frontend must require the trusted proxy")
peers = env.get("INGRESS_PROXY_CIDRS", "").split()
if not peers:
raise ValueError("INGRESS_PROXY_CIDRS must identify the observed Traefik socket peers")
for peer in peers:
proxy_network(peer)
backend = services["backend"]["environment"]
if backend.get("ASPNETCORE_ENVIRONMENT") != "Production":
raise ValueError("Production backend environment must be explicit")
if backend.get("DOTNET_ENVIRONMENT") not in (None, "Production"):
raise ValueError("DOTNET_ENVIRONMENT must not override production")
if backend.get("ASPNETCORE_FORWARDEDHEADERS_ENABLED", "false").lower() != "false":
raise ValueError("Use explicit proxy trust, not automatic framework forwarding")
origin = backend.get("App__PublicBaseUrl", "")
parsed = urlsplit(origin)
if (parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password
or parsed.path not in ("", "/") or parsed.query or parsed.fragment
or env.get("APP_PUBLIC_BASE_URL") != origin):
raise ValueError("Frontend and backend must share the canonical HTTPS origin")
if front.get("build", {}).get("args", {}).get("NEXT_PUBLIC_API_BASE_URL") not in (None, "", "/api"):
raise ValueError("Production frontend must use the same-origin /api route")
if backend.get("Proxy__TrustForwardedHeaders") != "true":
raise ValueError("Production backend must resolve nginx forwarding metadata")
proxy = config["networks"]["web_proxy"]
if not proxy.get("internal") or proxy.get("external"):
raise ValueError("The nginx-to-backend network must be dedicated and internal")
networks = proxy["ipam"]["config"]
if len(networks) != 1:
raise ValueError("Exactly one nginx-to-backend subnet is supported")
subnet = proxy_network(networks[0]["subnet"])
if not subnet.is_private:
raise ValueError("The nginx-to-backend subnet must be private")
known = {key: value for key, value in backend.items() if key.startswith("Proxy__KnownNetworks__")}
if known != {"Proxy__KnownNetworks__0": str(subnet)}:
raise ValueError("Backend trust must exactly match the dedicated proxy subnet")
members = {name for name, service in services.items() if "web_proxy" in service.get("networks", {})}
if members != {"frontend", "backend"}:
raise ValueError("Only frontend and backend may join the trusted proxy network")
if __name__ == "__main__":
try:
validate(json.load(sys.stdin))
except (ValueError, KeyError, TypeError) as error:
# Validation errors describe keys/contracts, never configuration values.
print(f"Ingress preflight rejected the rendered production configuration: {error}", file=sys.stderr)
sys.exit(1)
print("Production ingress configuration validated; observed host/proxy/firewall checks are still required.")