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` 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.
+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"
#
# 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 {
+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"
#
# 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]
+1 -1
View File
@@ -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);本条描述默认行为。)
+24 -23
View File
@@ -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