The plaintext app port binds all interfaces and the README has no reverse-proxy deployment section #268

Closed
opened 2026-08-24 02:11:12 +02:00 by clawbot · 2 comments
Collaborator

Found by a TLS deployment audit that stood up real nginx terminating TLSv1.3 in front of the app — the configuration the owner will actually deploy, and one nothing had previously exercised.

The defect

internal/server/http.go:28 binds fmt.Sprintf(":%d", Port). There is no bind-address configuration, so the plaintext app listens on every interface and is reachable directly, bypassing the TLS proxy entirely.

Verified: ss -ltn shows LISTEN *:19001, and from a separate host curl http://172.17.0.1:19001/pages/login returns 200 — the full admin UI in cleartext. The unauthenticated receiver is reachable the same way.

On an internet-facing host this is the difference between "a TLS-only service" and "a TLS service with a cleartext twin on another port".

Why the README is the larger half

A scripted scan of every fenced code block in README.md for proxy_pass|server_name|listen|reverse_proxy|proxy_set_header|ssl_certificate returns zero matches. There is no reverse-proxy deployment section at all, so the deployment shape this product is built for has nothing copy-pasteable, and nothing warns the operator to bind or firewall the app port.

Four further omissions the audit proved matter, all in the same missing section:

  • The Host header requirement is never stated. Not theoretical: the audit's first correct-looking nginx config used the widely-copied proxy_set_header Host $host, which strips the port, and login failed with 403 origin invalid. $http_host fixed it. Any operator on a non-443 port hits this with nothing to search for.
  • client_max_body_size is never mentioned. nginx's default happens to equal the app's 1 MB cap, so nothing breaks today — but an operator raising webhooker's limit will silently keep hitting nginx's, and the 413 becomes nginx's HTML page instead of the app's message.
  • The access log records the proxy's address, never the client's — see #270. The proxy must be configured to log the client IP, and nothing says so.
  • HSTS's value and non-configurability are never stated — see #271.

What the README already gets right, verified by execution: the TRUSTED_PROXIES section is accurate in every particular tested — right-to-left chain walk, untrusted-peer fallback, X-Real-IP and True-Client-IP never read, unparseable value aborts startup, empty value warns. The prod/dev Secure table is accurate. This issue is about what is missing, not what is wrong.

