Pace provider traffic and identify genuine foreground bank requests

This commit is contained in:
Lars Nolden
2026-09-10 17:47:44 +02:00
parent ba3ea6ae5a
commit 4324660888
13 changed files with 817 additions and 32 deletions
+39 -3
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"encoding/json"
"errors"
"io"
@@ -14,6 +15,7 @@ import (
"finance-duck/internal/analytics"
"finance-duck/internal/app"
"finance-duck/internal/banking"
"finance-duck/internal/domain"
)
@@ -43,14 +45,14 @@ func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) {
s.mux.HandleFunc("POST /api/import", s.importCSV)
s.mux.HandleFunc("POST /api/backfill", s.backfill)
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/sync", func(w http.ResponseWriter, r *http.Request) { v, e := a.Sync(s.manualBankContext(r)); 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) {
v, e := a.Balances(r.Context(), r.URL.Query().Get("account_id"))
v, e := a.Balances(s.manualBankContext(r), r.URL.Query().Get("account_id"))
respond(w, v, e)
})
s.mux.HandleFunc("POST /api/reclassify/preview", s.preview)
@@ -139,6 +141,40 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 32<<20)
s.mux.ServeHTTP(w, r)
}
// manualBankContext is used only at manual account-data boundaries, after the
// Host/origin guards. The configured public origin trusts the local reverse
// proxy to append its observed client IP to X-Forwarded-For. Never trust a
// client-supplied leading hop, or forwarding headers from a non-loopback peer.
func (s *Server) manualBankContext(r *http.Request) context.Context {
peer, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
peer = r.RemoteAddr
}
ip := net.ParseIP(peer)
if s.origin != nil && ip != nil && ip.IsLoopback() {
// A local proxy address is not the end user's address.
ip = nil
forwarded := r.Header.Values("X-Forwarded-For")
if len(forwarded) > 0 {
last := forwarded[len(forwarded)-1]
if index := strings.LastIndexByte(last, ','); index >= 0 {
last = last[index+1:]
}
// A malformed proxy hop is unknown, not the proxy's own PSU IP.
ip = net.ParseIP(strings.TrimSpace(last))
}
}
address := ""
if ip != nil {
address = ip.String()
}
return banking.WithPSU(r.Context(), banking.PSU{
IPAddress: address, UserAgent: r.UserAgent(),
Accept: r.Header.Get("Accept"), AcceptCharset: r.Header.Get("Accept-Charset"),
AcceptEncoding: r.Header.Get("Accept-Encoding"), AcceptLanguage: r.Header.Get("Accept-Language"),
})
}
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
d := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
d.DisallowUnknownFields()
@@ -291,7 +327,7 @@ func (s *Server) backfill(w http.ResponseWriter, r *http.Request) {
if !decode(w, r, &b) {
return
}
v, e := s.app.Backfill(r.Context(), b.Revision, b.AccountID, b.HistoryMonths)
v, e := s.app.Backfill(s.manualBankContext(r), b.Revision, b.AccountID, b.HistoryMonths)
respond(w, v, e)
}
func (s *Server) settings(w http.ResponseWriter, r *http.Request) {