mirror of
https://github.com/wweir/sower.git
synced 2026-10-21 05:51:38 +00:00
feat(sowerd): add an optional HTTP Basic gate for site routes
Site-route domains become public once autocert issues for them
(Certificate Transparency), and the edge answers any SNI. An upstream that
cannot authenticate its own visitors is therefore exposed, and sowerd owns
80/443 on the host, so nothing can be placed in front of it.
Add an opt-in per-route visitor gate:
- config: site_routes[].auth {username, password}; both fields required,
empty means ungated, ':' rejected in the username (RFC 7617 splits there).
- runtime: the gate runs in the reverse-proxy handler, so it covers path
overrides and WebSocket upgrades too; the sower transport path is
dispatched earlier and stays untouched. Credentials are compared in
constant time, failed attempts accumulate per source IP and lock it out for
15 minutes (bounded map, mirroring the admin login throttle), and a passing
request has its Authorization header removed so the edge credential never
reaches the upstream. The fake-site decoy is deliberately never gated.
- fix: upstream URL userinfo was silently dropped. ReverseProxy sends
through a Transport, and only net/http.Client turns userinfo into Basic
credentials, so sowerd now sets that header itself; the upstream password
is redacted in proxy error logs (url.URL.Redacted).
- docs: security.md records the mechanism and its limits (not a substitute
for upstream auth, no session/logout, CORS preflight blind spot, and the
browser WebSocket handshake: script cannot set Authorization, cached-
credential replay is browser-specific and unverified, and a reconnecting
client's failed handshakes feed the same per-IP counter, so it can lock its
own source IP out of the whole route) plus the rejected alternatives
(session login, gating the fake site).
Gate: gofmt, go vet ./..., go test ./... green; install_e2e_test PASS.
This commit is contained in:
+2
-1
@@ -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.
|
||||
|
||||
@@ -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 凭证发给上游;错误日志中该密码会被脱敏。
|
||||
|
||||
启动方式有两种。
|
||||
|
||||
直接运行:
|
||||
|
||||
+23
-4
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user