Skip to content

feat(server): close DNS-rebinding gap with Host validation middleware#361

Open
cpcloud wants to merge 15 commits into
mainfrom
feat/host-origin-middleware
Open

feat(server): close DNS-rebinding gap with Host validation middleware#361
cpcloud wants to merge 15 commits into
mainfrom
feat/host-origin-middleware

Conversation

@cpcloud
Copy link
Copy Markdown
Collaborator

@cpcloud cpcloud commented May 19, 2026

Summary

  • New middleware rejects requests whose Host header doesn't match the configured listen address, closing a DNS-rebinding gap that bypassed the existing Sec-Fetch-Site CSRF check (the browser sees a rebound name as same-origin).
  • localhost and 127.0.0.1 are canonicalized against a loopback bind.
  • allowed_hosts config accepts additional hostnames (e.g. ["middleman.local:8091"]) for users who reach the dashboard by a custom name.
  • trust_reverse_proxy config skips the strict check and instead trusts the standard forwarded-host headers, for reverse-proxy deployments that use base_path.
  • 403 body explains the fix and names the allowed_hosts / trust_reverse_proxy config knobs.
  • Wire-level tests cover: rejected mismatched Host, accepted loopback canonicalization, allowed_hosts, and the trust_reverse_proxy forwarded-header path.

@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented May 19, 2026

roborev: Combined Review (a17deda)

Summary verdict: Medium issues remain around production host-check defaults, production imports, and full-stack proxy allowlist coverage.

Medium

  • internal/server/server.go:424
    pickHostCheckOptions falls back to test-friendly defaults for any non-nil config whose BindHostKey was not populated by Validate. That can mask unvalidated or partial configs and make production enforce hard-coded 127.0.0.1:8091 plus test hostnames instead of the actual configured bind.
    Fix: Use the fallback only when cfg == nil and there is no override; otherwise fail fast or derive the bind by validating/parsing the provided config.

  • internal/server/server.go:18, internal/server/server.go:438
    Production code imports testing and calls testing.Testing(), which pollutes the application’s global flag set with test flags and adds unnecessary production binary coupling.
    Fix: Remove the testing import and control test-only loopback relaxation explicitly through ServerOptions or a package-level variable initialized from _test.go.

  • internal/server/apitest/host_check_test.go:37
    Full-stack API tests only cover direct loopback acceptance and attacker-host rejection. The user-visible allowed_hosts and trust_reverse_proxy behavior is only covered by narrower server tests, leaving the HTTP API + SQLite path untested for forwarded public-host validation.
    Fix: Add apitest cases using HostCheckOptions{TrustReverseProxy: true, Allowed: ...} to verify allowed forwarded hosts reach /api/v1/pulls and disallowed or mismatched forwarded hosts are rejected.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@mariusvniekerk mariusvniekerk force-pushed the feat/host-origin-middleware branch from a17deda to f1c53d7 Compare May 20, 2026 13:36
@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented May 20, 2026

roborev: Combined Review (f1c53d7)

No Medium, High, or Critical findings were reported; the reviewed code looks clean.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented May 20, 2026

roborev: Combined Review (72e7d33)

Summary verdict: no Medium, High, or Critical findings to report.

The only reported issue was Low severity, so it is omitted per the review rules.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented May 20, 2026

roborev: Combined Review (330f8ed)

Host validation looks mostly sound, but there are medium-risk gaps around port = 0 compatibility and config-derived test coverage.

Medium

  • internal/config/config.go:834
    Validate() now derives the bind key through ParseHostKey, whose port parser rejects 0. Existing config loading allowed port = 0, so users or tests relying on ephemeral bind ports may now fail config load.
    Fix: Preserve existing port = 0 behavior by handling it separately and resolving the actual listener port before enforcing Host validation, or explicitly reject it via the existing port validation and update related tests/docs.

  • internal/server/apitest/host_check_test.go:41
    The full-stack Host validation tests use explicit ServerOptions.HostCheck, so they bypass the TOML/config-derived allowed_hosts and trust_reverse_proxy wiring. A regression in config.Load -> Config.ParsedAllowedHosts/BindHostKey -> NewWithConfig could ship without e2e coverage.
    Fix: Add an apitest/e2e case that builds the server from real config content containing allowed_hosts and trust_reverse_proxy, then exercises accepted and rejected Host/forwarded-host values.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented May 20, 2026

roborev: Combined Review (c236b7b)

No Medium, High, or Critical findings were reported.

All substantive findings were below Medium severity, and the security/default reviews otherwise found no issues.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@mariusvniekerk mariusvniekerk force-pushed the feat/host-origin-middleware branch from c236b7b to d3c90c9 Compare May 20, 2026 17:23
@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented May 20, 2026

