mirror of
https://github.com/wweir/sower.git
synced 2026-10-20 08:16:35 +00:00
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.
107 lines
3.1 KiB
Go
107 lines
3.1 KiB
Go
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)
|
|
}
|
|
}
|