Skip to content

Harden internal endpoints: allow-list, /config scrub, loopback bind#239

Merged
diolektor merged 1 commit into
mainfrom
feature/internal-endpoint-access-control
Jun 21, 2026
Merged

Harden internal endpoints: allow-list, /config scrub, loopback bind#239
diolektor merged 1 commit into
mainfrom
feature/internal-endpoint-access-control

Conversation

@diolektor

Copy link
Copy Markdown
Contributor

Summary

Adds access control to the internal server (/health*, /metrics, /config), which is served by a separate Rust listener that PHP never touches — so it can only be guarded in-process.

What changed

INTERNAL_ALLOW_IPS allow-list (new)

  • A comma-separated CIDR list gates the internal router. A peer outside the list gets 403 before routing on /metrics, /config, plugin /__* routes, and unknown paths — so an outsider cannot even probe which paths exist.
  • Health endpoints (/health, /healthz, /readyz, /startupz and their long forms) are always served, so an orchestrator liveness/readiness probe or a load-balancer health check is never blocked into killing a healthy backend.
  • Unset/empty = allow all (prior behavior). Loopback is not implicit — list 127.0.0.1/32 to keep localhost/sidecar access. A malformed list aborts startup.
  • No bearer-token option, by design — a token invites exposing the port "because it's protected". The controls are network isolation + the allow-list.

Loopback-default bind

  • A port-only INTERNAL_ADDR (e.g. :9090) now binds 127.0.0.1 instead of failing to resolve. Bind an explicit 0.0.0.0:9090 to expose it off-host. Additive — fully-qualified values are unchanged, so this is not a breaking change.

/config scrub

  • /config no longer reports internal_addr or error_pages_dir — deployment topology and filesystem paths that aid an attacker and are not needed by metrics scrapers.

Private-aware startup warning

  • An off-host bind (0.0.0.0, ::, or a public address) with no INTERNAL_ALLOW_IPS logs a warn!; a private-interface bind logs info!; loopback is silent. The warning is suppressed once an allow-list is set, so it signals real exposure instead of firing for every deployment.

Implementation notes

  • CIDR parsing is extracted into a shared parse_cidr_list reused by TRUSTED_PROXIES and INTERNAL_ALLOW_IPS; new IpAllowList and a BindExposure classifier sit alongside it.
  • The accepted peer IP (previously discarded) is threaded to the gate and canonicalized (IPv4-mapped IPv6) before matching.

Verification

  • cargo fmt -- --check, cargo clippy --no-default-features -- -D warnings: clean.
  • cargo test --no-default-features: 863 unit + 10 integration passing (15 new unit tests).
  • Manual: 0.0.0.0 with no allow-list warns; a port-only :9099 (coerced to loopback) is silent; 0.0.0.0 + allow-list is silent.

Docs

README (en/ru/zh) and CHANGELOG document the new variable, the loopback default, the scrub, the warning, and the always-allowed health endpoints.

Security:
  - INTERNAL_ALLOW_IPS CIDR allow-list gates the internal router: a peer outside the list gets 403 before routing on /metrics, /config, plugin /__ routes and unknown paths, so it cannot probe which paths exist. Health endpoints (/health, /healthz, /readyz, /startupz and their long forms) are always served so orchestrator and load-balancer checks never break. Unset/empty allows all; loopback is not implicit (list 127.0.0.1/32 to keep localhost). A malformed list aborts startup. No bearer-token option by design — a token invites exposing the port "because it's protected".
  - Capture the accepted peer IP (previously discarded) and canonicalize IPv4-mapped IPv6 before matching.
  - /config no longer reports internal_addr or error_pages_dir — deployment topology and filesystem paths that aid an attacker and are not needed by metrics scrapers.
  - Private-aware startup warning: an off-host bind (0.0.0.0/::/public) with no INTERNAL_ALLOW_IPS warns; a private interface logs info; loopback is silent; the warning is suppressed once an allow-list is set, so it marks real exposure instead of firing for every deployment.

Config:
  - A port-only INTERNAL_ADDR (:9090) now binds 127.0.0.1 instead of failing to resolve; bind an explicit 0.0.0.0:9090 to expose it off-host. Additive — fully-qualified values pass through unchanged.

Refactor:
  - Extract parse_cidr_list(val, var_name), shared by TRUSTED_PROXIES and INTERNAL_ALLOW_IPS; add IpAllowList and the BindExposure classifier. Error messages keep the underlying CIDR parse cause.

Docs:
  - README (en/ru/zh) and CHANGELOG document INTERNAL_ALLOW_IPS, the loopback default, the /config scrub, the exposed-bind warning, and the always-allowed health endpoints.

15 tests (15 unit).
@diolektor diolektor merged commit 219993b into main Jun 21, 2026
6 checks passed
@diolektor diolektor deleted the feature/internal-endpoint-access-control branch June 21, 2026 22:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant