package server import ( "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/json" "encoding/pem" "io" "mime/multipart" "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("Finance")}}, "") 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"}`)) 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), "") { 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"}`, 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") } }) } } // The whole import must travel through prepare -> review -> confirm over HTTP, // and only the multipart upload may bypass the JSON content-type CSRF guard. func TestCSVImportOverHTTPImportsOnlyAfterConfirmation(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 origin = "http://localhost:8080" send := func(path, contentType, body, origin string, want int) *httptest.ResponseRecorder { t.Helper() r := httptest.NewRequest(http.MethodPost, "http://localhost:8080"+path, strings.NewReader(body)) r.Header.Set("Content-Type", contentType) r.Header.Set("Origin", origin) w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != want { t.Fatalf("POST %s: got %d, want %d: %s", path, w.Code, want, w.Body.String()) } return w } state := func(w *httptest.ResponseRecorder) app.State { t.Helper() var s app.State if err := json.Unmarshal(w.Body.Bytes(), &s); err != nil { t.Fatal(err) } return s } get := func(path string) *httptest.ResponseRecorder { t.Helper() w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://localhost:8080"+path, nil)) if w.Code != http.StatusOK { t.Fatalf("GET %s: got %d: %s", path, w.Code, w.Body.String()) } return w } account := `{"revision":"` + state(get("/api/state")).Revision + `","account":{"display_name":"ING","institution":"ING","currency":"EUR","active":true}}` current := state(send("/api/accounts", "application/json", account, origin, http.StatusOK)) if len(current.Data.Accounts) != 1 { t.Fatalf("account was not created: %+v", current.Data.Accounts) } statement := "Buchung;Wertstellungsdatum;Auftraggeber/Empfänger;Buchungstext;Verwendungszweck;Betrag;Währung\n" + "09.12.2025;09.12.2025;VISA Firma;Lastschrift;NR XXXX 4025 KAUFUMSATZ;-13,98;EUR\n" + "28.11.2025;28.11.2025;Rente;Gehalt/Rente;RV-RENTE 11.2025;2.647,74;EUR\n" upload := func(revision, accountID string) (string, string) { t.Helper() var body strings.Builder form := multipart.NewWriter(&body) for name, value := range map[string]string{"revision": revision, "account_id": accountID} { if err := form.WriteField(name, value); err != nil { t.Fatal(err) } } file, err := form.CreateFormFile("file", "umsatzanzeige.csv") if err != nil { t.Fatal(err) } if _, err := io.WriteString(file, statement); err != nil { t.Fatal(err) } if err := form.Close(); err != nil { t.Fatal(err) } return form.FormDataContentType(), body.String() } contentType, body := upload(current.Revision, current.Data.Accounts[0].ID) // A multipart upload from another origin is still refused. send("/api/import/prepare", contentType, body, "https://attacker.example", http.StatusForbidden) var prepared app.CSVImport if err := json.Unmarshal(send("/api/import/prepare", contentType, body, origin, http.StatusOK).Body.Bytes(), &prepared); err != nil { t.Fatal(err) } if prepared.SourceLabel != "ING" || prepared.MappedBy != "preset" || prepared.New != 2 || len(prepared.Samples) != 2 { t.Fatalf("unexpected prepared import: %+v", prepared) } if transactions := state(get("/api/state")).Data.Transactions; len(transactions) != 0 { t.Fatalf("preparing an import wrote %d transactions", len(transactions)) } // Confirmation is an ordinary JSON mutation, guarded like every other one. confirm := `{"id":"` + prepared.ID + `","revision":"` + prepared.Revision + `"}` send("/api/import/confirm", "text/plain", confirm, origin, http.StatusUnsupportedMediaType) send("/api/import/cancel", "text/plain", `{"id":"`+prepared.ID+`"}`, origin, http.StatusUnsupportedMediaType) var result app.ImportResult if err := json.Unmarshal(send("/api/import/confirm", "application/json", confirm, origin, http.StatusOK).Body.Bytes(), &result); err != nil { t.Fatal(err) } if result.Imported != 2 || len(result.State.Data.Transactions) != 2 { t.Fatalf("confirmation did not import the reviewed statement: %+v", result) } for _, tx := range result.State.Data.Transactions { if tx.Facts.Source != "ing_csv" || tx.Facts.Currency != "EUR" { t.Fatalf("unexpected imported facts: %+v", tx.Facts) } } send("/api/import/confirm", "application/json", confirm, origin, http.StatusBadRequest) }