🧪 Lab 21 – Verifying a Local-Only HTTP Service Cannot Be Reached or Misused
Lab Objective
Reproduce the verification checklist used on the real M4/Home Assistant sleep bridge: a minimal Flask-style HTTP service meant to be reachable only from 127.0.0.1, invoking exactly one fixed, non-shell subprocess action. The lab builds a toy version of that bridge and then attacks its own assumptions — wrong network origin, missing auth, malformed input, and log leakage — instead of just confirming the happy path works.
Lab Environment
- Language: Python 3,
http.serveror Flask (either works for the pattern) - Target: a toy local bridge exposing one action behind a token
- Core idea: a security control isn’t verified by the request that succeeds — it’s verified by the requests that correctly fail
Scenario
A bridge service needs to expose exactly one action (here, a stand-in POST /v1/action) to a caller on the same host only, gated by a token, invoking a fixed command with no shell interpolation. Before trusting it, four questions need direct, reproduced answers:
- Does a request with no token get rejected?
- Does a malformed request body get rejected?
- Is the service actually unreachable from anywhere but loopback?
- Does the service’s own logging leak the secret meant to protect it?
Commands / Structure Practiced
| Component | Purpose |
|---|---|
app.run(host="127.0.0.1", port=8765) |
Bind explicitly to loopback only, never 0.0.0.0 |
subprocess.run(["/usr/local/bin/fixed-action"], shell=False) |
Fixed argv, no shell, no string interpolation from request data |
require_token(request) |
Reject before touching any other logic if the token is missing or wrong |
validate_body(request) |
Reject a malformed or unexpected body shape before it reaches the action |
Step 1 - Build the Minimal Bridge
from http.server import BaseHTTPRequestHandler, HTTPServer
import subprocess, os, json
TOKEN = os.environ["BRIDGE_TOKEN"] # root-owned env file, 0600, never logged
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.headers.get("Authorization") != f"Bearer {TOKEN}":
self.send_response(401); self.end_headers(); return
length = int(self.headers.get("Content-Length", 0))
try:
body = json.loads(self.rfile.read(length) or b"{}")
except json.JSONDecodeError:
self.send_response(400); self.end_headers(); return
if body != {}:
self.send_response(400); self.end_headers(); return
subprocess.run(["/usr/local/bin/fixed-action"], shell=False, timeout=10)
self.send_response(200); self.end_headers()
HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()
Binding to 127.0.0.1 explicitly — not 0.0.0.0 — is the single most important line: it makes the service architecturally unreachable from any other network interface, rather than relying on a firewall rule that could later be misconfigured or forgotten.
Step 2 - Attack the Auth Path
curl -i -X POST http://127.0.0.1:8765/v1/action
# expect: 401, no Authorization header supplied
curl -i -X POST http://127.0.0.1:8765/v1/action -H "Authorization: Bearer wrong-token"
# expect: 401, wrong token rejected same as no token
A bridge that only tests the correct token in its own test suite has an unverified assumption about the wrong-token and no-token paths — both need their own explicit test, not an inference from the happy path.
Step 3 - Attack the Body Validation
curl -i -X POST http://127.0.0.1:8765/v1/action \
-H "Authorization: Bearer $BRIDGE_TOKEN" -d 'not json'
# expect: 400, malformed body rejected before the action runs
curl -i -X POST http://127.0.0.1:8765/v1/action \
-H "Authorization: Bearer $BRIDGE_TOKEN" -d '{"extra":"field"}'
# expect: 400, unexpected shape rejected — not silently ignored and actioned anyway
Rejecting an unexpected body shape, rather than silently accepting and ignoring extra fields, closes off a future path where a body could grow request parameters the fixed-argv design never intended to accept.
Step 4 - Confirm the Network Boundary Holds
# from a different host on the same LAN or Tailnet:
curl -i -X POST http://<real-tailnet-ip>:8765/v1/action -H "Authorization: Bearer $BRIDGE_TOKEN"
# expect: connection refused — the bind to 127.0.0.1 means this address never listens
This is the step that actually proves the loopback claim rather than assuming it from the code. A service that binds correctly refuses the connection outright; one that was accidentally bound to 0.0.0.0 would answer, and only an external-origin test catches that.
Step 5 - Scan for Secret Leakage
grep -r "$BRIDGE_TOKEN" /var/log/bridge/ 2>/dev/null
# expect: no matches
journalctl -u bridge.service --no-pager | grep -F "$BRIDGE_TOKEN"
# expect: no matches
A token that never leaks into request logs, error traces, or systemd journal output is a token that’s actually still a secret after the service has been running for a while — this has to be checked directly, not assumed from “the code doesn’t have a print statement for it.”
Security Takeaways
- Bind explicitly to loopback, never rely on a firewall alone.
127.0.0.1as the bind address makes external reachability architecturally impossible, not just policy-forbidden. - Fixed argv,
shell=False, no interpolation. The action the bridge runs should never be built from request data — it should be the same fixed command every time. - Test the rejection paths as carefully as the success path. No-token, wrong-token, malformed body, and unexpected-shape body all need their own explicit test.
- Verify the network boundary from outside, not just from the code. Only a request from a genuinely different origin proves the loopback bind actually holds.
- Scan logs for the secret, don’t assume it’s absent. Logging frameworks and error handlers have a way of accidentally including exactly the value you meant to protect.
Where This Applies Beyond the Lab
This is the same checklist behind any small, single-purpose internal service — a webhook receiver, an internal automation bridge, a local agent tool endpoint. The pattern generalizes: minimal network exposure, a fixed and narrow action surface, explicit negative-path testing, and a direct check that secrets never leak into logs. None of these are exotic controls; they’re just the ones that are easy to skip when a service “only needs to work for one caller, once.”
