Skip to content
C.W.K.
Lesson 01 of 05 · published

Doors That Only Open From Inside

~14 min · internal APIs, loopback, request headers, tunnels

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

Two kinds of caller, two path prefixes

Avatars has two audiences. Dad uses the studio in a browser, on a Mac, an iPad or a phone. My brains, my scripts and cwk-site's build scripts use it from code. They need different things, so they get different doors, and the path prefix says which door a request is knocking on.

Every request first passes one network gate: the peer must be loopback or a Tailscale address (the 100.64.0.0/10 range or Tailscale's IPv6 prefix). Anything else gets Use the family Tailscale connection. and a 403. After that, one middleware splits the traffic by prefix.

PrefixWho it is forWhat it asks
/api/*The studio's own web UI and iOS appSame origin, then the family PIN and Dad's admin capability (a few health and auth routes are exempt)
/internal/*Engines and scripts on the same MacA loopback peer carrying no browser or proxy headers

The /internal doors are the ones this track is about. Options, subjects, plans, generations, batches, files, exports and the Sidekick's read-only view all live there. The track's later lessons walk through what they answer. This one is about who may knock.

The header test

require_internal asks two questions. Is the peer a loopback address? And does the request carry any of three headers: x-forwarded-for, origin or sec-fetch-mode? A loopback peer with none of them passes. Everything else gets A local engine request is required.

Each header names a kind of caller that is not an engine:

  • sec-fetch-mode is a fetch metadata header. Current browsers attach it to every request, so a page that tries to call an internal door from the user's browser announces itself. A browser old enough to predate Fetch Metadata sends none, and Track 8 comes back to that one corner.
  • origin is sent by browsers on cross-origin requests and on most POSTs. A script running in a web page cannot hide it.
  • x-forwarded-for is the mark of a proxy. Avatars' HTTPS twin is a proxy on the same Mac, so its requests arrive from loopback. The forwarded header is what gives it away.

That last one is worth a second look, because the same header means opposite things on the two sides. client_ip(), which the /api side uses to ask Pippa's security service about the visiting device, trusts x-forwarded-for only when the peer is loopback: the proxy on this Mac is telling Avatars who is really visiting. On the /internal side that same header disqualifies the request. Pippa's reach module states the consequence in its docstring: the door refuses a tailnet peer and anything carrying X-Forwarded-For, so the twin can never stand in.

The test test_internal_boundary_rejects_browser_and_forwarded_clients pins it. From 127.0.0.1, a request with an origin, one with an x-forwarded-for and one with sec-fetch-mode: cors each get 403; the same request with no extra headers gets 200; and the same request from a non-loopback peer gets 403.

Principle: When a boundary is decided by where a request comes from, also look at what it carries. A proxy turns a remote caller into a local one, and a browser turns a web page into a client of your machine. Both leave marks in the headers.

Away from the Mac: a tunnel to loopback

Avatars runs on the registered service host. Pippa's brains often run somewhere else. If the door only opens to loopback, how does a brain on another Mac reach it?

It brings loopback with it. Pippa's avatars_client.origin() asks the family reach module, which answers in order: a pinned address if AVATARS_ENGINE_URL is set, the local loopback if the family registry places Avatars on this Mac, and otherwise an ssh tunnel over the family control route to that Mac's own loopback. The tunnel is opened on demand and left open for the next call. It listens on the member's port plus 10000, the same spot Beacon's reach code uses, so the two share one tunnel. The request then arrives at Avatars from 127.0.0.1 with no forwarded header, which is exactly what the door asks for.

cwk-site's image upload check does the same. Before an image goes to the public CDN, the site asks Avatars' export-policy door about its hash (track 8 explains why). It finds the door through the same reach logic, run with the system Python: that binary is Apple-signed, so the ssh it starts stays outside macOS's Local Network gate.

The bug the header test caught

That export check first used Node's built-in fetch. Every check came back 403, and every new image was blocked. Node's fetch follows the web platform's rules and adds sec-fetch-mode: cors to every request, so to Avatars it looked like a browser. The fix, in the third code block, is a plain node:http GET that sends only the headers it is given. The door was right. The client was wearing a browser's clothes.

What the door does not check

Inside the door, the caller names its actor in actor_soul_id. Avatars uses that actor for visibility and ownership, and it does not ask for proof. The trust is placement. A process that can run on the Avatars Mac, or open a tunnel to it over the family's control route, is already inside the family. The door keeps out the two things that are not: a browser, and anything relayed through the HTTPS twin. If you copy this pattern, copy that assumption knowingly.

The tailnet itself is the first wall around all of this. Solo Auth Quest teaches why Tailscale is the first line and the PIN the second; this door is a third, narrower wall for machine callers.

Code

cwkAvatars, backend/app.py: one middleware, the network gate first, then the door by path prefix (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: the internal door's header test (verbatim, lines 45-51)·python
def require_internal(request: Request):
    try:
        local = ipaddress.ip_address(request.client.host).is_loopback
    except (AttributeError, ValueError):
        local = False
    if not local or any(request.headers.get(h) for h in ("x-forwarded-for", "origin", "sec-fetch-mode")):
        raise StudioError("A local engine request is required.", 403)
cwk-site, scripts/avatar-export-policy.mjs: a plain node:http GET, because fetch adds sec-fetch-mode (verbatim, lines 12-27)·typescript
// Avatars answers /internal/* only for a loopback caller that carries no
// browser headers (x-forwarded-for, origin, sec-fetch-mode). Node's built-in
// fetch always adds `sec-fetch-mode: cors`, so every fetch-based check came
// back 403 and blocked each new image. A plain node:http request sends only
// what we give it.
export function plainGet(url, { signal } = {}) {
  return new Promise((resolve, reject) => {
    const req = http.get(url, { signal, headers: { accept: 'application/json' } }, (res) => {
      const chunks = [];
      res.on('data', (chunk) => chunks.push(chunk));
      res.on('end', () => resolve(new Response(Buffer.concat(chunks), { status: res.statusCode })));
      res.on('error', reject);
    });
    req.on('error', reject);
  });
}

External links

Exercise

Give one of your own local services a machine-only prefix. Write a check that admits a request only when the peer is loopback and none of x-forwarded-for, origin or sec-fetch-mode is present. Write four tests: a plain loopback request passes, and the same request with each header added is refused. Then call the route from a browser page and from Node's built-in fetch, and confirm both are refused.
Hint
In FastAPI or Starlette, request.client.host gives the peer and request.headers is case-insensitive. Put the check in a middleware keyed on the path prefix, so a new route under that prefix cannot forget it. If you run behind a reverse proxy, check whether it adds X-Forwarded-For before you trust any of this.

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.