Files
finance-duck/internal/server/server_test.go
T

310 lines
12 KiB
Go

package server
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"testing/fstest"
"finance-duck/internal/app"
"finance-duck/internal/banking"
)
func TestOriginAndHostGuardProtectNoLoginService(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "")
t.Setenv("ENABLEBANKING_APP_ID", "")
t.Setenv("ENABLEBANKING_KEY_FILE", "")
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
a, err := app.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer a.Close()
h, err := New(a, fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<!doctype html><title>Finance</title>")}}, "")
if err != nil {
t.Fatal(err)
}
cases := []struct {
name, host, origin, content string
want int
}{{"rebound host", "attacker.example", "", "application/json", 403}, {"cross origin", "localhost:8080", "https://attacker.example", "application/json", 403}, {"simple form CSRF", "localhost:8080", "", "text/plain", 415}, {"valid local mutation", "localhost:8080", "http://localhost:8080", "application/json", 200}}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/settings", strings.NewReader(`{"model":"example/model","include_amount":false}`))
r.Host = tt.host
r.Header.Set("Content-Type", tt.content)
r.Header.Set("Origin", tt.origin)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != tt.want {
t.Fatalf("got %d: %s", w.Code, w.Body.String())
}
})
}
r := httptest.NewRequest(http.MethodGet, "http://localhost:8080/api/state", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
var s app.State
if err = json.NewDecoder(w.Body).Decode(&s); err != nil {
t.Fatal(err)
}
if s.Settings.Model != "example/model" {
t.Fatal("same-origin edit not persisted")
}
r = httptest.NewRequest(http.MethodGet, "http://localhost:8080/", nil)
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
b, _ := io.ReadAll(w.Body)
if w.Code != 200 || !strings.Contains(string(b), "<!doctype html>") {
t.Fatalf("UI not served: %d %s", w.Code, b)
}
}
func TestOpenRouterKeyIsWriteOnlyAndRequiresExplicitRemoval(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "")
t.Setenv("ENABLEBANKING_APP_ID", "")
t.Setenv("ENABLEBANKING_KEY_FILE", "")
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
a, err := app.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer a.Close()
h, err := New(a, fstest.MapFS{}, "")
if err != nil {
t.Fatal(err)
}
const secret = "test-openrouter-private-key"
check := func(method, path, body, origin string, want int) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(method, "http://localhost:8080"+path, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Origin", origin)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if strings.Contains(w.Body.String(), secret) {
t.Fatal("credential leaked in HTTP response")
}
if w.Code != want {
t.Fatalf("%s %s: got %d, want %d: %s", method, path, w.Code, want, w.Body.String())
}
return w
}
configured := func(w *httptest.ResponseRecorder, want bool) {
t.Helper()
var state app.State
if err := json.Unmarshal(w.Body.Bytes(), &state); err != nil {
t.Fatal(err)
}
if state.Status.AIConfigured != want {
t.Fatalf("configured = %t, want %t", state.Status.AIConfigured, want)
}
}
const endpoint = "/api/settings/openrouter"
const origin = "http://localhost:8080"
keyJSON := `{"api_key":"` + secret + `"}`
check("POST", endpoint, keyJSON, "https://attacker.example", http.StatusForbidden)
configured(check("GET", "/api/state", "", origin, http.StatusOK), false)
configured(check("POST", endpoint, keyJSON, origin, http.StatusOK), true)
configured(check("GET", "/api/state", "", origin, http.StatusOK), true)
// Ordinary preference updates must not implicitly erase credentials.
configured(check("POST", "/api/settings", `{"model":"example/model","include_amount":false}`, origin, http.StatusOK), true)
for _, body := range []string{
`{}`,
`{"api_key":null}`,
`{"api_key":["` + secret + `"]}`,
`{"` + secret + `":"unexpected field"}`,
keyJSON + `{}`,
`{"api_key":"` + secret + `\ninvalid"}`,
} {
check("POST", endpoint, body, origin, http.StatusBadRequest)
configured(check("GET", "/api/state", "", origin, http.StatusOK), true)
}
configured(check("POST", endpoint, `{"api_key":""}`, origin, http.StatusOK), false)
configured(check("GET", "/api/state", "", origin, http.StatusOK), false)
}
func TestBankingConfigurationProtectsPrivateKeyAndCallbackOrigin(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "")
t.Setenv("ENABLEBANKING_APP_ID", "")
t.Setenv("ENABLEBANKING_KEY_FILE", "")
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
a, err := app.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer a.Close()
const origin = "https://finance.internal:8444"
const callback = origin + "/api/banking/callback"
const endpoint = "/api/settings/enablebanking"
h, err := New(a, fstest.MapFS{}, origin)
if err != nil {
t.Fatal(err)
}
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
secretLine := strings.Split(keyPEM, "\n")[1]
payload := func(v any) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return string(b)
}
check := func(method, path, body, requestOrigin string, want int) *httptest.ResponseRecorder {
t.Helper()
// The reverse-proxy hop is HTTP; public Origin and callback are HTTPS.
r := httptest.NewRequest(method, "http://finance.internal:8444"+path, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Origin", requestOrigin)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if strings.Contains(w.Body.String(), secretLine) || strings.Contains(w.Body.String(), "PRIVATE KEY") {
t.Fatal("private key leaked in banking response")
}
if w.Code != want {
t.Fatalf("%s %s: got %d, want %d: %s", method, path, w.Code, want, w.Body.String())
}
return w
}
configured := func(w *httptest.ResponseRecorder, want bool) {
t.Helper()
var state app.State
if err := json.Unmarshal(w.Body.Bytes(), &state); err != nil {
t.Fatal(err)
}
if state.Status.BankingConfigured != want {
t.Fatalf("banking configured = %t, want %t", state.Status.BankingConfigured, want)
}
if want && (state.BankingAppID != "bank-app" || state.CallbackURL != callback) {
t.Fatal("saved application metadata is not available to the UI")
}
}
save := payload(map[string]any{"app_id": "bank-app", "private_key": keyPEM, "redirect_url": callback})
check("POST", endpoint, save, "https://attacker.example", http.StatusForbidden)
configured(check("GET", "/api/state", "", origin, http.StatusOK), false)
configured(check("POST", endpoint, save, origin, http.StatusOK), true)
configured(check("GET", "/api/state", "", origin, http.StatusOK), true)
// A callback correction can retain the current signing key.
configured(check("POST", endpoint, payload(map[string]any{"app_id": "bank-app", "private_key": nil, "redirect_url": callback}), origin, http.StatusOK), true)
for _, body := range []string{
`{}`,
payload(map[string]any{"app_id": "bank-app", "redirect_url": "https://attacker.example/api/banking/callback"}),
payload(map[string]any{"app_id": "bank-app", "redirect_url": "http://finance.internal:8444/api/banking/callback"}),
payload(map[string]any{"app_id": "different-app", "private_key": nil, "redirect_url": callback}),
payload(map[string]any{"app_id": "", "private_key": "", "redirect_url": callback}),
payload(map[string]any{"remove": true, "private_key": keyPEM}),
payload(map[string]any{secretLine: "unknown field"}),
save + `{}`,
} {
check("POST", endpoint, body, origin, http.StatusBadRequest)
configured(check("GET", "/api/state", "", origin, http.StatusOK), true)
}
configured(check("POST", endpoint, `{"remove":true}`, origin, http.StatusOK), false)
configured(check("GET", "/api/state", "", origin, http.StatusOK), false)
}
type psuRoundTripFunc func(*http.Request) (*http.Response, error)
func (f psuRoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
func TestManualBankContextUsesOnlyTrustedPeerAndBrowserMetadata(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
origin, err := url.Parse("https://finance.internal")
if err != nil {
t.Fatal(err)
}
for _, tt := range []struct {
name string
public bool
peer string
forwarded []string
wantIP string
userAgent string
}{
{"direct rejects forwarding", false, "198.51.100.8:1234", []string{"192.0.2.99"}, "198.51.100.8", "real-browser"},
{"untrusted remote proxy", true, "198.51.100.8:1234", []string{"192.0.2.99"}, "198.51.100.8", "real-browser"},
{"unconfigured loopback", false, "127.0.0.1:1234", []string{"192.0.2.99"}, "127.0.0.1", "real-browser"},
{"trusted appended hop", true, "127.0.0.1:1234", []string{"192.0.2.99, 203.0.113.42"}, "203.0.113.42", "real-browser"},
{"last header appended hop", true, "[::1]:1234", []string{"192.0.2.99", "203.0.113.42"}, "203.0.113.42", "real-browser"},
{"IPv6 client", true, "[::1]:1234", []string{"192.0.2.99, 2001:db8::42"}, "2001:db8::42", "real-browser"},
{"missing trusted hop", true, "127.0.0.1:1234", nil, "", "real-browser"},
{"invalid appended hop not leading spoof", true, "127.0.0.1:1234", []string{"192.0.2.99, invalid"}, "", "real-browser"},
{"unknown peer and absent user agent", false, "invalid", []string{"192.0.2.99"}, "", ""},
} {
t.Run(tt.name, func(t *testing.T) {
s := &Server{}
if tt.public {
s.origin = origin
}
r := httptest.NewRequest(http.MethodPost, "https://finance.internal/api/backfill?secret=private", strings.NewReader(`{}`))
r.RemoteAddr = tt.peer
r.Header["X-Forwarded-For"] = tt.forwarded
r.Header.Set("User-Agent", tt.userAgent)
r.Header.Set("Accept", "application/json")
r.Header.Set("Accept-Charset", "utf-8")
r.Header.Set("Accept-Encoding", "gzip, br")
r.Header.Set("Accept-Language", "de-DE")
for _, name := range []string{"Cookie", "Authorization", "Referer", "Psu-Ip-Address", "Psu-User-Agent", "Psu-Referer", "Psu-Geo-Location", "Psu-Cookie"} {
r.Header.Set(name, "private-spoofed-value")
}
p, err := banking.NewEnableBanking("test-app", keyPEM, "https://finance.internal/api/banking/callback")
if err != nil {
t.Fatal(err)
}
called := false
p.HTTPClient = &http.Client{Transport: psuRoundTripFunc(func(out *http.Request) (*http.Response, error) {
called = true
want := map[string]string{
"Psu-Ip-Address": tt.wantIP, "Psu-User-Agent": tt.userAgent,
"Psu-Accept": "application/json", "Psu-Accept-Charset": "utf-8",
"Psu-Accept-Encoding": "gzip, br", "Psu-Accept-Language": "de-DE",
}
for name, value := range want {
if got := out.Header.Get(name); got != value {
t.Errorf("%s = %q, want %q", name, got, value)
}
}
for name, values := range out.Header {
if strings.HasPrefix(strings.ToLower(name), "psu-") {
if _, allowed := want[name]; !allowed {
t.Errorf("unexpected PSU header %s", name)
}
}
if strings.Contains(strings.Join(values, ","), "private") {
t.Errorf("secret request metadata leaked in %s", name)
}
}
if out.URL.RawQuery != "" || out.Header.Get("Cookie") != "" || out.Header.Get("Referer") != "" {
t.Error("request URL or secret headers copied to bank")
}
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"balances":[]}`))}, nil
})}
if _, err := p.Balances(s.manualBankContext(r), "uid"); err != nil {
t.Fatal(err)
}
if !called {
t.Fatal("manual retrieval never reached bank transport")
}
})
}
}