Add native NixOS deployment and UI-managed provider credentials

This commit is contained in:
Lars Nolden
2026-09-10 14:25:37 +02:00
parent 9843fe0c50
commit 964b9dfc15
21 changed files with 2084 additions and 104 deletions
+57
View File
@@ -44,6 +44,8 @@ func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) {
s.mux.HandleFunc("POST /api/rebuild", func(w http.ResponseWriter, r *http.Request) { v, e := a.Rebuild(r.Context()); respond(w, v, e) })
s.mux.HandleFunc("POST /api/sync", func(w http.ResponseWriter, r *http.Request) { v, e := a.Sync(r.Context()); respond(w, v, e) })
s.mux.HandleFunc("POST /api/settings", s.settings)
s.mux.HandleFunc("POST /api/settings/openrouter", s.openRouterKey)
s.mux.HandleFunc("POST /api/settings/enablebanking", s.bankingSettings)
s.mux.HandleFunc("POST /api/banking/authorize", s.authorize)
s.mux.HandleFunc("GET /api/banking/callback", s.callback)
s.mux.HandleFunc("GET /api/balances", func(w http.ResponseWriter, r *http.Request) {
@@ -287,6 +289,61 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) {
v, e := s.app.SaveSettings(r.Context(), b)
respond(w, v, e)
}
func (s *Server) openRouterKey(w http.ResponseWriter, r *http.Request) {
var b struct {
APIKey *string `json:"api_key"`
}
d := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
d.DisallowUnknownFields()
// Decoder errors can quote request values. Never echo credential input.
if d.Decode(&b) != nil || d.Decode(&struct{}{}) != io.EOF || b.APIKey == nil {
respond(w, nil, errors.New("expected one JSON object with an api_key string"))
return
}
v, e := s.app.SaveOpenRouterKey(r.Context(), *b.APIKey)
respond(w, v, e)
}
func (s *Server) bankingSettings(w http.ResponseWriter, r *http.Request) {
var b struct {
AppID *string `json:"app_id"`
PrivateKey *string `json:"private_key"`
RedirectURL *string `json:"redirect_url"`
Remove bool `json:"remove"`
}
d := json.NewDecoder(io.LimitReader(r.Body, 256<<10))
d.DisallowUnknownFields()
// Parsing failures must not echo uploaded private-key content.
if d.Decode(&b) != nil || d.Decode(&struct{}{}) != io.EOF {
respond(w, nil, errors.New("invalid Enable Banking configuration request"))
return
}
if b.Remove {
if b.AppID != nil || b.PrivateKey != nil || b.RedirectURL != nil {
respond(w, nil, errors.New("remove cannot be combined with banking credentials"))
return
}
v, e := s.app.RemoveBankingSettings(r.Context())
respond(w, v, e)
return
}
if b.AppID == nil || b.RedirectURL == nil {
respond(w, nil, errors.New("Enable Banking application ID and callback URL are required"))
return
}
scheme, host := "http", r.Host
if r.TLS != nil {
scheme = "https"
}
if s.origin != nil {
scheme, host = s.origin.Scheme, s.origin.Host
}
if *b.RedirectURL != scheme+"://"+host+"/api/banking/callback" {
respond(w, nil, errors.New("Enable Banking callback URL must match this application's origin and /api/banking/callback path"))
return
}
v, e := s.app.SaveBankingSettings(r.Context(), *b.AppID, b.PrivateKey, *b.RedirectURL)
respond(w, v, e)
}
func (s *Server) authorize(w http.ResponseWriter, r *http.Request) {
var b struct {
Institution string `json:"institution"`
+152
View File
@@ -1,7 +1,11 @@
package server
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
@@ -61,3 +65,151 @@ func TestOriginAndHostGuardProtectNoLoginService(t *testing.T) {
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)
}