Files
Pulse/internal/api/auth_query_token_test.go
rcourtman f60050a801 fix(security): restrict query-string token auth to WebSocket upgrades only
API tokens passed via ?token= query parameter were accepted on all HTTP
requests. This is a security concern because tokens in URLs can leak via
server logs, browser history, referrer headers, and proxy logs.

The query-string token path exists solely for WebSocket connections which
cannot set custom headers during the upgrade handshake. This change adds
an isWebSocketUpgrade check to all three query-string extraction sites
in CheckAuth and extractAndStoreAuthContext, rejecting ?token= on regular
HTTP requests while preserving WebSocket functionality.

No frontend impact — the kiosk flow stores the token in sessionStorage
then uses X-API-Token headers for all API calls.
2026-02-04 09:52:32 +00:00

113 lines
3.4 KiB
Go

package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestIsWebSocketUpgrade(t *testing.T) {
tests := []struct {
name string
upgrade string
expected bool
}{
{"lowercase websocket", "websocket", true},
{"uppercase WebSocket", "WebSocket", true},
{"mixed case WEBSOCKET", "WEBSOCKET", true},
{"empty header", "", false},
{"other value", "h2c", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if tt.upgrade != "" {
req.Header.Set("Upgrade", tt.upgrade)
}
if got := isWebSocketUpgrade(req); got != tt.expected {
t.Errorf("isWebSocketUpgrade() = %v, want %v", got, tt.expected)
}
})
}
}
func TestCheckAuth_QueryTokenRejectedWithoutWebSocketUpgrade(t *testing.T) {
// API-only mode: only API tokens configured, no user/password.
const rawToken = "test-api-only-query-token-12345"
record, err := config.NewAPITokenRecord(rawToken, "test", nil)
if err != nil {
t.Fatalf("create token record: %v", err)
}
cfg := &config.Config{
APITokens: []config.APITokenRecord{*record},
}
cfg.SortAPITokens()
// Regular HTTP request with query-string token should be rejected.
req := httptest.NewRequest(http.MethodGet, "/api/state?token="+rawToken, nil)
rr := httptest.NewRecorder()
if CheckAuth(cfg, rr, req) {
t.Fatal("expected CheckAuth to reject query-string token on regular HTTP request (API-only mode)")
}
// Same request with WebSocket Upgrade header should be accepted.
wsReq := httptest.NewRequest(http.MethodGet, "/api/state?token="+rawToken, nil)
wsReq.Header.Set("Upgrade", "websocket")
wsReq.Header.Set("Connection", "Upgrade")
wsRR := httptest.NewRecorder()
if !CheckAuth(cfg, wsRR, wsReq) {
t.Fatal("expected CheckAuth to accept query-string token on WebSocket upgrade (API-only mode)")
}
}
func TestCheckAuth_QueryTokenRejectedStandardMode(t *testing.T) {
// Standard mode: both password auth and API tokens configured.
const rawToken = "test-standard-query-token-12345"
record, err := config.NewAPITokenRecord(rawToken, "test", nil)
if err != nil {
t.Fatalf("create token record: %v", err)
}
cfg := &config.Config{
AuthUser: "admin",
AuthPass: "$2a$10$invalidhashfortesting1234567890123456789012",
APITokens: []config.APITokenRecord{*record},
}
cfg.SortAPITokens()
// Regular HTTP request with query-string token should be rejected.
req := httptest.NewRequest(http.MethodGet, "/api/state?token="+rawToken, nil)
rr := httptest.NewRecorder()
if CheckAuth(cfg, rr, req) {
t.Fatal("expected CheckAuth to reject query-string token on regular HTTP request (standard mode)")
}
// WebSocket upgrade with query-string token should be accepted.
wsReq := httptest.NewRequest(http.MethodGet, "/api/state?token="+rawToken, nil)
wsReq.Header.Set("Upgrade", "websocket")
wsReq.Header.Set("Connection", "Upgrade")
wsRR := httptest.NewRecorder()
if !CheckAuth(cfg, wsRR, wsReq) {
t.Fatal("expected CheckAuth to accept query-string token on WebSocket upgrade (standard mode)")
}
// Header-based token should still work for regular requests.
headerReq := httptest.NewRequest(http.MethodGet, "/api/state", nil)
headerReq.Header.Set("X-API-Token", rawToken)
headerRR := httptest.NewRecorder()
if !CheckAuth(cfg, headerRR, headerReq) {
t.Fatal("expected CheckAuth to accept header token on regular HTTP request")
}
}