diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0e3c5a1..9bfea0f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -105,7 +105,7 @@ For non-transport fallback traffic, it can route by TLS SNI to per-domain upstre 10. Probe the connection's first bytes to identify the `sower` transport. 11. If matched, authenticate (the frame checksum covers command, port, and target via HMAC-SHA256; empty or control-character targets are rejected) and relay traffic to the decoded target. The header read is bounded by its own deadline so a connection that sends only the probe byte cannot hold a goroutine and fd forever. 12. If authentication fails or no transport matches, read the TLS SNI from the terminated TLS connection. -13. If the SNI exactly matches a configured `site_routes` domain, reverse-proxy the decrypted HTTP/1.1 request to that route's `http://` or `https://` upstream URL. +13. If the SNI exactly matches a configured `site_routes` domain, apply that route's optional HTTP Basic visitor gate, then reverse-proxy the decrypted HTTP/1.1 request to that route's `http://` or `https://` upstream URL (URL userinfo credentials are sent to the upstream as Basic auth). A matched route always goes to the reverse proxy: `site_routes` serves HTTP/1.1 only. 14. If the SNI has no route and the decrypted probe is an HTTP/1.x request line, reverse-proxy to `fakeSite` while preserving the original Host and setting edge forwarding headers. Otherwise replay the original bytes to `fakeSite` with a TCP relay, including sower auth failures and other non-HTTP traffic. ## sowerd Install Flow @@ -132,6 +132,7 @@ For non-transport fallback traffic, it can route by TLS SNI to per-domain upstre - Traffic monitoring reports proxied payload bytes and request/connection counters, not packet-level network accounting. Per-domain byte attribution is batched per connection (32 KiB threshold or 500 ms window) so the relay hot path pays two atomics per I/O instead of a global mutex; Close always drains the remainder. - The process sets a soft Go memory limit (128 MiB by default; an explicit `GOMEMLIMIT` wins, `SOWER_MEMORY_LIMIT_MB` overrides with a MiB value, `0` disables). The default ad/china/gfw rule lists build ~40 MiB of suffix trees and GOGC's 2x target would otherwise keep resident memory near 250 MiB on an idle gateway. - Upstream TLS behavior is configured only on the client side; `sowerd` remains a normal TLS server and does not need uTLS-specific logic. +- Site routes are public endpoints (autocert issues for their domains, and issued certificates are published in Certificate Transparency logs), so a route whose upstream cannot authenticate its own visitors can opt into an edge-side HTTP Basic gate (`site_routes.auth`). The gate consumes the visitor's `Authorization` header — the edge credential is not the upstream's — and throttles accumulated failures per source IP. This is a perimeter gate for the route's HTTP surface, not an account system, a replacement for the upstream's own authentication, or a second factor; the mechanism and its rejected alternatives (session login, gating the fake-site decoy) are recorded in `docs/security.md`. - Rule loading supports local files and remote HTTP sources; remote downloads must use the configured upstream proxy and fail startup if the proxy path cannot fetch them. - Domain rule files support per-router skip rules for filtering third-party file entries without removing explicit local rules. - HTTP/HTTPS access probes are cached with an hour-long write TTL through `github.com/maypok86/otter/v2`, keeping repeated smart-routing checks bounded without hiding later reachability changes indefinitely. diff --git a/README.md b/README.md index d03f34b..8b4f731 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,26 @@ Sower 节点需要能监听 `53/udp`、`80/tcp`、`443/tcp` 和 `1080/tcp`。如 当 `fake_site` 指向本地目录时,`sowerd` 只会通过 `127.0.0.1:80` 的回退流量服务这个目录;公网 HTTP 流量仍会重定向到 HTTPS。未命中 `site_routes` 的 HTTP/1.x 浏览请求会反代到 `fake_site`(目录模式为 loopback `:80`,或配置的 `host:port`),并覆盖写入 `X-Forwarded-For`、`X-Forwarded-Proto`、`X-Forwarded-Host`、`X-Real-IP`;非 HTTP 流量仍按原字节 TCP relay。 +### 站点路由的访问认证 + +`site_routes` 的域名会随 Let's Encrypt 证书进入 Certificate Transparency 日志,任何人都能查到,因此上游站点如果自身没有登录功能,就等于公开。可以给单条路由加一个边缘认证门(HTTP Basic): + +```toml +[[site_routes]] +domains = ["nas.example.com"] +upstream = "http://127.0.0.1:5000" + +[site_routes.auth] # 两个字段都填才生效,留空即不启用 +username = "admin" +password = "your_long_random_password" +``` + +- 只作用于这条路由的反代流量(路径分流和 WebSocket 升级都包含在内)。`sower` 客户端的传输流量在 SNI 路由之前就已分流,只用传输密码认证,不受影响。 +- 浏览器 WebSocket 是已知的边界情况:门禁会拦到 `Upgrade` 请求,但脚本无法给 WebSocket 握手设置 `Authorization`,浏览器只能靠复用该 origin 已缓存的 Basic 凭证,能否复用因浏览器而异(未验证);门禁按路由生效,无法只放行 `/ws`。每次失败握手还会计入按来源 IP 的锁定计数,所以会自动重连的客户端(socket.io 之类)可能把自己这个 IP 锁在整条路由之外(连普通页面也一起锁)15 分钟。把浏览器的 WS 路由放在门禁后面之前请先自测,或让该客户端走 `sower` 传输。 +- 认证通过后客户端的 `Authorization` 头会被丢弃,不会转发给上游,也不会写进上游日志。 +- 同一来源 IP 累计 5 次失败(中间没有成功请求)会被拒绝 15 分钟(返回 429),所以弱口令不会被直接暴力枚举。但它是边缘门禁,不是上游自身认证的替代品,也不是账号系统(一条路由一组账号,没有会话和登出)。 +- 上游需要认证时把凭证写在 upstream 的 userinfo 里(`upstream = "http://user:pass@127.0.0.1:5000"`),`sowerd` 会把它作为 Basic 凭证发给上游;错误日志中该密码会被脱敏。 + 启动方式有两种。 直接运行: diff --git a/cmd/sowerd/main.go b/cmd/sowerd/main.go index 6ca5428..6081a25 100644 --- a/cmd/sowerd/main.go +++ b/cmd/sowerd/main.go @@ -519,11 +519,12 @@ type pathRoute struct { } // siteEntry is the routing target for one site route: a default upstream plus -// optional path-prefix overrides. +// optional path-prefix overrides and an optional visitor gate. type siteEntry struct { upstream *url.URL paths []pathRoute - preserveHost bool // fake-site fallback keeps the original Host header + basicAuth *siteBasicAuth // nil when the route is not gated + preserveHost bool // fake-site fallback keeps the original Host header } // resolve returns the upstream URL for the given request path. A configured @@ -544,9 +545,12 @@ type siteRouter struct { func newSiteRouter(routes []config.SiteRoute) siteRouter { m := make(map[string]*siteEntry) + // One throttle for every gated route: a visitor guessing at one route must + // not get a fresh budget by moving to another. + throttle := newAuthThrottle() for _, r := range routes { u, _ := url.Parse(r.Upstream) // validated in config.Validate - entry := &siteEntry{upstream: u} + entry := &siteEntry{upstream: u, basicAuth: newSiteBasicAuth(r.Auth, throttle)} for path, upstream := range r.Routes { pu, _ := url.Parse(upstream) // validated in config.Validate entry.paths = append(entry.paths, pathRoute{ @@ -620,7 +624,9 @@ func proxyErrorHandler(upstream *url.URL) func(http.ResponseWriter, *http.Reques return func(w http.ResponseWriter, req *http.Request, err error) { slog.Warn("proxy upstream error", "error", err, - "upstream", upstream.String(), + // Redacted: the upstream URL can carry Basic credentials in its + // userinfo, which must not reach the log. + "upstream", upstream.Redacted(), "method", req.Method, "host", req.Host, "path", req.URL.Path, @@ -654,6 +660,14 @@ func reverseProxyConn(conn net.Conn, entry *siteEntry, hijacked *atomic.Bool) er } stripResidualForwardingHeaders(pr.Out.Header) setEdgeProxyHeaders(pr.Out, pr.In.Host, pr.In.RemoteAddr) + if upstream.User != nil { + // net/http applies URL userinfo as Basic credentials only in + // http.Client; ReverseProxy round-trips the request through a + // Transport, which drops them. Configured upstream credentials + // are explicit, so they override any inbound Authorization. + password, _ := upstream.User.Password() + pr.Out.Header.Set("Authorization", basicAuthHeader(upstream.User.Username(), password)) + } }, ErrorHandler: proxyErrorHandler(upstream), } @@ -682,6 +696,11 @@ func reverseProxyConn(conn net.Conn, entry *siteEntry, hijacked *atomic.Bool) er }() handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // The gate covers the whole route, including its path overrides and + // WebSocket upgrades: it runs before request dispatch to any upstream. + if entry.basicAuth != nil && !entry.basicAuth.guard(w, req) { + return + } proxyFor(entry.resolve(req.URL.Path)).ServeHTTP(w, req) }) diff --git a/cmd/sowerd/main_test.go b/cmd/sowerd/main_test.go index e45b788..f35936d 100644 --- a/cmd/sowerd/main_test.go +++ b/cmd/sowerd/main_test.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "context" "crypto" @@ -15,6 +16,7 @@ import ( "encoding/base64" "encoding/pem" "errors" + "io" "log/slog" "math/big" "net" @@ -792,6 +794,207 @@ func websocketAcceptKey(key string) string { return base64.StdEncoding.EncodeToString(h.Sum(nil)) } +// proxyRoundTrip drives one HTTP request through reverseProxyConn over an +// in-memory connection and returns the parsed response. Reverse proxies keep +// the connection alive, so the response is parsed instead of read as raw +// bytes: a 401 body may arrive after the headers. +func proxyRoundTrip(t *testing.T, entry *siteEntry, raw string) *http.Response { + t.Helper() + + serverConn, clientConn := net.Pipe() + errCh := make(chan error, 1) + go func() { + errCh <- reverseProxyConn(serverConn, entry, &atomic.Bool{}) + }() + + go func() { + _, _ = clientConn.Write([]byte(raw)) + }() + + _ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + resp, err := http.ReadResponse(bufio.NewReader(clientConn), nil) + if err != nil { + t.Fatalf("read response: %v", err) + } + t.Cleanup(func() { + _ = resp.Body.Close() + _ = clientConn.Close() + select { + case <-errCh: + case <-time.After(2 * time.Second): + } + }) + return resp +} + +// startCapturingUpstream starts an HTTP server that records the Authorization +// header of every request and answers with body. It uses a bare http.Server +// rather than httptest.NewServer because httptest.Server.Close waits for +// in-flight handlers: a failed assertion that leaves the capture channel +// unread would wedge the test instead of failing it. +func startCapturingUpstream(t *testing.T, body string) (*url.URL, chan string) { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen upstream: %v", err) + } + authCh := make(chan string, 8) + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authCh <- r.Header.Get("Authorization") + _, _ = w.Write([]byte(body)) + })} + go func() { _ = srv.Serve(ln) }() + t.Cleanup(func() { _ = srv.Close() }) + + return &url.URL{Scheme: "http", Host: ln.Addr().String()}, authCh +} + +func TestReverseProxyConnBasicAuthGate(t *testing.T) { + t.Parallel() + + upstream, upstreamAuth := startCapturingUpstream(t, "gated-upstream") + + router := newSiteRouter([]config.SiteRoute{{ + Domains: []string{"gated.example.com"}, + Upstream: upstream.String(), + Auth: config.SiteRouteAuth{Username: "alice", Password: "s3cret"}, + }}) + entry := router.lookup("gated.example.com") + if entry == nil { + t.Fatal("gated route not found") + } + + t.Run("missing credentials", func(t *testing.T) { + resp := proxyRoundTrip(t, entry, "GET / HTTP/1.1\r\nHost: gated.example.com\r\n\r\n") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized) + } + if got := resp.Header.Get("WWW-Authenticate"); !strings.HasPrefix(got, "Basic ") { + t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", got) + } + select { + case got := <-upstreamAuth: + t.Fatalf("upstream was reached with Authorization %q", got) + default: + } + }) + + t.Run("wrong credentials", func(t *testing.T) { + resp := proxyRoundTrip(t, entry, + "GET / HTTP/1.1\r\nHost: gated.example.com\r\n"+ + "Authorization: "+basicAuthHeader("alice", "wrong")+"\r\n\r\n") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized) + } + select { + case got := <-upstreamAuth: + t.Fatalf("upstream was reached with Authorization %q", got) + default: + } + }) + + t.Run("valid credentials are consumed by the edge", func(t *testing.T) { + resp := proxyRoundTrip(t, entry, + "GET / HTTP/1.1\r\nHost: gated.example.com\r\n"+ + "Authorization: "+basicAuthHeader("alice", "s3cret")+"\r\n\r\n") + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + body, _ := io.ReadAll(resp.Body) + if string(body) != "gated-upstream" { + t.Fatalf("body = %q, want gated-upstream", body) + } + select { + case got := <-upstreamAuth: + if got != "" { + t.Fatalf("upstream saw Authorization %q, want the edge credential stripped", got) + } + case <-time.After(2 * time.Second): + t.Fatal("upstream did not receive the gated request") + } + }) +} + +func TestReverseProxyConnUngatedRouteKeepsAuthorization(t *testing.T) { + t.Parallel() + + upstream, upstreamAuth := startCapturingUpstream(t, "open-upstream") + + router := newSiteRouter([]config.SiteRoute{{ + Domains: []string{"open.example.com"}, + Upstream: upstream.String(), + }}) + entry := router.lookup("open.example.com") + if entry == nil { + t.Fatal("ungated route not found") + } + + resp := proxyRoundTrip(t, entry, + "GET / HTTP/1.1\r\nHost: open.example.com\r\nAuthorization: Bearer upstream-token\r\n\r\n") + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + select { + case got := <-upstreamAuth: + // A route without a gate must stay byte-compatible with the previous + // behavior: the client's own credentials belong to the upstream. + if got != "Bearer upstream-token" { + t.Fatalf("upstream saw Authorization %q, want the client header untouched", got) + } + case <-time.After(2 * time.Second): + t.Fatal("upstream did not receive the request") + } +} + +func TestReverseProxyConnUpstreamUserinfoSendsBasicAuth(t *testing.T) { + t.Parallel() + + upstream, upstreamAuth := startCapturingUpstream(t, "upstream-authed") + upstream.User = url.UserPassword("upuser", "uppass") + + resp := proxyRoundTrip(t, &siteEntry{upstream: upstream}, + "GET / HTTP/1.1\r\nHost: a.example.com\r\n\r\n") + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + // httputil.ReverseProxy sends the request through a Transport, which drops + // URL userinfo: net/http only turns it into Basic credentials in + // http.Client. The proxy must set the header itself. + want := "Basic " + base64.StdEncoding.EncodeToString([]byte("upuser:uppass")) + select { + case got := <-upstreamAuth: + if got != want { + t.Fatalf("upstream saw Authorization %q, want %q", got, want) + } + case <-time.After(2 * time.Second): + t.Fatal("upstream did not receive the request") + } +} + +func TestProxyErrorHandlerRedactsUpstreamCredentials(t *testing.T) { + var logged strings.Builder + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil))) + t.Cleanup(func() { slog.SetDefault(previous) }) + + upstream, _ := url.Parse("http://alice:s3cret@127.0.0.1:9081") + req := httptest.NewRequest(http.MethodGet, "https://gateway.example.com/", nil) + rec := httptest.NewRecorder() + + proxyErrorHandler(upstream)(rec, req, errors.New("dial tcp 127.0.0.1:9081: connect: connection refused")) + + out := logged.String() + if strings.Contains(out, "s3cret") { + t.Fatalf("log leaks the upstream password: %q", out) + } + if !strings.Contains(out, "alice:xxxxx@127.0.0.1:9081") { + t.Fatalf("log does not identify the upstream: %q", out) + } +} + func TestHandleConnFallsBackToFakeSiteWhenSNIMisses(t *testing.T) { gotCh := make(chan *http.Request, 1) upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1052,6 +1255,17 @@ func TestCouldBeHTTPRequestLine(t *testing.T) { } } +// The fake-site decoy answers unknown SNI and must stay open: it is what a +// prober sees, and a gate there would protect the wrong thing. A real site +// that needs a visitor gate belongs in site_routes. +func TestFakeSiteEntryIsNeverGated(t *testing.T) { + t.Parallel() + + if entry := newFakeSiteEntry("127.0.0.1:80"); entry.basicAuth != nil { + t.Fatal("fake-site fallback must not be gated") + } +} + func TestNewFakeSiteEntry(t *testing.T) { e := newFakeSiteEntry("127.0.0.1:9080") if e.upstream.String() != "http://127.0.0.1:9080" { diff --git a/cmd/sowerd/siteauth.go b/cmd/sowerd/siteauth.go new file mode 100644 index 0000000..e2d604d --- /dev/null +++ b/cmd/sowerd/siteauth.go @@ -0,0 +1,153 @@ +package main + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "log/slog" + "net" + "net/http" + "sync" + "time" + + "github.com/sower-proxy/sower/config" +) + +// Visitor gate tuning. The throttle mirrors internal/admin's login throttle: +// failed attempts from one source IP accumulate (a passing request clears +// them) and lock that source out for a backoff window, and the failure map is +// bounded so source-IP rotation cannot grow it without bound. Without it a +// dictionary attack finishes in seconds, which would make the gate decorative. +const ( + authMaxFails = 5 + authLockDuration = 15 * time.Minute + authMaxEntries = 4096 +) + +// siteBasicAuth gates a site route behind HTTP Basic credentials. It exists +// for upstreams that cannot authenticate their own visitors: the route +// domains are published in Certificate Transparency logs and the edge answers +// any SNI, so the upstream is reachable by anyone who reads those logs. +type siteBasicAuth struct { + username string + password string + throttle *authThrottle +} + +// newSiteBasicAuth builds the gate for one route, or nil when the route has no +// credentials configured. +func newSiteBasicAuth(conf config.SiteRouteAuth, throttle *authThrottle) *siteBasicAuth { + if conf.Username == "" && conf.Password == "" { + return nil + } + return &siteBasicAuth{username: conf.Username, password: conf.Password, throttle: throttle} +} + +// guard reports whether the request passes the gate, writing the rejection +// response itself when it does not. A passing request has its Authorization +// header removed: the edge credential authenticates the visitor to the edge, +// not to the upstream, so forwarding it would leak the edge password into +// upstream logs and would collide with an upstream realm that carries the +// same name. +func (a *siteBasicAuth) guard(w http.ResponseWriter, r *http.Request) bool { + ip := peerIP(r.RemoteAddr) + if !a.throttle.allow(ip) { + slog.Debug("site route auth locked", "host", r.Host, "remote", ip) + http.Error(w, "too many attempts", http.StatusTooManyRequests) + return false + } + + user, pass, ok := r.BasicAuth() + userOK := secureEqual(user, a.username) + passOK := secureEqual(pass, a.password) + if !ok || !userOK || !passOK { + a.throttle.fail(ip) + slog.Debug("site route auth rejected", "host", r.Host, "remote", ip) + w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return false + } + + a.throttle.success(ip) + r.Header.Del("Authorization") + return true +} + +// secureEqual compares two secrets in constant time regardless of length. +func secureEqual(a, b string) bool { + ha := sha256.Sum256([]byte(a)) + hb := sha256.Sum256([]byte(b)) + return subtle.ConstantTimeCompare(ha[:], hb[:]) == 1 +} + +// peerIP extracts the host part of a remote address for throttling. The TLS +// edge sees the direct peer, so the address is not attacker-controlled. +func peerIP(remoteAddr string) string { + if host, _, err := net.SplitHostPort(remoteAddr); err == nil { + return host + } + return remoteAddr +} + +// basicAuthHeader renders the Authorization value for the upstream +// credentials configured as URL userinfo on an upstream. +func basicAuthHeader(username, password string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) +} + +type authFail struct { + count int + until time.Time +} + +// authThrottle bounds visitor password guessing per source IP. A lockout only +// ever affects the IP that caused it: there is no shared account state, and +// TCP requires a completed handshake, so a third party cannot lock anyone +// else out by spoofing their address. +type authThrottle struct { + mu sync.Mutex + fails map[string]authFail +} + +func newAuthThrottle() *authThrottle { + return &authThrottle{fails: make(map[string]authFail)} +} + +func (t *authThrottle) allow(ip string) bool { + t.mu.Lock() + defer t.mu.Unlock() + f, ok := t.fails[ip] + return !ok || time.Now().After(f.until) +} + +func (t *authThrottle) fail(ip string) { + t.mu.Lock() + defer t.mu.Unlock() + now := time.Now() + // Bound the map: when full, drop entries whose lock window already + // expired. If it is still full, stop tracking the new IP (it cannot be + // locked, but the map stays bounded). + if len(t.fails) >= authMaxEntries { + for k, f := range t.fails { + if now.After(f.until) { + delete(t.fails, k) + } + } + } + if len(t.fails) >= authMaxEntries { + return + } + f := t.fails[ip] + f.count++ + if f.count >= authMaxFails { + f.until = now.Add(authLockDuration) + f.count = 0 + } + t.fails[ip] = f +} + +func (t *authThrottle) success(ip string) { + t.mu.Lock() + defer t.mu.Unlock() + delete(t.fails, ip) +} diff --git a/cmd/sowerd/siteauth_test.go b/cmd/sowerd/siteauth_test.go new file mode 100644 index 0000000..16aff6c --- /dev/null +++ b/cmd/sowerd/siteauth_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/sower-proxy/sower/config" +) + +func newGatedRequest(ip, user, password string) *http.Request { + req := httptest.NewRequest(http.MethodGet, "https://gated.example.com/", nil) + req.RemoteAddr = ip + if user != "" || password != "" { + req.SetBasicAuth(user, password) + } + return req +} + +func TestSiteBasicAuthGuard(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + user string + password string + wantPass bool + wantCode int + }{ + {name: "valid credentials", user: "alice", password: "s3cret", wantPass: true}, + {name: "wrong password", user: "alice", password: "wrong", wantCode: http.StatusUnauthorized}, + {name: "wrong user", user: "bob", password: "s3cret", wantCode: http.StatusUnauthorized}, + {name: "missing credentials", wantCode: http.StatusUnauthorized}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gate := newSiteBasicAuth(config.SiteRouteAuth{Username: "alice", Password: "s3cret"}, newAuthThrottle()) + if gate == nil { + t.Fatal("configured credentials must build a gate") + } + req := newGatedRequest("203.0.113.9:1234", tt.user, tt.password) + rec := httptest.NewRecorder() + + if got := gate.guard(rec, req); got != tt.wantPass { + t.Fatalf("guard = %v, want %v (status %d)", got, tt.wantPass, rec.Code) + } + if tt.wantPass { + if req.Header.Get("Authorization") != "" { + t.Fatalf("Authorization survived a passed gate: %q", req.Header.Get("Authorization")) + } + return + } + if rec.Code != tt.wantCode { + t.Fatalf("status = %d, want %d", rec.Code, tt.wantCode) + } + if got := rec.Header().Get("WWW-Authenticate"); got != `Basic realm="restricted"` { + t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", got) + } + }) + } +} + +func TestSiteBasicAuthDisabledWithoutCredentials(t *testing.T) { + t.Parallel() + + if gate := newSiteBasicAuth(config.SiteRouteAuth{}, newAuthThrottle()); gate != nil { + t.Fatal("empty credentials must leave the route ungated") + } +} + +func TestSiteBasicAuthGuardLocksFailingSourceIP(t *testing.T) { + t.Parallel() + + gate := newSiteBasicAuth(config.SiteRouteAuth{Username: "alice", Password: "s3cret"}, newAuthThrottle()) + + const lockedIP = "203.0.113.9:1234" + for i := 0; i < authMaxFails; i++ { + rec := httptest.NewRecorder() + req := newGatedRequest(lockedIP, "alice", "wrong") + if gate.guard(rec, req) { + t.Fatalf("attempt %d passed the gate", i) + } + if rec.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d status = %d, want %d", i, rec.Code, http.StatusUnauthorized) + } + } + + // The lockout rejects even the correct password: that is the point. + rec := httptest.NewRecorder() + if gate.guard(rec, newGatedRequest(lockedIP, "alice", "s3cret")) { + t.Fatal("locked source passed the gate") + } + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusTooManyRequests) + } + + // A lockout is scoped to the source that earned it. + other := httptest.NewRecorder() + if !gate.guard(other, newGatedRequest("198.51.100.7:5678", "alice", "s3cret")) { + t.Fatalf("unrelated source was rejected with status %d", other.Code) + } +} diff --git a/config/sowerd.go b/config/sowerd.go index 902799e..5420023 100644 --- a/config/sowerd.go +++ b/config/sowerd.go @@ -26,17 +26,30 @@ fake_site = "/var/www" # Fallback fake site directory or address (def # When a TLS connection does not match sower transport, # the SNI is looked up here. Unmatched connections fall through to fake_site. # Custom certificate mode requires the certificate SANs to cover all routed domains. +# Route domains become public once autocert issues their certificates +# (Certificate Transparency), so an upstream that cannot authenticate its own +# visitors needs the optional auth gate below. # # [[site_routes]] # domains = ["a.example.com", "b.example.com"] # upstream = "http://127.0.0.1:8080" # +# [site_routes.auth] +# username = "admin" # Both fields are required to enable the gate; +# password = "change_me" # an empty block leaves the route open. +# The gate also covers WebSocket upgrades, but browsers cannot set Authorization +# on a WS handshake and reconnecting clients feed the per-IP lockout: verify +# before gating one (see docs/security.md). +# # [site_routes.routes] # "/ws" = "http://127.0.0.1:8082" # # [[site_routes]] # domains = ["c.example.com"] # upstream = "https://backend.example.com" +# +# URL userinfo credentials are sent to the upstream as HTTP Basic auth: +# upstream = "http://user:pass@127.0.0.1:8080" # SSL/TLS certificate configuration [cert] @@ -56,6 +69,21 @@ type SiteRoute struct { Domains []string `usage:"exact domain names for this route"` Upstream string `usage:"upstream URL (http:// or https://)"` 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 + // route domains are public (Certificate Transparency), so an upstream that + // cannot authenticate its own visitors is exposed; the edge is the only + // place to insert a gate because sowerd owns 80/443 on the host. + Auth SiteRouteAuth `usage:"optional HTTP Basic visitor gate (leave empty to disable)"` +} + +// SiteRouteAuth gates a site route behind HTTP Basic credentials. Both +// fields must be set to enable the gate; an empty block leaves the route +// open. The mechanism is deliberately HTTP Basic only: it needs no session +// state and works for non-browser clients, at the cost of a per-origin +// browser credential cache and no logout. +type SiteRouteAuth struct { + Username string `usage:"visitor user name for the route's HTTP Basic gate"` + Password string `usage:"visitor password for the route's HTTP Basic gate"` } // SowerdConfig represents the configuration for sowerd daemon @@ -155,6 +183,10 @@ func validateSiteRoutes(routes []SiteRoute) error { return fmt.Errorf("site_routes[%d]: %w", i, err) } + if err := validateSiteRouteAuth(i, r.Auth); err != nil { + return err + } + for path, upstream := range r.Routes { if !strings.HasPrefix(path, "/") { return fmt.Errorf("site_routes[%d].routes: path %q must start with /", i, path) @@ -204,6 +236,23 @@ func validateUpstreamURL(raw string) error { return nil } +// validateSiteRouteAuth rejects a half-configured visitor gate: a route +// whose credentials are only partly set would otherwise run either open or +// with a credential nobody can present. An empty block means "no gate". +func validateSiteRouteAuth(i int, auth SiteRouteAuth) error { + if auth.Username == "" && auth.Password == "" { + return nil + } + if auth.Username == "" || auth.Password == "" { + return fmt.Errorf("site_routes[%d].auth: username and password must be set together", i) + } + if strings.ContainsRune(auth.Username, ':') { + // RFC 7617 splits the decoded credential at the first colon. + return fmt.Errorf("site_routes[%d].auth: username must not contain ':'", i) + } + return nil +} + func validExactDomain(domain string) bool { if domain == "" || len(domain) > 253 || strings.HasSuffix(domain, ".") { return false diff --git a/config/sowerd.toml b/config/sowerd.toml index daa832d..9076d8c 100644 --- a/config/sowerd.toml +++ b/config/sowerd.toml @@ -13,17 +13,30 @@ fake_site = "/var/www" # Fallback fake site directory or address (def # When a TLS connection does not match sower transport, # the SNI is looked up here. Unmatched connections fall through to fake_site. # Custom certificate mode requires the certificate SANs to cover all routed domains. +# Route domains become public once autocert issues their certificates +# (Certificate Transparency), so an upstream that cannot authenticate its own +# visitors needs the optional auth gate below. # # [[site_routes]] # domains = ["a.example.com", "b.example.com"] # upstream = "http://127.0.0.1:8080" # +# [site_routes.auth] +# username = "admin" # Both fields are required to enable the gate; +# password = "change_me" # an empty block leaves the route open. +# The gate also covers WebSocket upgrades, but browsers cannot set Authorization +# on a WS handshake and reconnecting clients feed the per-IP lockout: verify +# before gating one (see docs/security.md). +# # [site_routes.routes] # "/ws" = "http://127.0.0.1:8082" # # [[site_routes]] # domains = ["c.example.com"] # upstream = "https://backend.example.com" +# +# URL userinfo credentials are sent to the upstream as HTTP Basic auth: +# upstream = "http://user:pass@127.0.0.1:8080" # SSL/TLS certificate configuration [cert] diff --git a/config/sowerd_test.go b/config/sowerd_test.go index 3d8a860..d2c3450 100644 --- a/config/sowerd_test.go +++ b/config/sowerd_test.go @@ -45,6 +45,57 @@ fake_site = "127.0.0.1:18080" } } +func TestSowerdConfigSiteRouteBasicAuthFromTOML(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "sowerd.toml") + if err := os.WriteFile(path, []byte(` +password = "secret" +fake_site = "127.0.0.1:18080" + +[[site_routes]] +domains = ["nas.example.com"] +upstream = "http://127.0.0.1:9000" + +[site_routes.auth] +username = "alice" +password = "s3cret" + +[site_routes.routes] +"/ws" = "http://127.0.0.1:9002" + +[[site_routes]] +domains = ["open.example.com"] +upstream = "http://127.0.0.1:9001" +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + var cfg SowerdConfig + if err := loadConfigFileForTest(path, &cfg); err != nil { + t.Fatalf("load config: %v", err) + } + + // The gate lives in a nested table of an array-of-tables element; a + // decoder that silently drops it would leave the route open, so the + // parsed values (not just the load result) are the assertion. + if len(cfg.SiteRoutes) != 2 { + t.Fatalf("site routes = %d, want 2", len(cfg.SiteRoutes)) + } + if got := cfg.SiteRoutes[0].Auth; got.Username != "alice" || got.Password != "s3cret" { + t.Fatalf("basic auth = %#v, want alice/s3cret", got) + } + if cfg.SiteRoutes[0].Routes["/ws"] != "http://127.0.0.1:9002" { + t.Fatalf("path routes = %#v, want the /ws override alongside auth", cfg.SiteRoutes[0].Routes) + } + if got := cfg.SiteRoutes[1].Auth; got.Username != "" || got.Password != "" { + t.Fatalf("ungated route basic auth = %#v, want empty", got) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("validate config: %v", err) + } +} + func TestSowerdConfigValidate(t *testing.T) { t.Parallel() @@ -144,6 +195,65 @@ func TestSowerdConfigValidate(t *testing.T) { }, wantErr: true, }, + { + name: "basic auth visitor gate", + cfg: SowerdConfig{ + Password: "secret", + FakeSite: "127.0.0.1:8080", + SiteRoutes: []SiteRoute{ + { + Domains: []string{"nas.example.com"}, + Upstream: "http://127.0.0.1:9000", + Auth: SiteRouteAuth{Username: "alice", Password: "s3cret"}, + }, + }, + }, + }, + { + name: "basic auth username without password", + cfg: SowerdConfig{ + Password: "secret", + FakeSite: "127.0.0.1:8080", + SiteRoutes: []SiteRoute{ + { + Domains: []string{"nas.example.com"}, + Upstream: "http://127.0.0.1:9000", + Auth: SiteRouteAuth{Username: "alice"}, + }, + }, + }, + wantErr: true, + }, + { + name: "basic auth password without username", + cfg: SowerdConfig{ + Password: "secret", + FakeSite: "127.0.0.1:8080", + SiteRoutes: []SiteRoute{ + { + Domains: []string{"nas.example.com"}, + Upstream: "http://127.0.0.1:9000", + Auth: SiteRouteAuth{Password: "s3cret"}, + }, + }, + }, + wantErr: true, + }, + { + name: "basic auth username with colon", + cfg: SowerdConfig{ + Password: "secret", + FakeSite: "127.0.0.1:8080", + SiteRoutes: []SiteRoute{ + { + Domains: []string{"nas.example.com"}, + Upstream: "http://127.0.0.1:9000", + Auth: SiteRouteAuth{Username: "ali:ce", Password: "s3cret"}, + }, + }, + }, + wantErr: true, + }, { name: "path route with query", cfg: SowerdConfig{ diff --git a/docs/security.md b/docs/security.md index f3d8de3..6d707b8 100644 --- a/docs/security.md +++ b/docs/security.md @@ -49,10 +49,73 @@ Dispatch order for non-transport traffic (`cmd/sowerd/main.go:handleConn`): 2. Otherwise, a valid HTTP/1.1 request line → reverse proxy to `fake_site`. 3. Otherwise → raw TCP relay to `fake_site`. +A matched route is always fed to the reverse proxy, HTTP request line or not: +`site_routes` serves HTTP/1.1 only. Non-HTTP traffic on a routed domain fails +HTTP parsing rather than being relayed, which is also why an HTTP-layer visitor +gate (below) covers the route's entire surface. + Transport auth failures fall back to the same website paths (never RST, never hang), so a prober without the password observes ordinary site behavior on every probe. +## Site Route Visitor Gate + +`site_routes` upstreams are public: autocert issues certificates for the routed +domains on demand, and issued certificates are published in Certificate +Transparency logs. The optional per-route `site_routes.auth` block gates the +route with HTTP Basic credentials, for upstreams that cannot authenticate their +own visitors — the edge is the only insertion point, because sowerd owns 80/443 +on the host. + +Rules: + +- Opt-in and default off. A route without `auth` (or with both fields empty) + behaves exactly as before, including passing the client's `Authorization` + header through to the upstream. +- The gate runs in the reverse-proxy handler (`reverseProxyConn`), so it covers + the route's whole surface: path-prefix overrides and WebSocket upgrades + included. The sower transport path is dispatched before SNI routing and + carries its own frame-checksum authentication; it is never gated. +- Credentials are compared in constant time (`sha256` + `subtle`, so length + does not leak). +- A passing request has its `Authorization` header removed. The edge + credential authenticates the visitor to the edge, not to the upstream: + forwarding it would copy the edge password into the upstream and its logs, + and would collide with an upstream realm that carries the same name. +- Failures are throttled per direct peer address: `authMaxFails` failed + attempts from one source accumulate (there is no decay window; only a request + that passes the gate clears the count) and lock that source out for + `authLockDuration`. The failure map is bounded (`authMaxEntries`) so + source-IP rotation cannot grow it without bound. A lockout only ever affects + the IP that caused it; the counter is shared by all gated routes so a visitor + cannot get a fresh budget by moving between them. +- Upstream credentials are a separate mechanism: URL userinfo + (`upstream = "http://user:pass@host"`) is sent to the upstream as Basic + credentials. `net/http` turns userinfo into that header only in + `http.Client`; `httputil.ReverseProxy` round-trips through a `Transport` and + drops it, so sowerd sets the header explicitly. Upstream passwords are + redacted in error logs (`url.URL.Redacted`). +- ACME is unaffected: HTTP-01 runs on `:80` and TLS-ALPN-01 completes during + the handshake, both before any request reaches the reverse proxy. +- What it is not: a substitute for the upstream's own authentication, an + account system (one credential per route, no session, no logout), or a + second factor. A weak password stays weak against a distributed or + IPv6-rotating attacker. Cross-origin browser calls that need a CORS + preflight also stay broken: a preflight carries no credentials, so the edge + rejects it before the cross-origin request is even attempted. +- Browser WebSocket clients are the open edge case. The gate does run on the + `Upgrade` request, but script cannot set `Authorization` on a WebSocket + handshake: a browser can only pass it by replaying the origin's cached Basic + credential, and whether it does so is browser-specific and unverified here. + The gate is per-route, so a `/ws` path cannot be exempted while the rest of + the route stays gated. A reconnecting client makes this self-inflicted: every + failed handshake feeds the shared per-IP counter, so an auto-reconnecting + browser client (socket.io and friends) can lock its own source IP out of the + whole route — ordinary page loads included — for `authLockDuration`, and + again after each expiry. Verify in the target browser before putting a + browser WebSocket route behind the gate, or reach that upstream through the + sower transport instead. + ## Unknown-SNI Certificate Fallback **Rule: the TLS handshake must complete for arbitrary SNI.** A handshake @@ -125,6 +188,15 @@ Decisions recorded here so they are not re-litigated without new evidence: ACME challenges and 308-redirects public traffic to HTTPS; the `fake_site` directory is served there on loopback only (`isLocalRemoteAddr`). Routing :80 by domain would hand out a cleartext bypass of the TLS edge. +- **Session-cookie login for site routes** — declined in favor of HTTP Basic: + a login page plus server-side sessions adds edge state and login-page + surface, and non-browser clients (curl, media players, mobile apps) speak + Basic rather than HTML forms. Basic costs one browser credential prompt per + origin and keeps the gate stateless. +- **Gating the `fake_site` fallback** — the fake site is the decoy that answers + 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`. ## Residual Accepted Signals