Skip to content
C.W.K.
Lesson 04 of 04 · published

Three Gates Before a Picture

~13 min · tailscale, same-origin, pin-auth, middleware

Level 0Sketch
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Before any soul is asked about

The first three lessons decided which soul may see which picture. This one decides which device may talk to the studio at all. One middleware function in app.py runs on every request before any route, and it applies its checks in a fixed order: the network, then the path's own rule, then the admin question. A request that fails one never reaches the next.

Gate 1: the network

require_family_network reads the TCP peer address from the socket, never from a header. It allows three things: loopback, Tailscale's IPv4 range 100.64.0.0/10 (the shared address space RFC 6598 reserves for carrier-grade NAT) and Tailscale's IPv6 prefix fd7a:115c:a1e0::/48. Anything else, a home LAN address included, gets a 403 with Use the family Tailscale connection.

The backend test makes the point with two clients. A peer on the home LAN sends x-forwarded-for: 127.0.0.1 and still gets 403, because no header decides this gate. A tailnet peer, say 100.64.0.7, gets a 200 from /api/health and a 403 from /internal/providers, because the internal paths have a rule of their own.

Headers matter once, in client_ip. It trusts x-forwarded-for only when the TCP peer is loopback, which is how Tailscale Serve's local proxy arrives when it carries the studio's HTTPS twin. Serve answers only inside the tailnet, so a browser that came through the twin passes gate 1 as loopback, and its real address travels on in the header for the authority to judge.

The internal paths are for engines on this Mac

Under /internal/ the middleware calls require_internal: the peer must be loopback and the request must carry none of x-forwarded-for, origin or sec-fetch-mode. Serve adds the first, so nothing that came through the HTTPS twin passes. A browser on the Mac itself is caught by the third: current browsers send sec-fetch-mode on every request to a loopback address. origin is the weaker check, because a browser leaves it off a same-origin GET or HEAD and off a no-cors GET such as an image load, so it catches cross-origin calls and POSTs, not every request. The gate leans on Fetch Metadata here: an old browser that predates it, opening an internal URL directly on the Mac, would carry none of the three. Apart from that corner, what is left is a plain HTTP client running on the same machine, such as Pippa, or the site's export gate through a tunnel that ends at loopback. These are the doors that take an actor_soul_id, and nobody outside the Mac can knock on them.

Gate 2: the same origin

Every /api/ path then passes same_origin. It refuses a request whose sec-fetch-site is cross-site, and one whose Origin names a host other than the Host header. Another page open in Dad's browser cannot drive the studio with his session. The session cookie is also SameSite=strict, so a cross-site request would not carry it anyway. Two independent locks on one door.

Gate 3: is this Dad?

Every /api/ path except four needs the admin check. The exempt four are /api/health, /api/config, /api/auth and /api/auth/status, the four a device must reach before it has unlocked: a health probe, the app's config, the PIN form and the lock status. require_admin asks two authorities, and Avatars keeps no users, PINs or sessions of its own.

  1. Firelink's family facade reports the device's security status. A blocked device or deep lockdown is a 403. When PINs are on and this device is not authenticated, the answer is a 401 with Unlock with the family PIN.
  2. Pippa's /api/users reports the active user. Without the is_admin capability, the answer is a 403 with Avatar administration is available to Dad.

Both calls carry the visiting device's own headers: its resolved address as x-forwarded-for, plus its origin, fetch site, cookie and Pippa session header. The module's docstring says it in one line: Avatars asks the family authority about the actual visiting device. Only after all of this does a request reach the studio routes that read pictures with no actor, the admin view of every soul from the first lesson.

The PIN and the cookie

POST /api/auth hands the PIN to the family authority through the kit's verify_pin_verdict, naming the device's address as the client. The authority binds the session it mints to that address. Without it, the engine's own loopback would answer for every device, and five wrong PINs typed anywhere would put loopback on the blacklist, after which every PIN proxied through an engine is refused. A verifier that cannot be reached is a 503, never a wrong PIN. On success the studio sets pippa_session as HttpOnly and SameSite=strict, and Secure when the request is HTTPS or came through the loopback proxy with x-forwarded-proto: https. Solo Auth Quest builds that PIN layer and Tailscale's role as the first line; this lesson only shows a sibling consuming them.

