Bootstrap copied BFF and local accounts with public Sendico contracts (#34)

This commit is contained in:
Stephan D committed 2026-09-12 13:37:48 +02:00
1 parent 67dc5b5b1e
commit 7062672a87
556 files changed
+42291 -777

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
package browser
import (
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
)
const (
TransportHeader = "X-Auth-Transport"
TransportCookie = "cookie"
CSRFHeader = "X-CSRF-Protection"
CSRFValue = "1"
RefreshCookie = "sxchnge_refresh"
)
func UsesCookie(r *http.Request) bool {
return r != nil && strings.EqualFold(strings.TrimSpace(r.Header.Get(TransportHeader)), TransportCookie)
}
func RefreshCredential(r *http.Request, fallback string) (string, error) {
if !UsesCookie(r) {
return strings.TrimSpace(fallback), nil
}
cookie, err := r.Cookie(RefreshCookie)
if err != nil || strings.TrimSpace(cookie.Value) == "" {
return "", merrors.Unauthorized("browser session is missing")
}
return cookie.Value, nil
}
func SetRefreshCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
if !UsesCookie(r) {
return
}
maxAge := int(time.Until(expiresAt).Seconds())
if maxAge < 1 {
maxAge = 1
}
http.SetCookie(w, &http.Cookie{
Name: RefreshCookie,
Value: token,
Path: cookiePath(r),
Expires: expiresAt.UTC(),
MaxAge: maxAge,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func ClearRefreshCookie(w http.ResponseWriter, r *http.Request) {
if !UsesCookie(r) {
return
}
http.SetCookie(w, &http.Cookie{
Name: RefreshCookie,
Value: "",
Path: cookiePath(r),
Expires: time.Unix(1, 0).UTC(),
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func ValidateCSRF(r *http.Request, allowedOrigins []string) error {
if !UsesCookie(r) || isSafeMethod(r.Method) {
return nil
}
if r.Header.Get(CSRFHeader) != CSRFValue {
return merrors.PermissionDenied("browser CSRF protection header is missing")
}
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" || !originAllowed(origin, allowedOrigins) {
return merrors.PermissionDenied("browser request origin is not allowed")
}
return nil
}
func cookiePath(r *http.Request) string {
const accountsSegment = "/accounts"
if apiEndpoint := strings.TrimSpace(os.Getenv("API_ENDPOINT")); apiEndpoint != "" {
return path.Join("/", apiEndpoint, accountsSegment)
}
if r != nil && r.URL != nil {
if index := strings.Index(r.URL.Path, accountsSegment); index >= 0 {
return r.URL.Path[:index+len(accountsSegment)]
}
}
return accountsSegment
}
func isSafeMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
return true
default:
return false
}
}
func originAllowed(origin string, allowedOrigins []string) bool {
parsed, err := url.Parse(origin)
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" {
return false
}
normalized := strings.TrimSuffix(strings.ToLower(origin), "/")
for _, allowed := range allowedOrigins {
if strings.TrimSuffix(strings.ToLower(strings.TrimSpace(allowed)), "/") == normalized {
return true
}
}
return false
}
@@ -0,0 +1,122 @@
package browser
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestSetRefreshCookieUsesHardenedHostOnlyPolicy(t *testing.T) {
t.Setenv("API_ENDPOINT", "/api/v1")
req := httptest.NewRequest(http.MethodPost, "http://app.sendico.io/api/v1/accounts/login", nil)
req.Header.Set(TransportHeader, TransportCookie)
req.Header.Set("X-Forwarded-Proto", "https")
recorder := httptest.NewRecorder()
SetRefreshCookie(recorder, req, "secret-refresh-token", time.Now().Add(time.Hour))
cookies := recorder.Result().Cookies()
require.Len(t, cookies, 1)
cookie := cookies[0]
require.Equal(t, RefreshCookie, cookie.Name)
require.Equal(t, "secret-refresh-token", cookie.Value)
require.Equal(t, "/api/v1/accounts", cookie.Path)
require.Empty(t, cookie.Domain, "an omitted Domain attribute keeps the cookie host-only")
require.True(t, cookie.HttpOnly)
require.True(t, cookie.Secure)
require.Equal(t, http.SameSiteStrictMode, cookie.SameSite)
require.Positive(t, cookie.MaxAge)
}
func TestSetRefreshCookieUsesConfiguredAPIPathOutsideAccountsRouter(t *testing.T) {
t.Setenv("API_ENDPOINT", "/gateway/v2/")
req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/gateway/v2/verification/login", nil)
req.Header.Set(TransportHeader, TransportCookie)
recorder := httptest.NewRecorder()
SetRefreshCookie(recorder, req, "refresh", time.Now().Add(time.Hour))
require.Equal(t, "/gateway/v2/accounts", recorder.Result().Cookies()[0].Path)
}
func TestRefreshCookieIsAlwaysSecure(t *testing.T) {
t.Setenv("API_ENDPOINT", "/api/v1")
tests := []struct {
name string
target string
forwardedProto string
}{
{name: "public hostname", target: "http://app.sendico.io/api/v1/accounts/login"},
{name: "localhost", target: "http://localhost:4000/api/v1/accounts/login"},
{name: "loopback IP", target: "http://127.0.0.1:4000/api/v1/accounts/login"},
{name: "private IP", target: "http://192.168.1.20:4000/api/v1/accounts/login"},
{name: "forwarded HTTP", target: "http://app.sendico.io/api/v1/accounts/login", forwardedProto: "http"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, test.target, nil)
req.Header.Set(TransportHeader, TransportCookie)
if test.forwardedProto != "" {
req.Header.Set("X-Forwarded-Proto", test.forwardedProto)
}
recorder := httptest.NewRecorder()
SetRefreshCookie(recorder, req, "refresh", time.Now().Add(time.Hour))
require.True(t, recorder.Result().Cookies()[0].Secure)
})
}
}
func TestClearRefreshCookieUsesHardenedPolicy(t *testing.T) {
t.Setenv("API_ENDPOINT", "/api/v1")
req := httptest.NewRequest(http.MethodPost, "http://192.168.1.20:4000/api/v1/accounts/logout", nil)
req.Header.Set(TransportHeader, TransportCookie)
recorder := httptest.NewRecorder()
ClearRefreshCookie(recorder, req)
cookies := recorder.Result().Cookies()
require.Len(t, cookies, 1)
cookie := cookies[0]
require.Equal(t, RefreshCookie, cookie.Name)
require.Empty(t, cookie.Value)
require.Equal(t, -1, cookie.MaxAge)
require.True(t, cookie.HttpOnly)
require.True(t, cookie.Secure)
require.Equal(t, http.SameSiteStrictMode, cookie.SameSite)
}
func TestValidateCSRFRejectsCrossSiteAndMissingHeaderRequests(t *testing.T) {
allowed := []string{"https://app.sendico.io"}
missingHeader := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/payments", nil)
missingHeader.Header.Set(TransportHeader, TransportCookie)
missingHeader.Header.Set("Origin", allowed[0])
require.Error(t, ValidateCSRF(missingHeader, allowed))
crossSite := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/payments", nil)
crossSite.Header.Set(TransportHeader, TransportCookie)
crossSite.Header.Set(CSRFHeader, CSRFValue)
crossSite.Header.Set("Origin", "https://attacker.example")
require.Error(t, ValidateCSRF(crossSite, allowed))
valid := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/payments", nil)
valid.Header.Set(TransportHeader, TransportCookie)
valid.Header.Set(CSRFHeader, CSRFValue)
valid.Header.Set("Origin", allowed[0])
require.NoError(t, ValidateCSRF(valid, allowed))
}
func TestRefreshCredentialNeverFallsBackForCookieTransport(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/refresh", nil)
req.Header.Set(TransportHeader, TransportCookie)
credential, err := RefreshCredential(req, "body-token")
require.Error(t, err)
require.Empty(t, credential)
}