Files

288 lines
11 KiB
Go

package authz
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/netacl"
"github.com/lixenwraith/auth"
"github.com/lixenwraith/log"
)
// forwarding is the proxy's half of every request: the client and https
type forwarding struct{ next http.RoundTripper }
func (f forwarding) RoundTrip(r *http.Request) (*http.Response, error) {
r = r.Clone(r.Context())
r.Header.Set("X-Forwarded-For", "203.0.113.7")
r.Header.Set("X-Forwarded-Proto", "https")
return f.next.RoundTrip(r)
}
// proxyListener is a plaintext http sink policy behind a proxy on loopback
func (f *fixture) proxyListener(t *testing.T) (*Policy, *httptest.Server) {
t.Helper()
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1"}}, nil, config.Listener, HTTP)
mux := http.NewServeMux()
mux.HandleFunc("POST "+chain.AuthPath, func(w http.ResponseWriter, r *http.Request) { l.ServeAuth(w, r) })
mux.HandleFunc("GET /protected", func(w http.ResponseWriter, r *http.Request) {
if _, status, err := l.AuthorizeRequest(r); err != nil {
Refuse(w, status)
}
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return l, srv
}
// Behind trusted proxies only they may connect, only requests they received
// over https pass, and the client is the rightmost forwarded hop that is not
// a proxy (hops to its left are the client's own claims), or the leftmost
// when every hop is inside a proxy range.
func TestProxyModeTrustsOnlyItsProxies(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1", "10.0.0.0/8"}}, nil, config.Listener, HTTP)
for _, tc := range []struct{ name, peer, xff, proto, want string }{
{"untrusted peer", "192.0.2.1:5000", "203.0.113.7", "https", ""},
{"no proto", "127.0.0.1:5000", "203.0.113.7", "", ""},
{"plaintext site", "127.0.0.1:5000", "203.0.113.7", "http", ""},
{"one plaintext hop", "127.0.0.1:5000", "203.0.113.7", "https, http", ""},
{"no client", "127.0.0.1:5000", "", "https", ""},
{"malformed client", "127.0.0.1:5000", "not-an-address", "https", ""},
{"spoofed hop", "127.0.0.1:5000", "198.51.100.1, 203.0.113.7", "https", "203.0.113.7"},
{"proxy chain", "127.0.0.1:5000", "203.0.113.7, 10.1.2.3", "https", "203.0.113.7"},
{"proxy hop with a port", "127.0.0.1:5000", "203.0.113.7, 10.1.2.3:443", "https", "203.0.113.7"},
{"bracketed IPv6 client", "127.0.0.1:5000", "[2001:db8::1]", "https", "2001:db8::1"},
{"client inside a proxy range", "127.0.0.1:5000", "10.9.9.9, 10.1.2.3", "https", "10.9.9.9"},
} {
r := httptest.NewRequest(http.MethodGet, "/status", nil)
r.RemoteAddr = tc.peer
if tc.xff != "" {
r.Header.Set("X-Forwarded-For", tc.xff)
}
if tc.proto != "" {
r.Header.Set("X-Forwarded-Proto", tc.proto)
}
got, err := l.ClientAddr(r)
switch {
case tc.want == "" && !errors.Is(err, ErrRefused):
t.Errorf("%s: client %q, %v; want a refusal", tc.name, got, err)
case tc.want != "" && (err != nil || got != tc.want):
t.Errorf("%s: client %q, %v; want %s", tc.name, got, err, tc.want)
}
}
}
// In proxy mode a forwarded client passes the acl rules too, as the proxy
// passed them as the peer; a refusal counts in acl_denied
func TestForwardedClientIsRefusedByTheRules(t *testing.T) {
f := newFixture(t)
acl, err := netacl.New(&config.ACLOptions{Allow: []string{"127.0.0.1", "203.0.113.0/24"}, Deny: []string{"203.0.113.66"}},
"127.0.0.1", netacl.HTTPProxied, log.NewLogger(), "test", "acl")
if err != nil {
t.Fatal(err)
}
l, err := New(&config.AuthOptions{Type: MethodSCRAM, CredentialsFile: f.creds, TrustedProxies: []string{"127.0.0.1"}},
nil, acl, config.Listener, HTTP)
if err != nil {
t.Fatal(err)
}
for client, admitted := range map[string]bool{"203.0.113.7": true, "203.0.113.66": false, "198.51.100.1": false} {
r := httptest.NewRequest(http.MethodGet, "/status", nil)
r.RemoteAddr = "127.0.0.1:5000"
r.Header.Set("X-Forwarded-For", client)
r.Header.Set("X-Forwarded-Proto", "https")
if _, err := l.ClientAddr(r); (err == nil) != admitted {
t.Errorf("forwarded %s: %v", client, err)
}
}
if denied, _ := acl.Refused(); denied != 2 {
t.Fatalf("acl_denied %d, want 2", denied)
}
}
// A browser logs in unbound and gets its session only as a cookie that
// scopes itself to the mount; the cookie opens the endpoints, beside the
// site's own Basic auth too, until logout revokes it. Outside proxy mode
// cookie sessions and logout are refused.
func TestBrowserSessionBehindProxy(t *testing.T) {
f := newFixture(t)
l, srv := f.proxyListener(t)
client := &http.Client{Transport: forwarding{http.DefaultTransport}}
post := func(url string, body any, cookie *http.Cookie) (*http.Response, authStep) {
data, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPost, url+chain.AuthPath, bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
if cookie != nil {
req.AddCookie(cookie)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var step authStep
json.NewDecoder(resp.Body).Decode(&step)
return resp, step
}
login := func(url string) (*http.Response, authStep) {
c := auth.NewScramClient("edge-01", "edge-01-secret", auth.WithMinArgonCost(1, 64))
first, _ := c.StartAuthentication()
scram, _ := json.Marshal(first)
_, step := post(url, chain.Hello{LogWisp: chain.ProtocolVersion, Scram: scram}, nil)
if step.Challenge == nil {
t.Fatalf("no challenge: %q", step.Error)
}
proof, err := c.ProcessServerFirstMessage(*step.Challenge)
if err != nil {
t.Fatal(err)
}
return post(url, authStep{Proof: &proof, Session: "cookie"}, nil)
}
open := func(cookie *http.Cookie, basic bool) int {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/protected", nil)
req.AddCookie(cookie)
if basic {
req.SetBasicAuth("site-user", "site-password")
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp.StatusCode
}
resp, step := login(srv.URL)
cookies := resp.Cookies()
if resp.StatusCode != http.StatusOK || step.Token != "" || len(cookies) != 1 {
t.Fatalf("login: %d, body token %q, %d cookies", resp.StatusCode, step.Token, len(cookies))
}
c := cookies[0]
if c.Name != SessionCookie || !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode ||
c.Path != "" || c.MaxAge != int(DefaultTokenLifetime.Seconds()) {
t.Fatalf("session cookie %+v", c)
}
for _, basic := range []bool{false, true} {
if got := open(c, basic); got != http.StatusOK {
t.Fatalf("with the cookie (Basic auth %v): %d", basic, got)
}
}
req, _ := http.NewRequest(http.MethodPost, srv.URL+chain.AuthPath, strings.NewReader(`{"logout":true}`))
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth("site-user", "site-password")
req.AddCookie(c)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if cleared := resp.Cookies(); resp.StatusCode != http.StatusNoContent || len(cleared) != 1 || cleared[0].MaxAge >= 0 {
t.Fatalf("logout: %d, cookies %+v", resp.StatusCode, cleared)
}
if got := open(c, false); got != http.StatusUnauthorized {
t.Fatalf("after logout: %d, want 401", got)
}
if l.allowed.Load() != 1 {
t.Fatalf("logins = %d", l.allowed.Load())
}
direct := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, HTTP)
ds, _ := f.httpListener(t, direct, f.serverTLS)
client = f.httpClient(f.dialer(t, "edge-01", "edge-01-secret"))
proof := &auth.ClientFinalRequest{FullNonce: strings.Repeat("n", 32), ClientProof: "AAAA"}
for _, step := range []authStep{{Proof: proof, Session: "cookie"}, {Logout: true}} {
if resp, _ := post(ds.URL, step, nil); resp.StatusCode != http.StatusBadRequest {
t.Fatalf("%+v outside proxy mode: %d, want 400", step, resp.StatusCode)
}
}
}
// Behind a proxy logwisp never sees the certificate a client binds to, so
// only unbound proofs (lw auth token --unbound) log in.
func TestOnlyUnboundLoginsBehindProxy(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1"}}, f.serverTLS, config.Listener, HTTP)
srv, _ := f.httpListener(t, l, f.serverTLS)
for _, unbound := range []bool{false, true} {
d := f.dialer(t, "edge-01", "edge-01-secret")
if unbound {
d.Unbind()
}
client := f.httpClient(d)
client.Transport = forwarding{client.Transport}
_, err := d.Token(t.Context(), client, srv.URL)
if unbound != (err == nil) {
t.Errorf("unbound %v: %v", unbound, err)
}
}
if l.allowed.Load() != 1 || l.Rejected() != 1 {
t.Fatalf("logins %d, refusals %d; want 1 and 1", l.allowed.Load(), l.Rejected())
}
}
// Behind a proxy the limiter counts the forwarded client, and an IPv6 client
// by its /64, so neither one proxy address nor one host's many addresses
// share or escape a budget.
func TestProxyModeThrottlesTheForwardedClient(t *testing.T) {
f := newFixture(t)
_, srv := f.proxyListener(t)
hello := func(client string) int {
first, _ := auth.NewScramClient("edge-01", "edge-01-secret").StartAuthentication()
scram, _ := json.Marshal(first)
body, _ := json.Marshal(chain.Hello{LogWisp: chain.ProtocolVersion, Scram: scram})
req, _ := http.NewRequest(http.MethodPost, srv.URL+chain.AuthPath, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", client)
req.Header.Set("X-Forwarded-Proto", "https")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp.StatusCode
}
for _, tc := range []struct {
name string
clients []string
next, another string
}{
{"one client", []string{"203.0.113.7"}, "203.0.113.7", "198.51.100.1"},
{"one IPv6 /64", []string{"2001:db8::1", "2001:db8::2", "2001:db8::3", "2001:db8::4"}, "2001:db8::5", "2001:db8:0:1::1"},
} {
for i := range limitPending {
hello(tc.clients[i%len(tc.clients)]) // unanswered: each holds a slot
}
if got := hello(tc.next); got != http.StatusTooManyRequests {
t.Errorf("%s past its pending cap: %d, want 429", tc.name, got)
}
if got := hello(tc.another); got != http.StatusOK {
t.Errorf("%s: another client %s: %d, want 200", tc.name, tc.another, got)
}
}
}
// /auth takes only JSON: a cross-origin page cannot send it without a
// preflight nobody answers, so it cannot drive a login or logout.
func TestAuthTakesOnlyJSON(t *testing.T) {
f := newFixture(t)
_, srv := f.proxyListener(t)
client := &http.Client{Transport: forwarding{http.DefaultTransport}}
resp, err := client.Post(srv.URL+chain.AuthPath, "text/plain", strings.NewReader(`{"logout":true}`))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnsupportedMediaType {
t.Fatalf("text/plain /auth: %d, want 415", resp.StatusCode)
}
}