From dab041b9f64c56178fe3aaf1437332fe874c1683 Mon Sep 17 00:00:00 2001 From: wweir Date: Mon, 12 Oct 2026 16:09:19 +0800 Subject: [PATCH] docs(sowerd): keep the site-route authority rule in one place Second half of the review: the rationale for `authority` had been written into every comment that touched it, which is three copies to keep in sync. Keep the argument in docs/security.md and leave the config example, the struct comment and the constants stating only what the value does. - docs/security.md: record "an upstream trusting X-Forwarded-Host" under Rejected Directions, where the document already collects declined designs, instead of inside the new rule section; merge the per-route bullet into the client-controlled one they overlapped with. - ARCHITECTURE.md: the sentence added last commit left the fake-site rationale dangling after the site-route clause; split it back into one claim per sentence. - docs/plans/site-routing.md: the plan states the unconditional Host rewrite as a decision of its time, so it gets a forward note to the authoritative document rather than a rewrite of the record. - config/sowerd.go: clearer validation error ("unknown value", naming both accepted values) and a tighter comment; no behavior change beyond the message. --- ARCHITECTURE.md | 2 +- config/sowerd.go | 28 ++++++++--------------- config/sowerd.toml | 6 ++--- docs/plans/site-routing.md | 2 +- docs/security.md | 47 +++++++++++++++++++------------------- 5 files changed, 38 insertions(+), 47 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aa1fb54..347b1b0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -142,7 +142,7 @@ For non-transport fallback traffic, it can route by TLS SNI to per-domain upstre - `sowerd` prefers the user cache directory for ACME state, but falls back to `/var/cache/sower` so systemd services can start without `HOME`/`XDG_CACHE_HOME` or a config file. - `sowerd` fallback site routing is based only on exact TLS SNI matches; wildcard domains are not supported. - `sowerd` site routes use HTTP reverse proxying to support full `http://` and `https://` upstream URLs. The reverse proxy rewrites the outbound Host to the upstream host; a route can set `authority = "client"` to forward the client's authority instead, for upstreams that validate the authority themselves (see [docs/security.md](docs/security.md)). -- `sowerd` is the TLS edge for fallback HTTP. Reverse-proxied site routes and HTTP fake-site fallback overwrite `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Real-IP` from the immediate client; client-supplied forwarding headers are discarded. `X-Forwarded-Proto` is `https` because this path is the 443 listener after TLS termination. `X-Forwarded-Host` always carries the original authority; HTTP fake-site fallback and site routes with `authority = "client"` keep that authority in the Host header itself, matching the previous TCP relay for the fallback. +- `sowerd` is the TLS edge for fallback HTTP. Reverse-proxied site routes and HTTP fake-site fallback overwrite `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Real-IP` from the immediate client; client-supplied forwarding headers are discarded. `X-Forwarded-Proto` is `https` because this path is the 443 listener after TLS termination. `X-Forwarded-Host` always carries the original authority. The HTTP fake-site fallback has always kept that authority in the Host header itself, matching the previous TCP relay; a site route keeps it too when the route sets `authority = "client"`. - `sowerd` reverse-proxied fallback supports HTTP/1.1 protocol upgrades (including websocket) through the standard library ReverseProxy hijack path. - `sowerd` site routing applies bounded client header, upstream dial, TLS handshake, and upstream response-header timeouts. - `sowerd` advertises only HTTP/1.1 over TLS because fallback site routing and fake-site serving are HTTP/1.1 paths. diff --git a/config/sowerd.go b/config/sowerd.go index b90bfb0..0b88ddb 100644 --- a/config/sowerd.go +++ b/config/sowerd.go @@ -35,10 +35,8 @@ fake_site = "/var/www" # Fallback fake site directory or address (def # upstream = "http://127.0.0.1:8080" # # authority selects the Host header sent upstream: "upstream" (the default) -# sends the route's upstream host, "client" forwards the authority the client -# asked for. Use "client" only for an upstream that validates the authority -# itself (an exact host allowlist, or Origin against Host), because the -# forwarded name is client-controlled. +# sends the route's upstream host, "client" forwards the client's authority, +# for an upstream that validates that authority itself (see docs/security.md). # authority = "client" # # [site_routes.auth] @@ -77,12 +75,9 @@ type SiteRoute struct { Upstream string `usage:"upstream URL (http:// or https://)"` // Authority selects the Host header presented to the upstream: the empty // value and AuthorityUpstream send the upstream host, AuthorityClient - // forwards the authority the client asked for. An upstream that validates - // the visited authority itself (an exact host allowlist, or an - // Origin-versus-Host comparison) needs the client's name and rejects the - // rewritten one, so the choice is explicit and per route. Either way the - // forwarded name is client-controlled: only opt in for an upstream that - // checks it itself. + // forwards the authority the client asked for. Only an upstream that + // validates that authority itself wants the client's name; the reasoning is + // in docs/security.md. Authority string `usage:"authority presented to the upstream: upstream (default) or client"` Routes map[string]string `usage:"path-prefix to upstream URL overrides (e.g. /ws = http://127.0.0.1:8082)"` // Auth is an optional edge-side gate for visitors of this route. The @@ -187,12 +182,10 @@ func (c *SowerdConfig) Validate() error { // Authority values accepted by SiteRoute.Authority. const ( - // AuthorityUpstream sends the route's upstream host as the Host header. It - // is the default, and the behavior of every route that leaves the field - // unset. + // AuthorityUpstream sends the route's upstream host as the Host header; it + // is the default. AuthorityUpstream = "upstream" - // AuthorityClient forwards the authority the client asked for, for an - // upstream that validates that authority itself. + // AuthorityClient forwards the authority the client asked for. AuthorityClient = "client" ) @@ -201,14 +194,13 @@ const ( func (r SiteRoute) PreserveHost() bool { return r.Authority == AuthorityClient } // validateSiteRouteAuthority rejects a typo'd authority instead of silently -// falling back to the default: the two values differ in whether the upstream -// sees a client-controlled name. +// falling back to the default, which is the value that hides the client's name. func validateSiteRouteAuthority(i int, authority string) error { switch authority { case "", AuthorityUpstream, AuthorityClient: return nil } - return fmt.Errorf("site_routes[%d].authority: %q is not %q, %q, or empty", i, authority, AuthorityUpstream, AuthorityClient) + return fmt.Errorf("site_routes[%d].authority: unknown value %q (want %q or %q)", i, authority, AuthorityUpstream, AuthorityClient) } func validateSiteRoutes(routes []SiteRoute) error { diff --git a/config/sowerd.toml b/config/sowerd.toml index 6993a17..d7eda12 100644 --- a/config/sowerd.toml +++ b/config/sowerd.toml @@ -22,10 +22,8 @@ fake_site = "/var/www" # Fallback fake site directory or address (def # upstream = "http://127.0.0.1:8080" # # authority selects the Host header sent upstream: "upstream" (the default) -# sends the route's upstream host, "client" forwards the authority the client -# asked for. Use "client" only for an upstream that validates the authority -# itself (an exact host allowlist, or Origin against Host), because the -# forwarded name is client-controlled. +# sends the route's upstream host, "client" forwards the client's authority, +# for an upstream that validates that authority itself (see docs/security.md). # authority = "client" # # [site_routes.auth] diff --git a/docs/plans/site-routing.md b/docs/plans/site-routing.md index b1d9527..83fcc0f 100644 --- a/docs/plans/site-routing.md +++ b/docs/plans/site-routing.md @@ -26,4 +26,4 @@ ## 已推翻的实现决策 - ~~reverse proxy 会拒绝 HTTP upgrade 请求~~ — **已推翻**:现行实现不做任何拒绝,`httputil.ReverseProxy` 在 101 时经 hijack 直通,`TestReverseProxyConnWebSocket` 与 `TestReverseProxyConnPathRoutedWebSocket` 均通过,见 `ARCHITECTURE.md`。 -- reverse proxy 会把 outbound `Host` 显式改写为 upstream Host,路径和查询参数保留;并设置客户端 header、upstream dial、TLS handshake、upstream response header 超时。 +- reverse proxy 会把 outbound `Host` 显式改写为 upstream Host,路径和查询参数保留;并设置客户端 header、upstream dial、TLS handshake、upstream response header 超时。(更新 v1.7.1:route 可设 `authority = "client"` 保留客户端 authority,见 [docs/security.md](../security.md);本条描述默认行为。) diff --git a/docs/security.md b/docs/security.md index 212e17d..710a897 100644 --- a/docs/security.md +++ b/docs/security.md @@ -154,29 +154,25 @@ Rules: authority or the upstream's own host.** - By default the reverse proxy rewrites the outbound `Host` to the route's - upstream host and reports the client's authority in `X-Forwarded-Host`. - An upstream that virtual-hosts on its own address keeps working, and a - misconfigured route cannot make an upstream believe it serves a name it - does not own. -- `authority = "client"` forwards the client's `Host` verbatim instead. This - is what an upstream that validates the visited authority itself needs — an - exact host allowlist, or an `Origin` versus `Host` comparison — because it - rejects the rewritten host: pi-web answers `403` to every browser-originated - `/api` request, so the page loads and its WebSocket never connects. -- The forwarded authority is client-controlled, so preserving it does not make - the upstream safe by itself: the upstream must still validate the name, and - its allowlist has to include the public domain (pi-web's - `PI_WEB_ALLOWED_HOSTS`). The route's visitor gate still runs, and inbound - `X-Forwarded-*` headers from the client are still discarded, so one request - cannot tell the upstream two different stories. -- Keep it per route. An upstream that does not check the authority accepts any - name, and `authority = "client"` would then let a prober choose what that - upstream records in its logs, emits in redirects, and sets as a cookie - domain. -- Rejected alternative: teaching the upstream to trust `X-Forwarded-Host`. - Those headers are client-forgeable wherever sowerd is not the immediate - proxy, so the adaptation belongs to the edge, which already owns the - connection and strips what the client supplied. + upstream host and reports the client's authority in `X-Forwarded-Host`. An + upstream that virtual-hosts on its own address keeps working, and a + misconfigured route cannot make an upstream believe it serves a name it does + not own. +- `authority = "client"` forwards the client's `Host` verbatim instead. This is + what an upstream that validates the visited authority itself needs — an exact + host allowlist, or an `Origin` versus `Host` comparison — because it rejects + the rewritten host: pi-web answers `403` to every browser-originated `/api` + request, so the page loads and its realtime stream never connects. The + forwarded authority is client-controlled, so preserving it protects nothing + by itself: the upstream still has to check the name, and its allowlist has to + include the public domain (pi-web's `PI_WEB_ALLOWED_HOSTS`). The route's + visitor gate still runs, and inbound `X-Forwarded-*` headers from the client + are still discarded, so one request cannot tell the upstream two different + stories. +- Opt in per route, because an upstream that does not check the authority + accepts any name: with `"client"`, a prober chooses what that upstream records + in its logs, emits in redirects, and sets as a cookie domain. That is why the + default stays the upstream host. ## Unknown-SNI Certificate Fallback @@ -259,6 +255,11 @@ Decisions recorded here so they are not re-litigated without new evidence: unknown SNI (probes and failed transport auth included) and must keep answering like an ordinary public website. An operator's real site that needs a gate belongs in `site_routes`. +- **An upstream trusting `X-Forwarded-Host` instead of having its `Host` + forwarded** — the adaptation belongs to the edge: forwarding headers are + client-forgeable wherever sowerd is not the immediate proxy, while the edge + already owns the connection and discards what the client supplied. An + upstream that needs its own name gets it through `authority = "client"`. - **Rejecting known-weak passwords in `SowerdConfig.Validate`** — declined: a blocklist of placeholder strings is not a strength policy (it catches `change_me` and nothing else), and it would stop existing deployments from