roborev: Combined Review (d3c90c9)

No Medium, High, or Critical findings were reported.

The only finding was Low severity, so it is omitted per the review rules.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented May 20, 2026

roborev: Combined Review (98df236)

Medium issue found: host-check test fallback can still panic for partial configs.

Medium

  • internal/server/server.go:466 - HostCheckAllowLoopbackAnyPort is applied only after pickHostCheckOptions succeeds, so non-nil partial configs still panic before the httptest fallback can be used. The new internal/server/e2etest helper passes &config.Config{BasePath: "/", SSEBufferSize: size} with HostCheckAllowLoopbackAnyPort: true, but external test packages do not compile host_check_testmode_test.go, so this path can hit host is empty and panic.

    Suggested fix: Pass an explicit HostCheck from that helper, or make the resolver treat HostCheckAllowLoopbackAnyPort as permission to use the fallback for partial test configs before returning the error.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented May 20, 2026

roborev: Combined Review (7b28bf5)

No Medium, High, or Critical findings to report.

All reported findings were below the requested severity threshold.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented May 21, 2026

roborev: Combined Review (7b28bf5)

Summary verdict: No medium-or-higher severity issues were reported.

All review agents found no actionable findings to include.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

cpcloud and others added 15 commits May 22, 2026 20:17
middleman validates that the configured bind is loopback and gates
mutating /api/v1 calls on Sec-Fetch-Site, but the TCP listener accepts
requests regardless of Host. A page that resolves its hostname to
127.0.0.1 after the initial fetch (DNS rebinding) sends every fetch as
"same-origin" so checkCSRF passes; the request lands on the loopback
listener and is processed.

This spec describes the Host validation middleware (parser shared with
config.allowed_hosts via internal/config/hostmatch.go) and the
trust_reverse_proxy flag that opt-in honors X-Forwarded-Host /
Forwarded under proxied deployments. Both Backend Host and Public Host
must independently pass the allowlist gate, so a DNS-rebound request
spoofing X-Forwarded-Host is still rejected at Step 2 on the raw Host.
Tasks 1-7 cover: shared ParseHostKey parser in internal/config, two new
config fields, the host_check.go middleware, ServeHTTP wiring with a
strict precedence rule (caller override > validated cfg > cfg=nil test
default > panic on partial cfg), wire-level tests, full-stack apitest
coverage, and README documentation.

The cfg=nil test-friendly default keeps the dozens of pre-existing
internal/server test helpers working without per-test churn, while the
two known partial-cfg sites get targeted ServerOptions.HostCheck
overrides so the production contract stays strict.
Introduces a canonical HostKey type (lowercased host, port preserved or
empty, IPv6 bracketed) plus a ParseHostKey parser shared between
config validation (allowed_hosts entries) and the upcoming Host
validation middleware. Centralising the parser ensures the
"canonicalisation is identical at config-load time and request time"
invariant holds without duplication.

Rejects empty input, port-only values, unbracketed IPv6 literals, and
ports outside 1-65535.
Adds two config fields to support the upcoming Host validation
middleware. allowed_hosts is parsed via the shared ParseHostKey and
cached on the Config struct so the server constructor can derive the
runtime allowlist without re-parsing on each request setup.
trust_reverse_proxy opt-in flag governs whether X-Forwarded-Host /
Forwarded RFC 7239 host= are consulted.

Validate() also caches the canonical bind HostKey so the server
constructor can read it directly via BindHostKey(); the field is left
zero for partial test configs that bypass Validate.

Validate() itself stays side-effect-light; the trust_reverse_proxy
warning and the cfg=nil test-friendly default warning are emitted at
server construction time (next commit).
Introduces checkHost plus the supporting parsers for X-Forwarded-Host
and Forwarded RFC 7239 host=, gated on a HostCheckOptions value.
Three validation steps mirror the design spec: parse Backend Host,
match Backend against bind + loopback synonyms + allowed_hosts, and
(when trust_reverse_proxy is true) validate the Public Host from
forwarded headers against the same accept-set with conflict
detection across X-Forwarded-Host and Forwarded.

Wiring into Server.ServeHTTP happens in the next commit so this
diff can be reviewed in isolation.
Wires the Host validation middleware into ServeHTTP before the
existing CSRF gate. A DNS-rebound page that resolves
attacker.example to 127.0.0.1 was previously seen as same-origin
by the browser, slipping past Sec-Fetch-Site; this commit makes
the server reject any request whose raw Host (and, under
trust_reverse_proxy, forwarded host) does not match the bind,
loopback synonyms at the bind port, or an allowed_hosts entry.

