Pace provider traffic and identify genuine foreground bank requests
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -9,11 +9,13 @@ import (
|
||||
"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) {
|
||||
@@ -213,3 +215,95 @@ func TestBankingConfigurationProtectsPrivateKeyAndCallbackOrigin(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user