A reverse proxy accepts client requests and makes a separate upstream exchange with an application. It can centralize routing, transport, headers, and admission policy when those behaviors are configured. It does not automatically hide every backend interface, authenticate every caller, or terminate TLS. The useful boundary is the actual traffic path and policy applied there, not simply the proxy’s position in a diagram.
When it breaks
A backend reachable through another route can bypass controls implemented only at the proxy. Not publishing a container port reduces one exposure path, but peers on an attached network may still reach the application. If backend authorization relies entirely on the claim that all traffic passed through the public proxy, a direct internal request can invalidate that assumption.
URI mapping can create a subtler bypass. Blocking /admin at the proxy does not help if a broad /api/ mapping also exposes the same backend handler as /api/admin. Review the final upstream URI, not just the visible client path. Publishing a specific endpoint is different from exposing every handler beneath a convenient prefix and hoping names stay separate.
Forwarded metadata requires deliberate handling. A client may send a fabricated address or scheme header, and a proxy does not make that value trustworthy merely by forwarding it. The application needs to know which fields the trusted hop sets and which callers can reach it. A browser response header also does not replace request authorization or network reachability controls.
Check it quickly
Save as check-proxy-entry.py; Python, local Docker and preloaded pinned images are required. The author reports nginx 1.28.0 and a disposable echo app; exact output is unavailable, and this review did not rerun it. Checks survive optimization. Only the proxy binds host loopback; HTTP only. Docker/build limits are 30/120 seconds, with shorter readiness deadlines. Cleanup checks owned containers, tags and network; unresolved removal or daemon completion fails closed:
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import http.client,ipaddress,json,socket,subprocess,time,uuid
def require(condition, message):
if not condition:
raise AssertionError(message)
uncertain_commands = []
NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609'
PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True,timeout=30):
try:
result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=timeout)
except subprocess.TimeoutExpired as error:
uncertain_commands.append(args)
raise RuntimeError('Docker command timed out: '+args[0]) from error
except KeyboardInterrupt:
uncertain_commands.append(args)
raise
if check:require(result.returncode==0,result.stderr)
return result
def command(*args):
return docker(*args).stdout.strip()
def cleanup_owned(groups):
# Inspect exact owned names; absence is not inferred from a failed inspect.
errors = []
for kind, names, format_string in groups:
try:
listing = command(kind, "ls", *( ["--all"] if kind == "container" else [] ),
"--format", format_string).splitlines()
present = sorted(set(names) & set(listing))
if present:
command(kind, "rm", *( ["--force"] if kind == "container" else [] ), *present)
except Exception as error:
errors.append(f"{kind} removal: {error}")
try:
remaining = command(kind, "ls", *( ["--all"] if kind == "container" else [] ),
"--format", format_string).splitlines()
if set(names) & set(remaining):
errors.append(f"{kind} remains: {sorted(set(names) & set(remaining))}")
except Exception as error:
errors.append(f"{kind} inventory unverified: {error}")
if uncertain_commands:
errors.append("A Docker command timed out or was interrupted; daemon-side completion remains uncertain")
require(not errors, "Cleanup not verified: " + "; ".join(errors))
print(json.dumps({"cleanup": "owned names absent in final inventories"}), flush=True)
def wait(predicate):
deadline=time.monotonic()+15
while time.monotonic()<deadline:
try:
if predicate():return
except OSError:pass
time.sleep(.1)
raise AssertionError('fixture did not become ready')
@contextmanager
def lab(configuration,files=None,backend=None,extra_ports=()):
name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend',name+'-check',name+'-direct']
print(json.dumps({"owned_containers":containers,"owned_tags":tags,"owned_network":name}),flush=True)
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration)
for path,content in (files or {}).items():
target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True)
target.write_bytes(content if isinstance(content,bytes) else content.encode())
docker('image','inspect',NGINX)
if backend is not None:docker('image','inspect',PYTHON)
docker('network','create',name)
if backend is not None:
(root/'backend.py').write_text(backend)
(root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n')
docker('build','--pull=false','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root),timeout=120)
docker('run','--pull=never','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1])
wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False,timeout=5).returncode==0)
(root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n')
docker('build','--pull=false','--network','none','-t',tags[0],str(root),timeout=120)
docker('run','--pull=never','--rm','--name',containers[2],'--network',name,tags[0],'nginx','-t')
published=[arg for value in (8080,*extra_ports) for arg in ('-p','127.0.0.1::'+str(value))]
docker('run','--pull=never','-d','--name',containers[0],'--network',name,*published,tags[0])
details=json.loads(docker('inspect',containers[0]).stdout)[0]
binding=details['NetworkSettings']['Ports']['8080/tcp'][0];require(binding['HostIp']=='127.0.0.1','non-loopback binding');port=int(binding['HostPort'])
def ready():
connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True
wait(ready)
def request(path,method='GET',headers=None,body=None):
connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5)
try:
connection.request(method,path,body=body,headers=headers or {})
response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read()
finally:connection.close()
request.port=port;request.network=name
request.ports={int(key.split('/')[0]):int(value[0]['HostPort']) for key,value in details['NetworkSettings']['Ports'].items() if value}
yield containers[0],request
finally:
cleanup_owned((("container", containers, "{{.Names}}"),
("image", tags, "{{.Repository}}:{{.Tag}}"),
("network", (name,), "{{.Name}}")))
def config(locations,extra=''):
return ("worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\n"
"log_format peer escape=json '{\"id\":\"$request_id\",\"peer\":\"$remote_addr\","
"\"supplied_xff\":\"$http_x_forwarded_for\"}';\n"
"access_log /tmp/peer.log peer;\n"+extra+
"\nserver { listen 8080; root /srv;\n"+locations+"\n}\n}\n")
def observed_peer(log_text,request_id,forged):
require(bool(request_id), 'missing request ID')
rows=[json.loads(line) for line in log_text.splitlines() if line.strip()]
matches=[row for row in rows if row.get('id')==request_id]
if not matches:return None
require(len(matches)==1, 'duplicate request ID')
row=matches[0]
require(row.get('supplied_xff')==forged, 'wrong request matched')
peer=str(ipaddress.ip_address(row['peer']))
require(peer!=forged, 'test peer collided with forged control')
return peer
backend=r'''
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
import json
class Handler(BaseHTTPRequestHandler):
protocol_version='HTTP/1.1'
def log_message(self,*args):pass
def respond(self,body=b''):
data=json.dumps({'path':self.path,'method':self.command,'body':body.decode(),
'host':self.headers.get('Host'),'forwarded_for':self.headers.get('X-Forwarded-For'),
'forwarded_proto':self.headers.get('X-Forwarded-Proto')}).encode()
self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data)))
self.end_headers();self.wfile.write(data)
def do_GET(self):self.respond()
def do_POST(self):self.respond(self.rfile.read(int(self.headers['Content-Length'])))
ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever()
'''
locations='''
location = /api/echo {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://backend:8081/echo;
}
location /admin { deny all; }
location / { return 404; }
'''
extra='client_max_body_size 1k; add_header X-Content-Type-Options nosniff always; add_header X-Lab-Request-Id $request_id always;'
bad='events {} http { xbcatm_invalid_candidate; }'
with lab(config(locations,extra),{'invalid.conf':bad},backend) as (proxy,request):
status,h,b=request('/api/echo',method='POST',body=b'hello',headers={'Host':'edge.example',
'X-Forwarded-For':'192.0.2.123','X-Forwarded-Proto':'https'})
data=json.loads(b)
require(status==200 and data['path']=='/echo' and data['body']=='hello' and data['method']=='POST','route/body mismatch')
peer_rows=[]
def peer_ready():
peer=observed_peer(docker('exec',proxy,'cat','/tmp/peer.log',timeout=5).stdout,
h.get('x-lab-request-id'), '192.0.2.123')
if peer is not None:peer_rows.append(peer);return True
return False
wait(peer_ready)
require(data['host']=='edge.example' and data['forwarded_for']==peer_rows[0] and
data['forwarded_proto']=='http','forwarded-header mismatch')
require(h['x-content-type-options']=='nosniff','success header missing')
results={'proxied_request':data,'proxy_observed_peer':peer_rows[0]}
status,h,_=request('/admin')
require(status==403 and h['x-content-type-options']=='nosniff','admin rejection mismatch')
results['admin_at_proxy']=status
require(request('/api/echo',method='POST',body=b'x'*2048)[0]==413,'body limit mismatch')
require(request('/unknown')[0]==404 and request('/api/admin')[0]==404,'route rejection mismatch')
results['size_and_route_rejections']=[413,404];results['admin_alias_rejected']=404
details=json.loads(docker('inspect',request.network+'-backend').stdout)[0]
require(not details['HostConfig']['PortBindings'],'backend published to host')
code=('import json,http.client; c=http.client.HTTPConnection("backend",8081,timeout=5); '
'c.request("GET","/admin"); r=c.getresponse(); '
'print(json.dumps({"status":r.status,"data":json.loads(r.read())})); c.close()')
internal=docker('run','--pull=never','--rm','--name',request.network+'-direct',
'--network',request.network,PYTHON,'python','-c',code)
direct=json.loads(internal.stdout)
require(direct['status']==200 and direct['data']['path']=='/admin','direct backend control failed')
results['backend_has_host_port_binding']=False;results['internal_direct_path']=direct['data']['path']
require(docker('exec',proxy,'cat','/srv/invalid.conf').stdout==bad,'candidate bytes changed')
docker('exec',proxy,'nginx','-t','-c','/etc/nginx/nginx.conf')
probe=docker('exec',proxy,'nginx','-t','-c','/srv/invalid.conf',check=False)
diagnostic='nginx: [emerg] unknown directive "xbcatm_invalid_candidate" in /srv/invalid.conf:1'
require(probe.returncode==1 and diagnostic in probe.stderr.splitlines() and
'nginx: configuration file /srv/invalid.conf test failed' in probe.stderr.splitlines(),
'expected candidate-specific nginx rejection not established: '+probe.stderr)
results['invalid_candidate_diagnostic']=probe.stderr
require(request('/api/echo')[0]==200,'live config unavailable after candidate check')
results['invalid_candidate_rejected_live_config_survives']=True
# Results are printed only after the context manager's checked cleanup returns.
print(json.dumps(results,sort_keys=True))
Interrupted or timed-out commands remain uncertain despite empty inventories. Cleanup failures chain the primary exception. Repeated interruption or abrupt death can prevent cleanup; final inventories cannot exclude delayed daemon work or concurrent changes.
The fixture expects /api/echo to reach /echo with method and body preserved. Host is forwarded without an allowlist and cannot establish tenant identity. The X-Forwarded-For check now requires equality with nginx’s logged $remote_addr, correlated by the response request ID and supplied forged header. Missing, duplicate or invalid evidence fails. This is the proxy’s inbound peer, potentially a Docker gateway, not the backend’s peer or an authenticated user. The scheme must be http.
At the proxy, /admin returns 403 and /api/admin returns 404. The latter control prevents the alternative URI mapping from reaching that backend handler. An oversized 2,048-byte submission exceeds the 1 KiB limit and returns 413; an unknown route returns 404. The fixture asserts nosniff on success and explicit denial. Results print only after checked cleanup returns.
The script checks Docker’s backend configuration and confirms that it has no host port binding. It then starts a separate disposable client on the same private network and requests backend /admin directly. The assertion requires success, illustrating the intended boundary: the proxy’s denial does not apply to a path that never visits the proxy.
The candidate names an unknown directive. After checking its bytes and active configuration, require exit 1 and exact directive/path/line and test-failed diagnostics. Launcher failures, missing executables, timeouts and unrelated errors cannot pass. Retain stderr; require live HTTP 200. This unexecuted pinned-image contract validates without activation. Reload, certificate rotation and deployment remain unverified; runtime and end-to-end technical acceptance remain blocked.
Keep entry policy and application responsibility explicit
Inventory the ways clients can reach each backend, including internal peers and separately published listeners. Apply application authorization where the trust model requires it, and limit network access to the intended callers. A private bridge is a connectivity arrangement, not proof that every attached workload has the same privileges or that backend traffic is encrypted.
Choose URI exposure and forwarded fields as part of the application contract. An exact public route can prevent accidental handler exposure in this small example; a larger application needs a reviewed route family rather than an arbitrary list copied from a test. Verify near-miss and rewritten paths whenever several external URLs could name the same internal operation.
TLS can terminate at the proxy, continue to the backend, or follow another explicitly designed boundary. Certificates, upstream verification, renewal, and protocol choices need their own checks. Similarly, one proxy remains one capacity and failure component even when it fronts many healthy applications. Centralizing policy reduces duplication only when the resulting configuration and availability are actually managed.
Finally, validate every allowed path: what the application receives, what the client sees, and what never reaches the application. Shared policy depends on these observable boundaries, not a diagram that shows only one entrance.
Further reading
- Content-Type should describe the bytes you actually send
- HTTP methods are a promise about side effects
Sources
Python assert statement, optimization omits assertions and calls inside them, checked 2026-10-18.
nginx HTTP proxy module, URI mapping, forwarded request headers, and backend transport configuration, checked 2026-10-15.
nginx reverse proxy guide, the client-facing proxy and its independent upstream exchange, checked 2026-10-15.
nginx HTTP core module, exact locations, body limits, and request handling boundaries, checked 2026-10-15.
nginx HTTP log module, JSON-escaped access-log fields, checked 2026-10-19.
Python subprocess, subprocess timeouts do not certify daemon completion, checked 2026-10-19.