Skip to content

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.

PrioritySourceBehaviour
1 (highest)Z4J_ALLOWED_HOSTS envPins 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 filePersistent, additive. Managed via z4j allowed-hosts subcommand.
4 (lowest)Auto-detectAlways 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.

Out of the box, no config, z4j accepts Host: headers matching:

  • localhost, 127.0.0.1, [::1]
  • socket.gethostname() - what uname -n shows
  • socket.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.x LAN IPs even on Debian-default /etc/hosts setups

That covers homelab / single-server / Tailscale / direct-LAN scenarios without config.

You’re behind a reverse proxy or have a public DNS name pointed at z4j:

Terminal window
z4j allowed-hosts add tasks.example.com
sudo 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.

Terminal window
z4j allowed-hosts list # print current persisted set
z4j allowed-hosts add host-a.example.com host-b # multiple at once; idempotent
z4j allowed-hosts remove old-host.example.com # idempotent
z4j allowed-hosts path # prints ~/.z4j/allowed-hosts

All operations write the file atomically (tmpfile + rename) so an interrupted invocation can’t corrupt it.

~/.z4j/allowed-hosts - one hostname or IP literal per line:

# Public domains
tasks.example.com
api.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

In production, prefer Z4J_ALLOWED_HOSTS over the file so you know exactly what’s whitelisted. No auto-detect surprises, no operator-file drift:

Terminal window
Z4J_ALLOWED_HOSTS='["brain.example.com","brain-internal.example.com"]' z4j serve

When this env var is set:

  • Auto-detect is skipped entirely
  • ~/.z4j/allowed-hosts is ignored
  • --allowed-host CLI flags are ignored

Make it the only source of truth in IaC / Docker Compose / Kubernetes manifests.

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.com
z4j: persisted from /root/.z4j/allowed-hosts: tasks.example.com
z4j: 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:

Terminal window
# systemd
journalctl -u z4j --since "10 minutes ago" | grep req_abc123
# Docker
docker logs z4j 2>&1 | grep req_abc123
# stdout (foreground dev)
# just scroll up - the INFO line is printed right before the 400 is returned

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:

Terminal window
z4j serve --debug-host-errors

This flag:

  • Is refused outside dev mode (Z4J_ENVIRONMENT=dev required)
  • Prints a loud warning at startup (see below)
  • Sets Z4J_DEBUG_HOST_ERRORS=1 for 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.

ScenarioResponse body
Default (any env, any bind){error, message, request_id} - minimal
--debug-host-errors in dev mode bound to localhostverbose with details
--debug-host-errors attempted in productionCLI refuses at startup (return 1)
Z4J_DEBUG_HOST_ERRORS=1 env var set in productionmiddleware ignores it (dev-mode gate)

Verbose info is only in the operator log, which is not web-reachable.

  • Rejection even after z4j allowed-hosts add - did you restart z4j serve? Changes take effect on process restart, not immediately.
  • Z4J_ALLOWED_HOSTS set, 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 to localhost:7700 would 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.