216 lines
8.0 KiB
Go
216 lines
8.0 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"finance-duck/internal/app"
|
|
)
|
|
|
|
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)
|
|
}
|