Definition of done

  • A bind-address configuration option, defaulting to a value that does not silently expose cleartext on every interface. State the default chosen and why; changing the default is a behaviour change for anyone running without a proxy, so decide deliberately and document it.
  • A "Deployment behind a reverse proxy" section in README.md with a complete, correct, copy-pasteable nginx server block: ssl_certificate, proxy_set_header Host $http_host (NOT $host), X-Forwarded-For $proxy_add_x_forwarded_for, X-Forwarded-Proto $scheme, client_max_body_size.
  • That section must also state, because each was proven to matter: bind or firewall the app port; set WEBHOOKER_ENVIRONMENT=prod or lose session-cookie Secure (#269); set TRUSTED_PROXIES or per-IP rate limiting collapses into one global bucket; log the client IP at the proxy because webhooker's own log records only the proxy.
  • make fmt run over the changed markdown.

Verification

  • make check green.
  • Following the new README section VERBATIM must reproduce a working TLS deployment: login, event delivery and the event log all functioning over HTTPS. Prove it by doing it, and say so in the PR body.
  • Evidence that the app port is no longer reachable from another host under the documented configuration, with a positive control showing the proxy path still works.
Found by a TLS deployment audit that stood up real nginx terminating TLSv1.3 in front of the app — the configuration the owner will actually deploy, and one nothing had previously exercised. ## The defect `internal/server/http.go:28` binds `fmt.Sprintf(":%d", Port)`. There is no bind-address configuration, so the plaintext app listens on every interface and is reachable directly, bypassing the TLS proxy entirely. Verified: `ss -ltn` shows `LISTEN *:19001`, and from a separate host `curl http://172.17.0.1:19001/pages/login` returns **200** — the full admin UI in cleartext. The unauthenticated receiver is reachable the same way. On an internet-facing host this is the difference between "a TLS-only service" and "a TLS service with a cleartext twin on another port". ## Why the README is the larger half A scripted scan of every fenced code block in `README.md` for `proxy_pass|server_name|listen|reverse_proxy|proxy_set_header|ssl_certificate` returns **zero matches**. There is no reverse-proxy deployment section at all, so the deployment shape this product is built for has nothing copy-pasteable, and nothing warns the operator to bind or firewall the app port. Four further omissions the audit proved matter, all in the same missing section: - **The `Host` header requirement is never stated.** Not theoretical: the audit's first correct-looking nginx config used the widely-copied `proxy_set_header Host $host`, which strips the port, and login failed with `403 origin invalid`. `$http_host` fixed it. Any operator on a non-443 port hits this with nothing to search for. - **`client_max_body_size` is never mentioned.** nginx's default happens to equal the app's 1 MB cap, so nothing breaks today — but an operator raising webhooker's limit will silently keep hitting nginx's, and the 413 becomes nginx's HTML page instead of the app's message. - **The access log records the proxy's address, never the client's** — see https://git.eeqj.de/sneak/webhooker/issues/270. The proxy must be configured to log the client IP, and nothing says so. - **HSTS's value and non-configurability are never stated** — see https://git.eeqj.de/sneak/webhooker/issues/271. What the README already gets right, verified by execution: the `TRUSTED_PROXIES` section is accurate in every particular tested — right-to-left chain walk, untrusted-peer fallback, `X-Real-IP` and `True-Client-IP` never read, unparseable value aborts startup, empty value warns. The prod/dev `Secure` table is accurate. This issue is about what is missing, not what is wrong. ## Definition of done - A bind-address configuration option, defaulting to a value that does not silently expose cleartext on every interface. State the default chosen and why; changing the default is a behaviour change for anyone running without a proxy, so decide deliberately and document it. - A "Deployment behind a reverse proxy" section in `README.md` with a complete, correct, copy-pasteable nginx server block: `ssl_certificate`, `proxy_set_header Host $http_host` (NOT `$host`), `X-Forwarded-For $proxy_add_x_forwarded_for`, `X-Forwarded-Proto $scheme`, `client_max_body_size`. - That section must also state, because each was proven to matter: bind or firewall the app port; set `WEBHOOKER_ENVIRONMENT=prod` or lose session-cookie `Secure` (https://git.eeqj.de/sneak/webhooker/issues/269); set `TRUSTED_PROXIES` or per-IP rate limiting collapses into one global bucket; log the client IP at the proxy because webhooker's own log records only the proxy. - `make fmt` run over the changed markdown. ## Verification - `make check` green. - Following the new README section VERBATIM must reproduce a working TLS deployment: login, event delivery and the event log all functioning over HTTPS. Prove it by doing it, and say so in the PR body. - Evidence that the app port is no longer reachable from another host under the documented configuration, with a positive control showing the proxy path still works.
clawbot added this to the 1.0.0 milestone 2026-08-24 02:11:35 +02:00
Author
Collaborator

Plan.

Config. New BIND_ADDRESS, parsed with netip.ParseAddr — IP literal only, no hostnames, so there is no DNS lookup at startup and no ambiguity about which of several resolved addresses gets bound. Set-but-unparseable (garbage, localhost, 10.0.0.1:8080) aborts startup naming the variable and the value, per the existing iron rule. An address that parses but is not on the host fails at ListenAndServe and exits non-zero through the existing shutdownOnListenFailure path. internal/server/http.go switches from fmt.Sprintf(":%d") to net.JoinHostPort.

Default: 127.0.0.1. This is the judgement call, so stating it up front. Loopback is the only default that does not silently publish the admin UI and the unauthenticated receiver in cleartext on every interface, which is the defect. It does break a container, where a loopback-bound process is unreachable even with -p — so the documented docker run gains -e BIND_ADDRESS=0.0.0.0 and the README calls out that containers must set it. That breakage is loud (the container's healthcheck fails immediately) rather than silent, which is the right direction for a security default; the alternative default fails silently in the direction of exposure. No container auto-detection — that would be magic that is wrong in the cases that matter.

README. New "Deployment behind a reverse proxy" section with a working nginx server block (ssl_certificate, proxy_set_header Host $http_host — not $hostX-Forwarded-For $proxy_add_x_forwarded_for, X-Forwarded-Proto $scheme, client_max_body_size, proxy_pass to a literal 127.0.0.1 rather than localhost so IPv6 resolution cannot miss the v4 loopback bind), plus the four operator requirements the audit proved matter: bind or firewall the app port, WEBHOOKER_ENVIRONMENT=prod, TRUSTED_PROXIES, and logging the client IP at the proxy. One sentence on the fixed HSTS value. The existing TRUSTED_PROXIES and Secure-table sections are correct and are not being rewritten — the new section links to them.

Verification. make check; real nginx terminating TLS with a self-signed cert, driven from the new section verbatim, proving login, delivery and the event log over HTTPS; the app port unreachable off-host with a positive control through the proxy; the documented docker run still working.

Plan. **Config.** New `BIND_ADDRESS`, parsed with `netip.ParseAddr` — IP literal only, no hostnames, so there is no DNS lookup at startup and no ambiguity about which of several resolved addresses gets bound. Set-but-unparseable (garbage, `localhost`, `10.0.0.1:8080`) aborts startup naming the variable and the value, per the existing iron rule. An address that parses but is not on the host fails at `ListenAndServe` and exits non-zero through the existing `shutdownOnListenFailure` path. `internal/server/http.go` switches from `fmt.Sprintf(":%d")` to `net.JoinHostPort`. **Default: `127.0.0.1`.** This is the judgement call, so stating it up front. Loopback is the only default that does not silently publish the admin UI and the unauthenticated receiver in cleartext on every interface, which is the defect. It does break a container, where a loopback-bound process is unreachable even with `-p` — so the documented `docker run` gains `-e BIND_ADDRESS=0.0.0.0` and the README calls out that containers must set it. That breakage is loud (the container's healthcheck fails immediately) rather than silent, which is the right direction for a security default; the alternative default fails silently in the direction of exposure. No container auto-detection — that would be magic that is wrong in the cases that matter. **README.** New "Deployment behind a reverse proxy" section with a working nginx `server` block (`ssl_certificate`, `proxy_set_header Host $http_host` — not `$host` — `X-Forwarded-For $proxy_add_x_forwarded_for`, `X-Forwarded-Proto $scheme`, `client_max_body_size`, `proxy_pass` to a literal `127.0.0.1` rather than `localhost` so IPv6 resolution cannot miss the v4 loopback bind), plus the four operator requirements the audit proved matter: bind or firewall the app port, `WEBHOOKER_ENVIRONMENT=prod`, `TRUSTED_PROXIES`, and logging the client IP at the proxy. One sentence on the fixed HSTS value. The existing `TRUSTED_PROXIES` and `Secure`-table sections are correct and are not being rewritten — the new section links to them. **Verification.** `make check`; real nginx terminating TLS with a self-signed cert, driven from the new section verbatim, proving login, delivery and the event log over HTTPS; the app port unreachable off-host with a positive control through the proxy; the documented `docker run` still working.
Author
Collaborator

Done in #277.

Correction to the plan above. I claimed a container without BIND_ADDRESS would fail loudly via its health check. I then measured it instead of assuming, and the mechanism is different from what I described — but the conclusion holds, more strongly. The health check requests http://localhost:8080; busybox wget tries ::1 first, a 127.0.0.1 bind is not there, and the container goes unhealthy after ~95s with Connection refused in its health log. Not "passes anyway", and not "fails immediately" — it fails visibly, on the health-check clock. The README states this, and it independently confirms the section's instruction to proxy_pass at a literal 127.0.0.1 rather than localhost.

BIND_ADDRESS defaults to 127.0.0.1, IP literals only. internal/server builds the listen address with net.JoinHostPort.

Verified by execution, not by inspection:

  • Real nginx 1.27 terminating TLS with a self-signed cert, configured from the new README section verbatim. Login, source creation, POST to the receiver, outbound delivery to a target, and the event log all worked over HTTPS on a non-443 port.
  • The $host claim reproduced: with Host $host the login POST is 403 and the app logs reason="origin invalid"; with Host $http_host it is 303. One character.
  • From another host (a container against the host's 172.17.0.1): the app port refuses the connection under the default, while the proxy port answers 200. Same binary with BIND_ADDRESS=0.0.0.0 shows LISTEN *:19160 and serves that container 200 — the reported defect reproduced, then withheld by the default.
  • Fail-loud against the built binary: not-an-address, localhost, 127.0.0.1:8080 each exit 1 from config; 10.99.99.99 parses and exits 1 at listen with cannot assign requested address.
  • The documented docker run, with -e BIND_ADDRESS=0.0.0.0 added and -p narrowed to a host address, comes up healthy and serves the published port.
  • make check green: 20 packages, no data race, lint 0 issues. in Docker.

One disclosed fix beyond the issue: http.Server was assigned by the serving goroutine and read by the fx stop hook with nothing ordering them — a data race the detector reports as soon as anything starts and stops the server, and a nil dereference if a stop lands first. It is now constructed in New. No test could start and stop the server without it. Details in the PR.

Done in https://git.eeqj.de/sneak/webhooker/pulls/277. **Correction to the plan above.** I claimed a container without `BIND_ADDRESS` would fail loudly via its health check. I then measured it instead of assuming, and the mechanism is different from what I described — but the conclusion holds, more strongly. The health check requests `http://localhost:8080`; busybox `wget` tries `::1` first, a `127.0.0.1` bind is not there, and the container goes `unhealthy` after ~95s with `Connection refused` in its health log. Not "passes anyway", and not "fails immediately" — it fails visibly, on the health-check clock. The README states this, and it independently confirms the section's instruction to `proxy_pass` at a literal `127.0.0.1` rather than `localhost`. `BIND_ADDRESS` defaults to `127.0.0.1`, IP literals only. `internal/server` builds the listen address with `net.JoinHostPort`. Verified by execution, not by inspection: - Real nginx 1.27 terminating TLS with a self-signed cert, configured from the new README section verbatim. Login, source creation, `POST` to the receiver, outbound delivery to a target, and the event log all worked over HTTPS on a non-443 port. - The `$host` claim reproduced: with `Host $host` the login `POST` is `403` and the app logs `reason="origin invalid"`; with `Host $http_host` it is `303`. One character. - From another host (a container against the host's `172.17.0.1`): the app port refuses the connection under the default, while the proxy port answers `200`. Same binary with `BIND_ADDRESS=0.0.0.0` shows `LISTEN *:19160` and serves that container `200` — the reported defect reproduced, then withheld by the default. - Fail-loud against the built binary: `not-an-address`, `localhost`, `127.0.0.1:8080` each exit 1 from config; `10.99.99.99` parses and exits 1 at listen with `cannot assign requested address`. - The documented `docker run`, with `-e BIND_ADDRESS=0.0.0.0` added and `-p` narrowed to a host address, comes up `healthy` and serves the published port. - `make check` green: 20 packages, no data race, lint `0 issues.` in Docker. One disclosed fix beyond the issue: `http.Server` was assigned by the serving goroutine and read by the fx stop hook with nothing ordering them — a data race the detector reports as soon as anything starts and stops the server, and a nil dereference if a stop lands first. It is now constructed in `New`. No test could start and stop the server without it. Details in the PR.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#268