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.
This commit is contained in:
wweir
2026-10-12 16:09:19 +08:00
parent b42b2738e7
commit dab041b9f6
5 changed files with 38 additions and 47 deletions
+1 -1
View File
@@ -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` 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` 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` 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` 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` 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. - `sowerd` advertises only HTTP/1.1 over TLS because fallback site routing and fake-site serving are HTTP/1.1 paths.
+10 -18
View File
@@ -35,10 +35,8 @@ fake_site = "/var/www" # Fallback fake site directory or address (def
# upstream = "http://127.0.0.1:8080" # upstream = "http://127.0.0.1:8080"
# #
# authority selects the Host header sent upstream: "upstream" (the default) # authority selects the Host header sent upstream: "upstream" (the default)
# sends the route's upstream host, "client" forwards the authority the client # sends the route's upstream host, "client" forwards the client's authority,
# asked for. Use "client" only for an upstream that validates the authority # for an upstream that validates that authority itself (see docs/security.md).
# itself (an exact host allowlist, or Origin against Host), because the
# forwarded name is client-controlled.
# authority = "client" # authority = "client"
# #
# [site_routes.auth] # [site_routes.auth]
@@ -77,12 +75,9 @@ type SiteRoute struct {
Upstream string `usage:"upstream URL (http:// or https://)"` Upstream string `usage:"upstream URL (http:// or https://)"`
// Authority selects the Host header presented to the upstream: the empty // Authority selects the Host header presented to the upstream: the empty
// value and AuthorityUpstream send the upstream host, AuthorityClient // value and AuthorityUpstream send the upstream host, AuthorityClient
// forwards the authority the client asked for. An upstream that validates // forwards the authority the client asked for. Only an upstream that
// the visited authority itself (an exact host allowlist, or an // validates that authority itself wants the client's name; the reasoning is
// Origin-versus-Host comparison) needs the client's name and rejects the // in docs/security.md.
// 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.
Authority string `usage:"authority presented to the upstream: upstream (default) or client"` 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)"` 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 // 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. // Authority values accepted by SiteRoute.Authority.
const ( const (
// AuthorityUpstream sends the route's upstream host as the Host header. It // 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 // is the default.
// unset.
AuthorityUpstream = "upstream" AuthorityUpstream = "upstream"
// AuthorityClient forwards the authority the client asked for, for an // AuthorityClient forwards the authority the client asked for.
// upstream that validates that authority itself.
AuthorityClient = "client" AuthorityClient = "client"
) )
@@ -201,14 +194,13 @@ const (
func (r SiteRoute) PreserveHost() bool { return r.Authority == AuthorityClient } func (r SiteRoute) PreserveHost() bool { return r.Authority == AuthorityClient }
// validateSiteRouteAuthority rejects a typo'd authority instead of silently // validateSiteRouteAuthority rejects a typo'd authority instead of silently
// falling back to the default: the two values differ in whether the upstream // falling back to the default, which is the value that hides the client's name.
// sees a client-controlled name.
func validateSiteRouteAuthority(i int, authority string) error { func validateSiteRouteAuthority(i int, authority string) error {
switch authority { switch authority {
case "", AuthorityUpstream, AuthorityClient: case "", AuthorityUpstream, AuthorityClient:
return nil 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 { func validateSiteRoutes(routes []SiteRoute) error {
+2 -4
View File
@@ -22,10 +22,8 @@ fake_site = "/var/www" # Fallback fake site directory or address (def
# upstream = "http://127.0.0.1:8080" # upstream = "http://127.0.0.1:8080"
# #
# authority selects the Host header sent upstream: "upstream" (the default) # authority selects the Host header sent upstream: "upstream" (the default)
# sends the route's upstream host, "client" forwards the authority the client # sends the route's upstream host, "client" forwards the client's authority,
# asked for. Use "client" only for an upstream that validates the authority # for an upstream that validates that authority itself (see docs/security.md).
# itself (an exact host allowlist, or Origin against Host), because the
# forwarded name is client-controlled.
# authority = "client" # authority = "client"
# #
# [site_routes.auth] # [site_routes.auth]
+1 -1
View File
@@ -26,4 +26,4 @@
## 已推翻的实现决策 ## 已推翻的实现决策
- ~~reverse proxy 会拒绝 HTTP upgrade 请求~~ — **已推翻**:现行实现不做任何拒绝,`httputil.ReverseProxy` 在 101 时经 hijack 直通,`TestReverseProxyConnWebSocket` 与 `TestReverseProxyConnPathRoutedWebSocket` 均通过,见 `ARCHITECTURE.md`。 - ~~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);本条描述默认行为。)
+24 -23
View File
@@ -154,29 +154,25 @@ Rules:
authority or the upstream's own host.** authority or the upstream's own host.**
- By default the reverse proxy rewrites the outbound `Host` to the route's - 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`. upstream host and reports the client's authority in `X-Forwarded-Host`. An
An upstream that virtual-hosts on its own address keeps working, and a upstream that virtual-hosts on its own address keeps working, and a
misconfigured route cannot make an upstream believe it serves a name it misconfigured route cannot make an upstream believe it serves a name it does
does not own. not own.
- `authority = "client"` forwards the client's `Host` verbatim instead. This - `authority = "client"` forwards the client's `Host` verbatim instead. This is
is what an upstream that validates the visited authority itself needs — an what an upstream that validates the visited authority itself needs — an exact
exact host allowlist, or an `Origin` versus `Host` comparison — because it host allowlist, or an `Origin` versus `Host` comparison — because it rejects
rejects the rewritten host: pi-web answers `403` to every browser-originated the rewritten host: pi-web answers `403` to every browser-originated `/api`
`/api` request, so the page loads and its WebSocket never connects. request, so the page loads and its realtime stream never connects. The
- The forwarded authority is client-controlled, so preserving it does not make forwarded authority is client-controlled, so preserving it protects nothing
the upstream safe by itself: the upstream must still validate the name, and by itself: the upstream still has to check the name, and its allowlist has to
its allowlist has to include the public domain (pi-web's include the public domain (pi-web's `PI_WEB_ALLOWED_HOSTS`). The route's
`PI_WEB_ALLOWED_HOSTS`). The route's visitor gate still runs, and inbound visitor gate still runs, and inbound `X-Forwarded-*` headers from the client
`X-Forwarded-*` headers from the client are still discarded, so one request are still discarded, so one request cannot tell the upstream two different
cannot tell the upstream two different stories. stories.
- Keep it per route. An upstream that does not check the authority accepts any - Opt in per route, because an upstream that does not check the authority
name, and `authority = "client"` would then let a prober choose what that accepts any name: with `"client"`, a prober chooses what that upstream records
upstream records in its logs, emits in redirects, and sets as a cookie in its logs, emits in redirects, and sets as a cookie domain. That is why the
domain. default stays the upstream host.
- 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.
## Unknown-SNI Certificate Fallback ## 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 unknown SNI (probes and failed transport auth included) and must keep
answering like an ordinary public website. An operator's real site that needs answering like an ordinary public website. An operator's real site that needs
a gate belongs in `site_routes`. 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 - **Rejecting known-weak passwords in `SowerdConfig.Validate`** — declined: a
blocklist of placeholder strings is not a strength policy (it catches blocklist of placeholder strings is not a strength policy (it catches
`change_me` and nothing else), and it would stop existing deployments from `change_me` and nothing else), and it would stop existing deployments from