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:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.")
|
||||
Reference in New Issue
Block a user