HostCheckOptions gains an AllowLoopbackAnyPort flag that the
test-friendly fallback (cfg=nil or partial cfg) and any go-test
process turn on automatically. httptest.NewServer binds an
ephemeral port that callers cannot pre-declare in
allowed_hosts, so test binaries accept literal loopback IPs at
any port; testing.Testing() is false in production so the
strict spec semantics are preserved on the deployed binary.
The doJSON helper sets req.Host so partial-cfg tests with a
validated 127.0.0.1:8091 bind also pass cleanly.
Adds table-driven wire-level tests that exercise every row of
the spec's decision matrix: bind / loopback synonyms /
allowed_hosts (Step 2), forwarded headers under
trust_reverse_proxy (Step 3), and the 403 body shape. Each row
constructs a Server with explicit HostCheckOptions so the
test-friendly relaxation does not mask production behavior.
Direct tests on the unexported parseForwardedHost and
parseXForwardedHost helpers cover the malformed-but-present
cases that the round-trip path cannot otherwise hand to the
middleware.
Adds a full-stack apitest case that exercises the production
contract: an explicit HostCheckOptions override skips the
test-friendly AllowLoopbackAnyPort relaxation, so the middleware
behaves exactly as it would on a deployed binary. A request with
Host attacker.example:8091 returns the documented JSON 403 body
before any handler runs; a request whose Host matches the
configured bind reaches the handler and returns 200 with the
seeded PR list.
Resolve PR review findings by removing the production testing dependency from server host-check setup and deriving host validation options from non-nil config values when Validate has not populated cached keys. Keep the legacy partial-config fallback behind test-only wiring and add full-stack trusted proxy allowlist coverage for forwarded hosts.
The host-check repair made validated configs enforce their configured bind. Workspace API tests use a middleman.test apiclient base URL and, in some cases, httptest listeners on ephemeral loopback ports, so make that fixture opt in explicitly instead of relying on production test detection.
Workspace diff tests issue direct httptest requests against the full server fixture. After host validation became strict, those requests kept httptest's default example.com host and hit the middleware instead of the workspace handlers. Route them through the fixture's accepted test host so the tests exercise the intended API behavior.
Address roborev feedback by rejecting port 0 during config validation instead of letting Host validation fail later with an indirect parse error. Add full-stack API coverage that loads allowed_hosts and trust_reverse_proxy from TOML so the config-derived host-check path is exercised.
The SSE replay e2e helper builds a config-backed server but runs it through httptest.NewServer, so requests arrive with a random loopback port. Opt the helper into the explicit loopback-any-port test relaxation used by the other e2e fixtures so host validation does not block the stream before frames are emitted.
Roborev flagged the SSE replay fixture for relying on config-derived host-check setup before the loopback test relaxation. Give the helper an explicit HostCheck override so httptest random ports are accepted without depending on partial config derivation.
Rebasing onto the current main branch brought in the go.kenn.io/middleman module path. The host check files were still using the old github.com/wesm/middleman imports, which prevented the rebased branch from compiling.
@mariusvniekerk mariusvniekerk force-pushed the feat/host-origin-middleware branch from 7b28bf5 to 0dc80e6 Compare May 23, 2026 00:23
@roborev-ci
Copy link
Copy Markdown

roborev-ci Bot commented May 23, 2026

roborev: Combined Review (0dc80e6)

Host validation change has several blocking follow-ups before merge.

High

  • internal/server/host_check.go:97, internal/server/host_check.go:105
    trust_reverse_proxy accepts the first comma-separated X-Forwarded-Host / Forwarded value after joining all header values. If a reverse proxy appends forwarded headers instead of overwriting client-supplied ones, an attacker can inject an allowed first value while the real public host appears later. The backend Host may still pass if the proxy rewrites it, defeating the DNS-rebinding protection for that deployment mode.

    Fix: Fail closed on multiple forwarded-host values/commas unless there is an explicit trusted-hop model, or only accept the value written by the nearest trusted proxy with clear stripping requirements. Rejecting multiple forwarded host entries is the safest default here.

Medium

  • internal/config/config.go:1320
    AllowedHosts and TrustReverseProxy were added to Config, but the configFile save model and Save method do not include them. Any settings or repo-import write that calls cfg.Save will silently drop these fields from config.toml, breaking reverse-proxy/allowlist configuration on the next restart.

    Fix: Add both fields to configFile, populate them in Save, and add a save/load round-trip test.

  • internal/server/config_reload.go:39
    Host validation options are resolved once into s.hostOpts, but AllowedHosts and TrustReverseProxy are not included in the startup config snapshot. Editing only these fields reports restart_required: false even though the running middleware keeps using the old values.

    Fix: Include these fields in startupConfigSnapshot and add a reload test that changing them sets restart_required, or make s.hostOpts hot-reload safely.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

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.

2 participants