Shared connections, private credentials

Avatars keeps one pooled HTTP client for every upstream call, sixteen connections at most. Pooling is safe only because each call is built from scratch with exactly the headers it is given, and its comment says so: connections are shared, each visiting device's credentials are not. A test proves it. The upstream answers with a Set-Cookie, and the next request, sent without a cookie, carries none. A client-level cookie jar here would quietly let one device's session ride on another device's request.

Every response that reached a route then gets X-Content-Type-Options: nosniff, and everything under /api/ and /internal/ also gets Cache-Control: private, no-store, so no shared cache keeps a copy of a protected answer. The gates' own refusals skip both. When a gate raises, the except branch returns a fresh JSONResponse that never passes the two lines that add the headers. Those answers carry an error message and no picture, so nothing protected leaks, but the headers belong to the answers that reached a route, not to every response.

Principle: Decide who may connect from the socket, who may act from the authority, and never from a field the caller fills in.

Code

cwkAvatars, backend/auth.py: gate 1 reads the socket, and forwarding is trusted only from loopback (verbatim, lines 7-28)·python
def require_family_network(request: Request):
    try:
        peer = ipaddress.ip_address(request.client.host)
        allowed = peer.is_loopback or peer in ipaddress.ip_network("100.64.0.0/10") or peer in ipaddress.ip_network("fd7a:115c:a1e0::/48")
    except (AttributeError, ValueError):
        allowed = False
    if not allowed:
        raise StudioError("Use the family Tailscale connection.", 403)


def client_ip(request: Request) -> str:
    peer = request.client.host if request.client else ""
    try:
        local = ipaddress.ip_address(peer).is_loopback
    except ValueError:
        local = False
    forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
    value = forwarded if local and forwarded else peer
    try:
        return str(ipaddress.ip_address(value))
    except ValueError as exc:
        raise StudioError("The client address is unavailable.", 403) from exc
cwkAvatars, backend/app.py: the middleware, network first, then the path's rule, then admin (verbatim, lines 124-140)·python
    @app.middleware("http")
    async def access(request, call_next):
        try:
            require_family_network(request)
            if request.url.path.startswith("/internal/"):
                require_internal(request)
            elif request.url.path.startswith("/api/"):
                same_origin(request)
                if request.url.path not in {"/api/health", "/api/config", "/api/auth", "/api/auth/status"}:
                    await (auth_check or require_admin)(request, upstream)
            result = await call_next(request)
            result.headers["X-Content-Type-Options"] = "nosniff"
            if request.url.path.startswith(("/api/", "/internal/")):
                result.headers["Cache-Control"] = "private, no-store"
            return result
        except StudioError as exc:
            return JSONResponse({"detail": str(exc)}, status_code=exc.status)
cwkAvatars, backend/auth.py: require_admin asks Firelink, then Pippa, about the visiting device (verbatim, lines 54-64)·python
async def require_admin(request, upstream):
    same_origin(request)
    headers = caller_headers(request)
    status = await upstream.json(upstream.settings.family, "/api/security/status", headers=headers)
    if status.get("blocked") or status.get("auth_mode") == "deep_lockdown":
        raise StudioError("Family access is locked for this device.", 403)
    if status.get("auth_mode") != "off" and not status.get("authenticated"):
        raise StudioError("Unlock with the family PIN.", 401)
    users = await upstream.json(upstream.settings.pippa, "/api/users", headers=headers)
    if not users.get("active", {}).get("capabilities", {}).get("is_admin"):
        raise StudioError("Avatar administration is available to Dad.", 403)

External links

Exercise

Take a small web service of your own and put one middleware in front of every route. Gate 1 accepts only loopback and your VPN's address range, read from the socket peer. Gate 2 refuses cross-site requests and mismatched Origin headers on your API paths. Gate 3 asks whatever holds your logins whether this device is authenticated and allowed. Then write two tests: a LAN client that forges x-forwarded-for still gets 403, and an upstream Set-Cookie never appears on the next request from a different caller.
Hint
In FastAPI, TestClient accepts a client=(host, port) tuple, which sets the socket peer your middleware sees, so you can test gate 1 without a real network. Use addresses from the documentation ranges or invented ones inside your VPN's range. For the cookie test, an httpx.MockTransport that records each request is enough.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.