Serving on a port: the Host check needs an exact match, so the README's proxy example and --host 0.0.0.0 refuse every client #13

Closed
opened 2026-10-01 10:48:04 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo-mcp at 0b29e70b9, reading serving.py against the MCP SDK's TransportSecurityMiddleware (mcp 2.1.1, as locked).

Related to #3.

Listening.security hands the SDK a list of allowed Host values, and the SDK accepts a header only on an exact match or on an entry ending in :*. The list is built from host:port strings, so the headers ordinary clients send never match in two common set-ups, and every request is refused with 421 "Invalid Host header" even when it carries the right bearer secret.

  • Where: src/postulo_mcp/serving.py:117-133 (Listening.security: hosts = [self.address, *self.allowed_hosts], origins built as f"{scheme}://{host}"), README.md:136-139, src/postulo_mcp/__main__.py:60-69 and :115-120; tests/test_server.py:444-457, which asserts the derived https://postulo.internal:443 origin.
  • What happens:
    1. The README says to run behind a proxy with --allowed-host proxy.example:443. A client reaching https://proxy.example sends Host: proxy.example, without the default port, and Caddy and Traefik pass the Host header through unchanged by default, so the server sees proxy.example, which is not in the list. The derived origin https://proxy.example:443 likewise never equals a browser's Origin: https://proxy.example.
    2. With --host 0.0.0.0 (or ::), the only non-loopback entry is 0.0.0.0:8000, which no client sends; a LAN client sends 192.168.1.5:8000 and is refused. Start-up meanwhile prints "Listening on 0.0.0.0:8000 … Every request must carry the bearer token", which implies it will work.
  • Why it matters: an operator who follows the README gets a server that refuses everything and a log line that points at the Host header, not at the advice. The check fails closed, so nothing is exposed.
  • Fix: for each --allowed-host without a port, allow the bare name and name:*; with a port, also allow the bare name when the port is 80 or 443. Build origins without a default port (https://name). When --host is a wildcard address, refuse to start unless at least one --allowed-host is given, and say why. Change the README example to --allowed-host proxy.example.

Done when

  • A test in tests/test_server.py builds Listening.security with --allowed-host proxy.example and checks that TransportSecurityMiddleware accepts Host: proxy.example and Origin: https://proxy.example.
  • A test in tests/test_server.py shows that serving.plan with host="0.0.0.0" and no --allowed-host stops with a sentence.
Found on 2026-09-29 in a read-only review of postulo-mcp at 0b29e70b9, reading `serving.py` against the MCP SDK's `TransportSecurityMiddleware` (mcp 2.1.1, as locked). Related to #3. `Listening.security` hands the SDK a list of allowed `Host` values, and the SDK accepts a header only on an exact match or on an entry ending in `:*`. The list is built from `host:port` strings, so the headers ordinary clients send never match in two common set-ups, and every request is refused with 421 "Invalid Host header" even when it carries the right bearer secret. - **Where:** `src/postulo_mcp/serving.py:117-133` (`Listening.security`: `hosts = [self.address, *self.allowed_hosts]`, origins built as `f"{scheme}://{host}"`), `README.md:136-139`, `src/postulo_mcp/__main__.py:60-69` and `:115-120`; `tests/test_server.py:444-457`, which asserts the derived `https://postulo.internal:443` origin. - **What happens:** 1. The README says to run behind a proxy with `--allowed-host proxy.example:443`. A client reaching `https://proxy.example` sends `Host: proxy.example`, without the default port, and Caddy and Traefik pass the Host header through unchanged by default, so the server sees `proxy.example`, which is not in the list. The derived origin `https://proxy.example:443` likewise never equals a browser's `Origin: https://proxy.example`. 2. With `--host 0.0.0.0` (or `::`), the only non-loopback entry is `0.0.0.0:8000`, which no client sends; a LAN client sends `192.168.1.5:8000` and is refused. Start-up meanwhile prints "Listening on 0.0.0.0:8000 … Every request must carry the bearer token", which implies it will work. - **Why it matters:** an operator who follows the README gets a server that refuses everything and a log line that points at the Host header, not at the advice. The check fails closed, so nothing is exposed. - **Fix:** for each `--allowed-host` without a port, allow the bare name and `name:*`; with a port, also allow the bare name when the port is 80 or 443. Build origins without a default port (`https://name`). When `--host` is a wildcard address, refuse to start unless at least one `--allowed-host` is given, and say why. Change the README example to `--allowed-host proxy.example`. ## Done when - A test in `tests/test_server.py` builds `Listening.security` with `--allowed-host proxy.example` and checks that `TransportSecurityMiddleware` accepts `Host: proxy.example` and `Origin: https://proxy.example`. - A test in `tests/test_server.py` shows that `serving.plan` with `host="0.0.0.0"` and no `--allowed-host` stops with a sentence.
tiagoagueda added this to the 0.6.0 milestone 2026-10-01 10:48:04 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo-mcp#13
No description provided.