mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
d890904ce6
getCookieSettings auto-set SameSite=None on the session and CSRF cookies whenever a request arrived via a trusted proxy with X-Forwarded-Proto: https. The original intent was "be more permissive for proxied deployments," but in practice that disabled the browser-side CSRF defence for every Pulse deployment behind a reverse proxy — which is nearly all of them. SameSite=None tells the browser to attach the cookie on arbitrary cross-site requests. That is only required for cross-origin iframe embedding scenarios Pulse does not document or support. The top-level-navigation cases that actually matter (OIDC/SAML callback landing, Cloudflare-tunnel access from a bookmark) all work under Lax, which still attaches cookies on top-level navigations and only blocks cross-site sub-resource requests. Always return SameSiteLaxMode. The Secure flag still tracks the actual connection state via isConnectionSecure(r). Together with the earlier CSRF bypass fix (require token regardless of Authorization header), the browser-side and server-side defences are now both in place. Test expectations updated: three cases that previously asserted SameSiteNoneMode for the proxied-HTTPS / Cloudflare-tunnel / direct- TLS-with-Forwarded paths now assert SameSiteLaxMode, with comments flagging them as regression coverage.
1055 lines
29 KiB
Go
1055 lines
29 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
)
|
|
|
|
func TestDetectProxy(t *testing.T) {
|
|
// Set up trusted proxy CIDRs so that 127.0.0.1 (default httptest RemoteAddr) is trusted.
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "127.0.0.1/32,192.0.2.1/32")
|
|
ResetTrustedProxyConfigForTests()
|
|
|
|
tests := []struct {
|
|
name string
|
|
headers map[string]string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "no proxy headers",
|
|
headers: map[string]string{},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "X-Forwarded-For present",
|
|
headers: map[string]string{"X-Forwarded-For": "192.168.1.1"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "X-Real-IP present",
|
|
headers: map[string]string{"X-Real-IP": "10.0.0.1"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "X-Forwarded-Proto present",
|
|
headers: map[string]string{"X-Forwarded-Proto": "https"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "X-Forwarded-Host present",
|
|
headers: map[string]string{"X-Forwarded-Host": "example.com"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "Forwarded header present (RFC 7239)",
|
|
headers: map[string]string{"Forwarded": "for=192.168.1.1;proto=https"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "Cloudflare CF-Ray present",
|
|
headers: map[string]string{"CF-Ray": "abc123"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "Cloudflare CF-Connecting-IP present",
|
|
headers: map[string]string{"CF-Connecting-IP": "1.2.3.4"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "X-Forwarded-Server present",
|
|
headers: map[string]string{"X-Forwarded-Server": "proxy.example.com"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "X-Forwarded-Port present",
|
|
headers: map[string]string{"X-Forwarded-Port": "443"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "multiple proxy headers",
|
|
headers: map[string]string{
|
|
"X-Forwarded-For": "192.168.1.1",
|
|
"X-Forwarded-Proto": "https",
|
|
"CF-Ray": "abc123",
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "unrelated headers only",
|
|
headers: map[string]string{"Content-Type": "application/json", "User-Agent": "test"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "empty X-Forwarded-For",
|
|
headers: map[string]string{"X-Forwarded-For": ""},
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
for key, value := range tt.headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
|
|
got := detectProxy(req)
|
|
if got != tt.want {
|
|
t.Errorf("detectProxy() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Verify untrusted peer cannot inject proxy headers
|
|
t.Run("untrusted peer with proxy headers", func(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "10.0.0.0/8")
|
|
ResetTrustedProxyConfigForTests()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.RemoteAddr = "203.0.113.1:12345" // not in trusted CIDRs
|
|
req.Header.Set("X-Forwarded-For", "192.168.1.1")
|
|
if detectProxy(req) {
|
|
t.Error("detectProxy() should return false for untrusted peer")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIsConnectionSecure(t *testing.T) {
|
|
// Trust the default httptest RemoteAddr (192.0.2.1) for proxy header tests
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "192.0.2.1/32")
|
|
ResetTrustedProxyConfigForTests()
|
|
|
|
tests := []struct {
|
|
name string
|
|
useTLS bool
|
|
headers map[string]string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "plain HTTP no headers",
|
|
useTLS: false,
|
|
headers: map[string]string{},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "TLS connection",
|
|
useTLS: true,
|
|
headers: map[string]string{},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "X-Forwarded-Proto https (trusted proxy)",
|
|
useTLS: false,
|
|
headers: map[string]string{"X-Forwarded-Proto": "https"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "X-Forwarded-Proto http",
|
|
useTLS: false,
|
|
headers: map[string]string{"X-Forwarded-Proto": "http"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "X-Forwarded-Proto HTTPS uppercase",
|
|
useTLS: false,
|
|
headers: map[string]string{"X-Forwarded-Proto": "HTTPS"},
|
|
want: false, // strict comparison
|
|
},
|
|
{
|
|
name: "Forwarded header with proto=https (trusted proxy)",
|
|
useTLS: false,
|
|
headers: map[string]string{"Forwarded": "for=192.168.1.1;proto=https"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "Forwarded header with proto=http",
|
|
useTLS: false,
|
|
headers: map[string]string{"Forwarded": "for=192.168.1.1;proto=http"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "TLS with X-Forwarded-Proto http (TLS takes precedence)",
|
|
useTLS: true,
|
|
headers: map[string]string{"X-Forwarded-Proto": "http"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "both X-Forwarded-Proto and Forwarded present",
|
|
useTLS: false,
|
|
headers: map[string]string{
|
|
"X-Forwarded-Proto": "https",
|
|
"Forwarded": "proto=http",
|
|
},
|
|
want: true, // X-Forwarded-Proto is checked first
|
|
},
|
|
{
|
|
name: "empty X-Forwarded-Proto",
|
|
useTLS: false,
|
|
headers: map[string]string{"X-Forwarded-Proto": ""},
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
for key, value := range tt.headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
|
|
if tt.useTLS {
|
|
req.TLS = &tls.ConnectionState{}
|
|
}
|
|
|
|
got := isConnectionSecure(req)
|
|
if got != tt.want {
|
|
t.Errorf("isConnectionSecure() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Verify untrusted peer's X-Forwarded-Proto is ignored
|
|
t.Run("untrusted peer X-Forwarded-Proto ignored", func(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "10.0.0.0/8")
|
|
ResetTrustedProxyConfigForTests()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.RemoteAddr = "203.0.113.1:12345"
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
if isConnectionSecure(req) {
|
|
t.Error("isConnectionSecure() should return false for untrusted peer")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetCookieSettings(t *testing.T) {
|
|
// Trust the default httptest RemoteAddr (192.0.2.1) for proxy tests
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "192.0.2.1/32")
|
|
ResetTrustedProxyConfigForTests()
|
|
|
|
tests := []struct {
|
|
name string
|
|
useTLS bool
|
|
headers map[string]string
|
|
wantSecure bool
|
|
wantSameSite http.SameSite
|
|
}{
|
|
{
|
|
name: "plain HTTP no proxy",
|
|
useTLS: false,
|
|
headers: map[string]string{},
|
|
wantSecure: false,
|
|
wantSameSite: http.SameSiteLaxMode,
|
|
},
|
|
{
|
|
name: "HTTPS direct connection",
|
|
useTLS: true,
|
|
headers: map[string]string{},
|
|
wantSecure: true,
|
|
wantSameSite: http.SameSiteLaxMode,
|
|
},
|
|
{
|
|
// Regression: previously auto-escalated SameSite to None here,
|
|
// which disabled the browser-side CSRF defence for every
|
|
// proxied-HTTPS deployment. SameSite must stay Lax; Secure
|
|
// still tracks the proxied protocol.
|
|
name: "HTTPS through proxy",
|
|
useTLS: false,
|
|
headers: map[string]string{
|
|
"X-Forwarded-For": "192.168.1.1",
|
|
"X-Forwarded-Proto": "https",
|
|
},
|
|
wantSecure: true,
|
|
wantSameSite: http.SameSiteLaxMode,
|
|
},
|
|
{
|
|
name: "HTTP through proxy",
|
|
useTLS: false,
|
|
headers: map[string]string{
|
|
"X-Forwarded-For": "192.168.1.1",
|
|
"X-Forwarded-Proto": "http",
|
|
},
|
|
wantSecure: false,
|
|
wantSameSite: http.SameSiteLaxMode,
|
|
},
|
|
{
|
|
// Regression: same pattern as the HTTPS-through-proxy case via
|
|
// Cloudflare's CF-* headers — must NOT escalate to SameSite=None.
|
|
name: "Cloudflare tunnel HTTPS",
|
|
useTLS: false,
|
|
headers: map[string]string{
|
|
"CF-Ray": "abc123",
|
|
"CF-Connecting-IP": "1.2.3.4",
|
|
"X-Forwarded-Proto": "https",
|
|
},
|
|
wantSecure: true,
|
|
wantSameSite: http.SameSiteLaxMode,
|
|
},
|
|
{
|
|
name: "proxy detected but no proto header",
|
|
useTLS: false,
|
|
headers: map[string]string{
|
|
"X-Forwarded-For": "192.168.1.1",
|
|
},
|
|
wantSecure: false,
|
|
wantSameSite: http.SameSiteLaxMode,
|
|
},
|
|
{
|
|
// Regression: previously SameSite=None when TLS + trusted-proxy
|
|
// Forwarded header. Must stay Lax.
|
|
name: "direct TLS with Forwarded header (trusted proxy)",
|
|
useTLS: true,
|
|
headers: map[string]string{
|
|
"Forwarded": "for=192.168.1.1;proto=https",
|
|
},
|
|
wantSecure: true,
|
|
wantSameSite: http.SameSiteLaxMode,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
for key, value := range tt.headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
|
|
if tt.useTLS {
|
|
req.TLS = &tls.ConnectionState{}
|
|
}
|
|
|
|
gotSecure, gotSameSite := getCookieSettings(req)
|
|
if gotSecure != tt.wantSecure {
|
|
t.Errorf("getCookieSettings() secure = %v, want %v", gotSecure, tt.wantSecure)
|
|
}
|
|
if gotSameSite != tt.wantSameSite {
|
|
t.Errorf("getCookieSettings() sameSite = %v, want %v", gotSameSite, tt.wantSameSite)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenerateSessionToken(t *testing.T) {
|
|
// Test that tokens are generated
|
|
token := generateSessionToken()
|
|
if token == "" {
|
|
t.Error("generateSessionToken() returned empty string")
|
|
}
|
|
|
|
// Test token length (32 bytes = 64 hex characters)
|
|
if len(token) != 64 {
|
|
t.Errorf("generateSessionToken() length = %d, want 64", len(token))
|
|
}
|
|
|
|
// Test that tokens are unique
|
|
tokens := make(map[string]bool)
|
|
for i := 0; i < 100; i++ {
|
|
tok := generateSessionToken()
|
|
if tokens[tok] {
|
|
t.Errorf("generateSessionToken() generated duplicate token: %s", tok)
|
|
}
|
|
tokens[tok] = true
|
|
}
|
|
|
|
// Test that token is valid hex
|
|
for _, c := range token {
|
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
|
t.Errorf("generateSessionToken() contains non-hex character: %c", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateSession_NonExistentToken(t *testing.T) {
|
|
// Ensure session store is initialized
|
|
InitSessionStore(t.TempDir())
|
|
|
|
// A random token that doesn't exist should return false
|
|
result := ValidateSession("nonexistent-token-12345")
|
|
if result {
|
|
t.Error("ValidateSession should return false for non-existent token")
|
|
}
|
|
}
|
|
|
|
func TestValidateSession_EmptyToken(t *testing.T) {
|
|
InitSessionStore(t.TempDir())
|
|
|
|
result := ValidateSession("")
|
|
if result {
|
|
t.Error("ValidateSession should return false for empty token")
|
|
}
|
|
}
|
|
|
|
func TestValidateSession_ValidToken(t *testing.T) {
|
|
dir := t.TempDir()
|
|
InitSessionStore(dir)
|
|
|
|
// Create a valid session with a generated token
|
|
store := GetSessionStore()
|
|
token := generateSessionToken()
|
|
store.CreateSession(token, 24*time.Hour, "test-agent", "127.0.0.1", "testuser")
|
|
|
|
result := ValidateSession(token)
|
|
if !result {
|
|
t.Error("ValidateSession should return true for valid token")
|
|
}
|
|
}
|
|
|
|
func TestValidateSession_ExpiredToken(t *testing.T) {
|
|
dir := t.TempDir()
|
|
InitSessionStore(dir)
|
|
|
|
// Create a session and manually expire it
|
|
store := GetSessionStore()
|
|
token := generateSessionToken()
|
|
store.CreateSession(token, 24*time.Hour, "test-agent", "127.0.0.1", "testuser")
|
|
|
|
// Manually expire the session by modifying the store
|
|
store.mu.Lock()
|
|
hash := sessionHash(token)
|
|
if session, exists := store.sessions[hash]; exists {
|
|
session.ExpiresAt = time.Now().Add(-1 * time.Hour) // Set to past
|
|
store.sessions[hash] = session
|
|
}
|
|
store.mu.Unlock()
|
|
|
|
result := ValidateSession(token)
|
|
if result {
|
|
t.Error("ValidateSession should return false for expired token")
|
|
}
|
|
}
|
|
|
|
// CheckProxyAuth tests
|
|
|
|
func TestCheckProxyAuth_NotConfigured(t *testing.T) {
|
|
cfg := &config.Config{
|
|
ProxyAuthSecret: "", // Not configured
|
|
}
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
|
|
valid, username, isAdmin := CheckProxyAuth(cfg, req)
|
|
if valid {
|
|
t.Error("CheckProxyAuth should return false when not configured")
|
|
}
|
|
if username != "" {
|
|
t.Errorf("username should be empty, got %q", username)
|
|
}
|
|
if isAdmin {
|
|
t.Error("isAdmin should be false when not authenticated")
|
|
}
|
|
}
|
|
|
|
func TestCheckProxyAuth_InvalidSecret(t *testing.T) {
|
|
cfg := &config.Config{
|
|
ProxyAuthSecret: "correct-secret",
|
|
}
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Header.Set("X-Proxy-Secret", "wrong-secret")
|
|
|
|
valid, username, isAdmin := CheckProxyAuth(cfg, req)
|
|
if valid {
|
|
t.Error("CheckProxyAuth should return false for invalid secret")
|
|
}
|
|
if username != "" {
|
|
t.Errorf("username should be empty, got %q", username)
|
|
}
|
|
if isAdmin {
|
|
t.Error("isAdmin should be false when not authenticated")
|
|
}
|
|
}
|
|
|
|
func TestCheckProxyAuth_MissingSecret(t *testing.T) {
|
|
cfg := &config.Config{
|
|
ProxyAuthSecret: "correct-secret",
|
|
}
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
// No X-Proxy-Secret header
|
|
|
|
valid, username, isAdmin := CheckProxyAuth(cfg, req)
|
|
if valid {
|
|
t.Error("CheckProxyAuth should return false when secret header is missing")
|
|
}
|
|
if username != "" {
|
|
t.Errorf("username should be empty, got %q", username)
|
|
}
|
|
if isAdmin {
|
|
t.Error("isAdmin should be false when not authenticated")
|
|
}
|
|
}
|
|
|
|
func TestCheckProxyAuth_ValidSecretNoUserHeader(t *testing.T) {
|
|
cfg := &config.Config{
|
|
ProxyAuthSecret: "correct-secret",
|
|
// No ProxyAuthUserHeader configured
|
|
}
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Header.Set("X-Proxy-Secret", "correct-secret")
|
|
|
|
valid, username, isAdmin := CheckProxyAuth(cfg, req)
|
|
if !valid {
|
|
t.Error("CheckProxyAuth should return true for valid secret")
|
|
}
|
|
if username != "" {
|
|
t.Errorf("username should be empty when user header not configured, got %q", username)
|
|
}
|
|
if !isAdmin {
|
|
t.Error("isAdmin should be true by default when no role checking")
|
|
}
|
|
}
|
|
|
|
func TestCheckProxyAuth_MissingUserHeader(t *testing.T) {
|
|
cfg := &config.Config{
|
|
ProxyAuthSecret: "correct-secret",
|
|
ProxyAuthUserHeader: "X-Remote-User",
|
|
}
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Header.Set("X-Proxy-Secret", "correct-secret")
|
|
// Missing X-Remote-User header
|
|
|
|
valid, username, isAdmin := CheckProxyAuth(cfg, req)
|
|
if valid {
|
|
t.Error("CheckProxyAuth should return false when user header is missing")
|
|
}
|
|
if username != "" {
|
|
t.Errorf("username should be empty, got %q", username)
|
|
}
|
|
if isAdmin {
|
|
t.Error("isAdmin should be false when not authenticated")
|
|
}
|
|
}
|
|
|
|
func TestCheckProxyAuth_ValidWithUsername(t *testing.T) {
|
|
cfg := &config.Config{
|
|
ProxyAuthSecret: "correct-secret",
|
|
ProxyAuthUserHeader: "X-Remote-User",
|
|
}
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Header.Set("X-Proxy-Secret", "correct-secret")
|
|
req.Header.Set("X-Remote-User", "testuser")
|
|
|
|
valid, username, isAdmin := CheckProxyAuth(cfg, req)
|
|
if !valid {
|
|
t.Error("CheckProxyAuth should return true for valid auth")
|
|
}
|
|
if username != "testuser" {
|
|
t.Errorf("username should be 'testuser', got %q", username)
|
|
}
|
|
if !isAdmin {
|
|
t.Error("isAdmin should be true by default when no role checking")
|
|
}
|
|
}
|
|
|
|
func TestCheckProxyAuth_RoleCheckingEmptyRolesHeader(t *testing.T) {
|
|
cfg := &config.Config{
|
|
ProxyAuthSecret: "correct-secret",
|
|
ProxyAuthUserHeader: "X-Remote-User",
|
|
ProxyAuthRoleHeader: "X-Remote-Roles",
|
|
ProxyAuthAdminRole: "admin",
|
|
}
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Header.Set("X-Proxy-Secret", "correct-secret")
|
|
req.Header.Set("X-Remote-User", "testuser")
|
|
// No X-Remote-Roles header
|
|
|
|
valid, username, isAdmin := CheckProxyAuth(cfg, req)
|
|
if !valid {
|
|
t.Error("CheckProxyAuth should return true for valid auth")
|
|
}
|
|
if username != "testuser" {
|
|
t.Errorf("username should be 'testuser', got %q", username)
|
|
}
|
|
// When role header is empty, isAdmin stays true (default)
|
|
if !isAdmin {
|
|
t.Error("isAdmin should be true when roles header is empty")
|
|
}
|
|
}
|
|
|
|
func TestCheckProxyAuth_RoleCheckingWithAdminRole(t *testing.T) {
|
|
cfg := &config.Config{
|
|
ProxyAuthSecret: "correct-secret",
|
|
ProxyAuthUserHeader: "X-Remote-User",
|
|
ProxyAuthRoleHeader: "X-Remote-Roles",
|
|
ProxyAuthAdminRole: "admin",
|
|
}
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Header.Set("X-Proxy-Secret", "correct-secret")
|
|
req.Header.Set("X-Remote-User", "adminuser")
|
|
req.Header.Set("X-Remote-Roles", "user|admin|viewer")
|
|
|
|
valid, username, isAdmin := CheckProxyAuth(cfg, req)
|
|
if !valid {
|
|
t.Error("CheckProxyAuth should return true for valid auth")
|
|
}
|
|
if username != "adminuser" {
|
|
t.Errorf("username should be 'adminuser', got %q", username)
|
|
}
|
|
if !isAdmin {
|
|
t.Error("isAdmin should be true when user has admin role")
|
|
}
|
|
}
|
|
|
|
func TestCheckProxyAuth_RoleCheckingWithoutAdminRole(t *testing.T) {
|
|
cfg := &config.Config{
|
|
ProxyAuthSecret: "correct-secret",
|
|
ProxyAuthUserHeader: "X-Remote-User",
|
|
ProxyAuthRoleHeader: "X-Remote-Roles",
|
|
ProxyAuthAdminRole: "admin",
|
|
}
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Header.Set("X-Proxy-Secret", "correct-secret")
|
|
req.Header.Set("X-Remote-User", "regularuser")
|
|
req.Header.Set("X-Remote-Roles", "user|viewer")
|
|
|
|
valid, username, isAdmin := CheckProxyAuth(cfg, req)
|
|
if !valid {
|
|
t.Error("CheckProxyAuth should return true for valid auth")
|
|
}
|
|
if username != "regularuser" {
|
|
t.Errorf("username should be 'regularuser', got %q", username)
|
|
}
|
|
if isAdmin {
|
|
t.Error("isAdmin should be false when user lacks admin role")
|
|
}
|
|
}
|
|
|
|
func TestCheckProxyAuth_CustomRoleSeparator(t *testing.T) {
|
|
cfg := &config.Config{
|
|
ProxyAuthSecret: "correct-secret",
|
|
ProxyAuthUserHeader: "X-Remote-User",
|
|
ProxyAuthRoleHeader: "X-Remote-Roles",
|
|
ProxyAuthAdminRole: "administrator",
|
|
ProxyAuthRoleSeparator: ",",
|
|
}
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Header.Set("X-Proxy-Secret", "correct-secret")
|
|
req.Header.Set("X-Remote-User", "adminuser")
|
|
req.Header.Set("X-Remote-Roles", "user,administrator,viewer")
|
|
|
|
valid, _, isAdmin := CheckProxyAuth(cfg, req)
|
|
if !valid {
|
|
t.Error("CheckProxyAuth should return true for valid auth")
|
|
}
|
|
if !isAdmin {
|
|
t.Error("isAdmin should be true with custom separator")
|
|
}
|
|
}
|
|
|
|
func TestCheckProxyAuth_RoleWithWhitespace(t *testing.T) {
|
|
cfg := &config.Config{
|
|
ProxyAuthSecret: "correct-secret",
|
|
ProxyAuthUserHeader: "X-Remote-User",
|
|
ProxyAuthRoleHeader: "X-Remote-Roles",
|
|
ProxyAuthAdminRole: "admin",
|
|
}
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Header.Set("X-Proxy-Secret", "correct-secret")
|
|
req.Header.Set("X-Remote-User", "adminuser")
|
|
req.Header.Set("X-Remote-Roles", "user| admin |viewer") // Whitespace around admin
|
|
|
|
valid, _, isAdmin := CheckProxyAuth(cfg, req)
|
|
if !valid {
|
|
t.Error("CheckProxyAuth should return true for valid auth")
|
|
}
|
|
if !isAdmin {
|
|
t.Error("isAdmin should be true when role matches after trimming whitespace")
|
|
}
|
|
}
|
|
|
|
// CheckAuth tests
|
|
|
|
func TestCheckAuth_ProxyAuthSetsHeaders(t *testing.T) {
|
|
cfg := &config.Config{
|
|
ProxyAuthSecret: "secret123",
|
|
ProxyAuthUserHeader: "X-Remote-User",
|
|
}
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.Header.Set("X-Proxy-Secret", "secret123")
|
|
req.Header.Set("X-Remote-User", "proxyuser")
|
|
w := httptest.NewRecorder()
|
|
|
|
result := CheckAuth(cfg, w, req)
|
|
if !result {
|
|
t.Error("CheckAuth should return true for valid proxy auth")
|
|
}
|
|
if w.Header().Get("X-Authenticated-User") != "proxyuser" {
|
|
t.Errorf("X-Authenticated-User header = %q, want 'proxyuser'", w.Header().Get("X-Authenticated-User"))
|
|
}
|
|
if w.Header().Get("X-Auth-Method") != "proxy" {
|
|
t.Errorf("X-Auth-Method header = %q, want 'proxy'", w.Header().Get("X-Auth-Method"))
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_ProxyAuthNoUsernameHeader(t *testing.T) {
|
|
cfg := &config.Config{
|
|
ProxyAuthSecret: "secret123",
|
|
// No ProxyAuthUserHeader configured
|
|
}
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.Header.Set("X-Proxy-Secret", "secret123")
|
|
w := httptest.NewRecorder()
|
|
|
|
result := CheckAuth(cfg, w, req)
|
|
if !result {
|
|
t.Error("CheckAuth should return true for valid proxy auth without user header")
|
|
}
|
|
// X-Authenticated-User should not be set when username is empty
|
|
if w.Header().Get("X-Authenticated-User") != "" {
|
|
t.Errorf("X-Authenticated-User should be empty, got %q", w.Header().Get("X-Authenticated-User"))
|
|
}
|
|
if w.Header().Get("X-Auth-Method") != "proxy" {
|
|
t.Errorf("X-Auth-Method header = %q, want 'proxy'", w.Header().Get("X-Auth-Method"))
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_OIDCEnabledWithoutCredentials(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
// No AuthUser, AuthPass, or APITokens
|
|
}
|
|
setSSOAuthSnapshot(cfg, &config.SSOConfig{
|
|
Providers: []config.SSOProvider{
|
|
{
|
|
ID: "test-oidc",
|
|
Name: "Test OIDC",
|
|
Type: config.SSOProviderTypeOIDC,
|
|
Enabled: true,
|
|
OIDC: &config.OIDCProviderConfig{
|
|
IssuerURL: "https://issuer.example.com",
|
|
ClientID: "client-id",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
result := CheckAuth(cfg, w, req)
|
|
// Should return false - OIDC enabled requires authentication
|
|
if result {
|
|
t.Error("CheckAuth should return false when OIDC is enabled without credentials")
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_OIDCSessionValid(t *testing.T) {
|
|
dir := t.TempDir()
|
|
InitSessionStore(dir)
|
|
|
|
// Create a session and track it for a user
|
|
store := GetSessionStore()
|
|
sessionToken := generateSessionToken()
|
|
store.CreateOIDCSession(sessionToken, 24*time.Hour, "test-agent", "127.0.0.1", "oidcuser", &OIDCTokenInfo{
|
|
Issuer: "https://issuer.example.com",
|
|
ClientID: "client-id",
|
|
})
|
|
|
|
cfg := &config.Config{
|
|
DataPath: dir,
|
|
ConfigPath: dir,
|
|
}
|
|
setSSOAuthSnapshot(cfg, &config.SSOConfig{
|
|
Providers: []config.SSOProvider{
|
|
{
|
|
ID: "test-oidc",
|
|
Name: "Test OIDC",
|
|
Type: config.SSOProviderTypeOIDC,
|
|
Enabled: true,
|
|
OIDC: &config.OIDCProviderConfig{
|
|
IssuerURL: "https://issuer.example.com",
|
|
ClientID: "client-id",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.AddCookie(&http.Cookie{
|
|
Name: "pulse_session",
|
|
Value: sessionToken,
|
|
})
|
|
w := httptest.NewRecorder()
|
|
|
|
result := CheckAuth(cfg, w, req)
|
|
if !result {
|
|
t.Error("CheckAuth should return true for valid OIDC session")
|
|
}
|
|
if w.Header().Get("X-Authenticated-User") != "oidcuser" {
|
|
t.Errorf("X-Authenticated-User = %q, want 'oidcuser'", w.Header().Get("X-Authenticated-User"))
|
|
}
|
|
if w.Header().Get("X-Auth-Method") != "oidc" {
|
|
t.Errorf("X-Auth-Method = %q, want 'oidc'", w.Header().Get("X-Auth-Method"))
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_OIDCSessionInvalid(t *testing.T) {
|
|
dir := t.TempDir()
|
|
InitSessionStore(dir)
|
|
|
|
cfg := &config.Config{
|
|
DataPath: dir,
|
|
ConfigPath: dir,
|
|
}
|
|
setSSOAuthSnapshot(cfg, &config.SSOConfig{
|
|
Providers: []config.SSOProvider{
|
|
{
|
|
ID: "test-oidc",
|
|
Name: "Test OIDC",
|
|
Type: config.SSOProviderTypeOIDC,
|
|
Enabled: true,
|
|
OIDC: &config.OIDCProviderConfig{
|
|
IssuerURL: "https://issuer.example.com",
|
|
ClientID: "client-id",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.AddCookie(&http.Cookie{
|
|
Name: "pulse_session",
|
|
Value: "invalid-session-token",
|
|
})
|
|
w := httptest.NewRecorder()
|
|
|
|
result := CheckAuth(cfg, w, req)
|
|
// Should return false - invalid session
|
|
if result {
|
|
t.Error("CheckAuth should return false for invalid OIDC session")
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_SessionCookieValid(t *testing.T) {
|
|
dir := t.TempDir()
|
|
InitSessionStore(dir)
|
|
|
|
// Create a session
|
|
store := GetSessionStore()
|
|
sessionToken := generateSessionToken()
|
|
store.CreateSession(sessionToken, 24*time.Hour, "test-agent", "127.0.0.1", "testuser")
|
|
|
|
cfg := &config.Config{
|
|
AuthUser: "testuser",
|
|
AuthPass: "$2a$10$dummy", // Has auth configured but using session
|
|
}
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.AddCookie(&http.Cookie{
|
|
Name: "pulse_session",
|
|
Value: sessionToken,
|
|
})
|
|
w := httptest.NewRecorder()
|
|
|
|
result := CheckAuth(cfg, w, req)
|
|
if !result {
|
|
t.Error("CheckAuth should return true for valid session cookie")
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_SessionCookieExpired(t *testing.T) {
|
|
dir := t.TempDir()
|
|
InitSessionStore(dir)
|
|
|
|
// Create and expire a session
|
|
store := GetSessionStore()
|
|
sessionToken := generateSessionToken()
|
|
store.CreateSession(sessionToken, 24*time.Hour, "test-agent", "127.0.0.1", "testuser")
|
|
|
|
// Manually expire the session
|
|
store.mu.Lock()
|
|
hash := sessionHash(sessionToken)
|
|
if session, exists := store.sessions[hash]; exists {
|
|
session.ExpiresAt = time.Now().Add(-1 * time.Hour)
|
|
store.sessions[hash] = session
|
|
}
|
|
store.mu.Unlock()
|
|
|
|
cfg := &config.Config{
|
|
AuthUser: "testuser",
|
|
AuthPass: "$2a$10$dummy",
|
|
}
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.AddCookie(&http.Cookie{
|
|
Name: "pulse_session",
|
|
Value: sessionToken,
|
|
})
|
|
w := httptest.NewRecorder()
|
|
|
|
result := CheckAuth(cfg, w, req)
|
|
// Should return false - expired session, no valid basic auth
|
|
if result {
|
|
t.Error("CheckAuth should return false for expired session with invalid basic auth")
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_NoSessionCookie(t *testing.T) {
|
|
cfg := &config.Config{
|
|
AuthUser: "testuser",
|
|
AuthPass: "$2a$10$dummy",
|
|
}
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
// No session cookie
|
|
w := httptest.NewRecorder()
|
|
|
|
result := CheckAuth(cfg, w, req)
|
|
// Should return false - no session and no valid auth header
|
|
if result {
|
|
t.Error("CheckAuth should return false without session or valid auth")
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_ValidSessionWithSlidingExpiration(t *testing.T) {
|
|
dir := t.TempDir()
|
|
InitSessionStore(dir)
|
|
|
|
store := GetSessionStore()
|
|
sessionToken := generateSessionToken()
|
|
store.CreateSession(sessionToken, 24*time.Hour, "test-agent", "127.0.0.1", "testuser")
|
|
|
|
cfg := &config.Config{
|
|
// No auth configured, so session alone should work
|
|
}
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.AddCookie(&http.Cookie{
|
|
Name: "pulse_session",
|
|
Value: sessionToken,
|
|
})
|
|
w := httptest.NewRecorder()
|
|
|
|
result := CheckAuth(cfg, w, req)
|
|
if !result {
|
|
t.Error("CheckAuth should return true for valid session with sliding expiration")
|
|
}
|
|
if got := w.Header().Get("X-Authenticated-User"); got != "testuser" {
|
|
t.Fatalf("X-Authenticated-User = %q, want testuser", got)
|
|
}
|
|
if got := w.Header().Get("X-Auth-Method"); got != "session" {
|
|
t.Fatalf("X-Auth-Method = %q, want session", got)
|
|
}
|
|
}
|
|
|
|
func TestReadSessionCookie_SecureRequestRequiresHostPrefix(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "https://pulse.example.test/api/test", nil)
|
|
req.TLS = &tls.ConnectionState{}
|
|
req.AddCookie(&http.Cookie{
|
|
Name: cookieNameSession,
|
|
Value: "legacy-session",
|
|
})
|
|
|
|
if _, err := readSessionCookie(req); err == nil {
|
|
t.Fatalf("expected secure request to reject legacy session cookie")
|
|
}
|
|
}
|
|
|
|
func TestReadSessionCookie_SecureRequestAcceptsHostPrefix(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "https://pulse.example.test/api/test", nil)
|
|
req.TLS = &tls.ConnectionState{}
|
|
req.AddCookie(&http.Cookie{
|
|
Name: cookieNameSessionSecure,
|
|
Value: "secure-session",
|
|
})
|
|
|
|
cookie, err := readSessionCookie(req)
|
|
if err != nil {
|
|
t.Fatalf("expected secure request to accept __Host- session cookie: %v", err)
|
|
}
|
|
if cookie.Value != "secure-session" {
|
|
t.Fatalf("unexpected cookie value %q", cookie.Value)
|
|
}
|
|
}
|
|
|
|
func TestValidateFreshBrowserSession_AllowsRecentBoundSession(t *testing.T) {
|
|
dir := t.TempDir()
|
|
InitSessionStore(dir)
|
|
|
|
sessionToken := generateSessionToken()
|
|
GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "alice")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/orgs/acme/members", nil)
|
|
req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: sessionToken})
|
|
|
|
if authErr := validateFreshBrowserSession(req, "alice", "transfer ownership", privilegedBrowserSessionMaxAge); authErr != nil {
|
|
t.Fatalf("expected recent session to pass, got %#v", authErr)
|
|
}
|
|
}
|
|
|
|
func TestValidateFreshBrowserSession_RejectsStaleSession(t *testing.T) {
|
|
dir := t.TempDir()
|
|
InitSessionStore(dir)
|
|
|
|
sessionToken := generateSessionToken()
|
|
store := GetSessionStore()
|
|
store.CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "alice")
|
|
|
|
store.mu.Lock()
|
|
store.sessions[sessionHash(sessionToken)].CreatedAt = time.Now().Add(-privilegedBrowserSessionMaxAge - time.Minute)
|
|
store.mu.Unlock()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/orgs/acme/members", nil)
|
|
req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: sessionToken})
|
|
|
|
authErr := validateFreshBrowserSession(req, "alice", "transfer ownership", privilegedBrowserSessionMaxAge)
|
|
if authErr == nil {
|
|
t.Fatal("expected stale session to be rejected")
|
|
}
|
|
if authErr.Status != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want %d", authErr.Status, http.StatusUnauthorized)
|
|
}
|
|
if authErr.Code != "fresh_session_required" {
|
|
t.Fatalf("code = %q, want fresh_session_required", authErr.Code)
|
|
}
|
|
}
|
|
|
|
func TestValidateFreshBrowserSession_RejectsUsernameMismatch(t *testing.T) {
|
|
dir := t.TempDir()
|
|
InitSessionStore(dir)
|
|
|
|
sessionToken := generateSessionToken()
|
|
GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "alice")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/orgs/acme/members", nil)
|
|
req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: sessionToken})
|
|
|
|
authErr := validateFreshBrowserSession(req, "bob", "transfer ownership", privilegedBrowserSessionMaxAge)
|
|
if authErr == nil {
|
|
t.Fatal("expected mismatched session identity to be rejected")
|
|
}
|
|
if authErr.Code != "fresh_session_required" {
|
|
t.Fatalf("code = %q, want fresh_session_required", authErr.Code)
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_NoAuthConfigured(t *testing.T) {
|
|
cfg := &config.Config{
|
|
// No auth configured at all
|
|
}
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.RemoteAddr = "127.0.0.1:12345"
|
|
w := httptest.NewRecorder()
|
|
|
|
result := CheckAuth(cfg, w, req)
|
|
// Should return true for direct loopback when no auth is configured
|
|
if !result {
|
|
t.Error("CheckAuth should return true for loopback when no auth is configured")
|
|
}
|
|
}
|
|
|
|
func TestCheckAuth_NoAuthConfiguredRejectsRemote(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.RemoteAddr = "198.51.100.10:12345"
|
|
w := httptest.NewRecorder()
|
|
|
|
if CheckAuth(cfg, w, req) {
|
|
t.Fatal("CheckAuth should reject remote access before auth is configured")
|
|
}
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected explicit unauthorized response, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "Authentication required") {
|
|
t.Fatalf("expected authentication-required body, got %q", w.Body.String())
|
|
}
|
|
}
|