Files
picpeak/tests/nginx/test_request_logging.py
T
Paul Nothaft 99f857c1d1 fix: prevent nginx error logs from exposing bearer URLs
Suppress native request errors at every severity while retaining safe
access logs and process diagnostics. Exercise ordinary and critical
upstream failures across all four configs and both shipped nginx versions
in Docker, with controls that prove the critical error path is reached.

Strengthen gallery pagination coverage using deferred responses to assert
the four-request cap and server order under out-of-order completion.
2026-09-08 12:38:19 +02:00

161 lines
6.9 KiB
Python

"""Run with python3 tests/nginx/test_request_logging.py; requires Docker and openssl."""
import http.client
import os
from pathlib import Path
import re
import ssl
import subprocess
import tempfile
import time
import unittest
import uuid
ROOT = Path(__file__).resolve().parents[2]
IMAGE = os.environ.get("NGINX_TEST_IMAGE", "nginx:1.30-alpine")
CONFIGS = (
"nginx/nginx.conf",
"nginx/nginx.conf.example",
"frontend/nginx.conf",
"frontend/nginx.dev.conf",
)
TOKEN = "NGINX_TEST_CAPABILITY_73cb40b17c"
FORWARDED_IP = "198.51.100.9"
def run(*args):
return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT, timeout=120).strip()
class RequestLoggingTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary = tempfile.TemporaryDirectory(prefix="picpeak-nginx-test-")
cls.addClassCleanup(cls.temporary.cleanup)
cls.directory = Path(cls.temporary.name)
cls.network = "picpeak-nginx-test-" + uuid.uuid4().hex[:12]
run("docker", "network", "create", cls.network)
cls.addClassCleanup(run, "docker", "network", "rm", cls.network)
certs = cls.directory / "certs"
certs.mkdir()
run("openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", str(certs / "privkey.pem"), "-out", str(certs / "fullchain.pem"),
"-days", "1", "-subj", "/CN=your-domain.com",
"-addext", "subjectAltName=IP:127.0.0.1,DNS:your-domain.com")
def configuration(self, filename, critical, unsafe_control):
source = (ROOT / filename).read_text()
if unsafe_control:
# Positive control: the same request must expose the fixture token
# when native request errors are enabled, even at crit severity.
source, count = re.subn(r"error_log\s+/dev/null\s*;",
"error_log /var/log/nginx/error.log crit;", source)
self.assertEqual(count, 1)
# A source address absent from this disposable network makes bind()
# fail at CRIT deterministically, without exhausting runner resources.
bind = "proxy_bind 192.0.2.1;" if critical else ""
if filename.startswith("frontend/"):
(self.directory / "frontend.conf").write_text(source)
source = """worker_processes 1;
error_log /var/log/nginx/error.log notice;
events { worker_connections 128; }
http {
include /etc/nginx/mime.types;
%s
include /fixtures/frontend.conf;
}
""" % bind
else:
if "worker_processes auto;" in source:
source = source.replace("worker_processes auto;", "worker_processes 1;")
else:
source = "worker_processes 1;\n" + source
source = source.replace("http {", "http {\n " + bind, 1)
(self.directory / "nginx.conf").write_text(source)
# The standalone main configuration intentionally delegates vhosts.
(self.directory / "site.conf").write_text("""server {
listen 80;
server_name localhost;
location /api { proxy_pass http://backend:3000; }
}
""")
def request(self, port, tls, uri):
# Trust only our fixture certificate while retaining hostname checks.
context = ssl.create_default_context(cafile=str(self.directory / "certs" / "fullchain.pem"))
for attempt in range(30):
connection = (http.client.HTTPSConnection("127.0.0.1", port, context=context, timeout=3)
if tls else http.client.HTTPConnection("127.0.0.1", port, timeout=3))
try:
connection.request("GET", uri, headers={
"Host": "your-domain.com" if tls else "localhost",
"X-Forwarded-For": FORWARDED_IP,
"Referer": "https://example.invalid/gallery?token=" + TOKEN,
})
response = connection.getresponse()
response.read()
return response.status
except (ConnectionError, OSError):
if attempt == 29:
raise
time.sleep(0.1)
finally:
connection.close()
def exercise(self, filename, critical=False, unsafe_control=False):
self.configuration(filename, critical, unsafe_control)
name = "picpeak-nginx-test-" + uuid.uuid4().hex[:12]
try:
# DNS aliases cover both static and variable-based proxy_pass in
# the supplied configs. Nothing listens on the fixture's port 3000.
run("docker", "run", "-d", "--name", name, "--network", self.network,
"--network-alias", "backend", "--network-alias", "frontend",
"--sysctl", "net.ipv4.ip_nonlocal_bind=0",
"-p", "127.0.0.1::80", "-p", "127.0.0.1::443",
"-v", f"{self.directory}:/fixtures:ro",
"-v", f"{self.directory}/site.conf:/etc/nginx/sites-enabled/test.conf:ro",
"-v", f"{self.directory}/certs:/etc/letsencrypt/live/your-domain.com:ro",
IMAGE, "nginx", "-c", "/fixtures/nginx.conf", "-g", "daemon off;")
syntax = run("docker", "exec", name, "nginx", "-t", "-c", "/fixtures/nginx.conf")
self.assertIn("test is successful", syntax)
tls = filename.endswith(".example")
port = int(run("docker", "port", name, "443/tcp" if tls else "80/tcp").split(":")[-1])
paths = (
f"/api/customer/auth/password-reset/{TOKEN}?token={TOKEN}",
f"/api/secure-images/gallery/secure/1/{TOKEN}",
f"/api/public/newsletter/unsubscribe/{TOKEN}",
)
for uri in paths:
self.assertIn(self.request(port, tls, uri), (500, 502))
# Wait for the final access entry to reach the container log pipe.
for _ in range(30):
logs = run("docker", "logs", name)
if '"GET /api/public"' in logs:
break
time.sleep(0.05)
for surface in ("customer", "secure-images", "public"):
self.assertRegex(logs, rf'"GET /api/{surface}" 50[02] \d+ \d+\.\d+')
self.assertIn(FORWARDED_IP, logs)
if unsafe_control:
self.assertIn("[crit]", logs)
self.assertIn("bind(192.0.2.1) failed", logs)
self.assertIn(TOKEN, logs)
else:
self.assertNotIn(TOKEN, logs)
finally:
run("docker", "rm", "-f", name)
def test_tokens_stay_out_of_logs_for_ordinary_and_critical_upstream_errors(self):
for filename in CONFIGS:
for scenario in ("ordinary", "critical", "unsafe-control"):
with self.subTest(config=filename, scenario=scenario, image=IMAGE):
self.exercise(filename, critical=scenario != "ordinary",
unsafe_control=scenario == "unsafe-control")
if __name__ == "__main__":
unittest.main(verbosity=2)