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.server or 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:

  1. Does a request with no token get rejected?
  2. Does a malformed request body get rejected?
  3. Is the service actually unreachable from anywhere but loopback?
  4. 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

  1. Bind explicitly to loopback, never rely on a firewall alone. 127.0.0.1 as the bind address makes external reachability architecturally impossible, not just policy-forbidden.
  2. 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.
  3. 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.
  4. 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.
  5. 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.”