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.

Configured HTTP expectations show exact /api/echo mapping, separate 403, 404 and 413 rejections, a direct backend path and explicit forwarded-header assignments.
The exact reverse-proxy route: mechanism and evidence boundaries.

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

Sources