Allowed hosts
z4j validates the HTTP Host: header on every inbound request. Requests with a Host value outside the configured allow-list get a 400 and never reach your routes. This defends against cache-poisoning attacks (an attacker who could spoof Host: evil.example.com would otherwise cause password-reset emails or webhooks to point at evil.example.com).
This page covers the full model: where the allow-list comes from, how to change it, and what happens when a request fails.
Four sources, in precedence order
Section titled “Four sources, in precedence order”| Priority | Source | Behaviour |
|---|---|---|
| 1 (highest) | Z4J_ALLOWED_HOSTS env | Pins the set. Replaces everything below. JSON array: '["a.example.com","b.example.com"]'. |
| 2 | --allowed-host CLI flag (repeatable) | Additive. Merged onto lower sources. Use for ad-hoc testing. |
| 3 | ~/.z4j/allowed-hosts file | Persistent, additive. Managed via z4j allowed-hosts subcommand. |
| 4 (lowest) | Auto-detect | Always on unless #1 is set. Adds localhost + system hostname + FQDN + all bound LAN IPs. |
When Z4J_ALLOWED_HOSTS is set, sources 2-4 are ignored. When it’s not, z4j merges sources 2-4 into a single effective set.
What auto-detect catches
Section titled “What auto-detect catches”Out of the box, no config, z4j accepts Host: headers matching:
localhost,127.0.0.1,[::1]socket.gethostname()- whatuname -nshowssocket.getfqdn()- FQDN, including Tailscale’s<host>.<tailnet>.ts.net- Every IP returned by
socket.gethostbyname_ex(hostname)- covers multi-interface boxes - The primary outbound IP (stdlib UDP-socket trick) - catches
192.168.x.xLAN IPs even on Debian-default/etc/hostssetups
That covers homelab / single-server / Tailscale / direct-LAN scenarios without config.
Adding a custom domain (the common case)
Section titled “Adding a custom domain (the common case)”You’re behind a reverse proxy or have a public DNS name pointed at z4j:
z4j allowed-hosts add tasks.example.comsudo systemctl restart z4j# (or: docker compose restart z4j)The host is persisted to ~/.z4j/allowed-hosts (one host per line, # comments allowed). Edits take effect on the next z4j serve start.
CLI reference
Section titled “CLI reference”z4j allowed-hosts list # print current persisted setz4j allowed-hosts add host-a.example.com host-b # multiple at once; idempotentz4j allowed-hosts remove old-host.example.com # idempotentz4j allowed-hosts path # prints ~/.z4j/allowed-hostsAll operations write the file atomically (tmpfile + rename) so an interrupted invocation can’t corrupt it.
File format
Section titled “File format”~/.z4j/allowed-hosts - one hostname or IP literal per line:
# Public domainstasks.example.comapi.example.com
# Internal reverse proxy (office LAN)z4j.internal.lan- Lines starting with
#are comments - Trailing
# ...on a value is also a comment - Blank lines are ignored
- Case-insensitive (stored as written, compared lower-case)
- No CIDR ranges, no wildcards - the middleware does exact-match comparison
Pinning via env var (production)
Section titled “Pinning via env var (production)”In production, prefer Z4J_ALLOWED_HOSTS over the file so you know exactly what’s whitelisted. No auto-detect surprises, no operator-file drift:
Z4J_ALLOWED_HOSTS='["brain.example.com","brain-internal.example.com"]' z4j serveWhen this env var is set:
- Auto-detect is skipped entirely
~/.z4j/allowed-hostsis ignored--allowed-hostCLI flags are ignored
Make it the only source of truth in IaC / Docker Compose / Kubernetes manifests.
Boot banner
Section titled “Boot banner”z4j serve prints the resolved allow-list on startup. Use this to confirm your setup:
z4j: serving on 0.0.0.0:7700, accepting Host headers: localhost, 127.0.0.1, [::1], your-server, your-server.lan, 192.168.1.42, tasks.example.comz4j: persisted from /root/.z4j/allowed-hosts: tasks.example.comz4j: to add more, run `z4j allowed-hosts add <name>` (persists across restarts).The “persisted from” line only appears when ~/.z4j/allowed-hosts is non-empty.
What happens when a request fails the check
Section titled “What happens when a request fails the check”The middleware returns HTTP 400:
{ "error": "invalid_host", "message": "Bad Request: invalid Host header.", "request_id": "req_abc123"}The response body is intentionally minimal. It does not include the rejected Host, the allow-list, or a fix command. If it did, a public reverse proxy (or anyone who can reach z4j’s port) would learn internal hostnames / LAN IPs / Tailscale node names just by probing with a wrong Host header.
Finding out what was rejected (as an operator)
Section titled “Finding out what was rejected (as an operator)”The operator-facing INFO log carries the full detail, correlatable via request_id:
INFO z4j: rejected request - Host header 'tasks.example.com' is not in the allow-list. Persist it via `z4j allowed-hosts add tasks.example.com` or restart with `z4j serve --allowed-host tasks.example.com`. Current allow-list: ['localhost', '127.0.0.1', '[::1]', 'brain.example.com']Pull the log by request_id:
# systemdjournalctl -u z4j --since "10 minutes ago" | grep req_abc123
# Dockerdocker logs z4j 2>&1 | grep req_abc123
# stdout (foreground dev)# just scroll up - the INFO line is printed right before the 400 is returnedLocal dev opt-in for verbose responses
Section titled “Local dev opt-in for verbose responses”For local-laptop development where z4j is bound to 127.0.0.1 only and no reverse proxy is involved, you can opt into the verbose response shape:
z4j serve --debug-host-errorsThis flag:
- Is refused outside dev mode (
Z4J_ENVIRONMENT=devrequired) - Prints a loud warning at startup (see below)
- Sets
Z4J_DEBUG_HOST_ERRORS=1for the middleware
z4j: WARNING - --debug-host-errors is ON. Rejected requests will return internal hostnames in the response body. For local development only.In this mode, the 400 body carries the same detail the log did:
{ "error": "invalid_host", "message": "Host header 'tasks.example.com' is not in the configured allow-list.", "request_id": "req_abc123", "details": { "rejected_host": "tasks.example.com", "allowed_hosts": ["localhost", "127.0.0.1", "[::1]"], "fix": "Persist the host: run `z4j allowed-hosts add tasks.example.com` and restart `z4j serve`." }}Do not enable this on anything reachable from the network. The whole threat model for the information-disclosure fix assumes a minimal response. Opt-in exists so you don’t have to journalctl to debug a Host-header typo on your laptop.
Security summary
Section titled “Security summary”| Scenario | Response body |
|---|---|
| Default (any env, any bind) | {error, message, request_id} - minimal |
--debug-host-errors in dev mode bound to localhost | verbose with details |
--debug-host-errors attempted in production | CLI refuses at startup (return 1) |
Z4J_DEBUG_HOST_ERRORS=1 env var set in production | middleware ignores it (dev-mode gate) |
Verbose info is only in the operator log, which is not web-reachable.
Troubleshooting
Section titled “Troubleshooting”- Rejection even after
z4j allowed-hosts add- did you restartz4j serve? Changes take effect on process restart, not immediately. Z4J_ALLOWED_HOSTSset, file ignored - by design. The env var pins the list.- Auto-detect missed my hostname - check
socket.gethostbyname_ex(socket.gethostname())in a Python REPL on the server. If the hostname isn’t in/etc/hosts, add it or fall back to the persistent file. - Reverse proxy strips the original Host - set
proxy_set_header Host $host;in nginx,header_up Host {host}in Caddy. The brain’s middleware checks the Host header as received by uvicorn, so a proxy that rewrites it tolocalhost:7700would always pass even if your DNS name isn’t in the allow-list (not a security issue - just confusing).
See also CLI reference: allowed-hosts.