456 lines
13 KiB
Go
456 lines
13 KiB
Go
package restapi
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/gateway/internal/config"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestPublicAntiAbuseRejectsOversizedBodies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
oversizedJSONBody := `{"email":"` + strings.Repeat("a", 8200) + `@example.com"}`
|
|
oversizedConfirmJSONBody := `{"challenge_id":"` + strings.Repeat("c", 8300) + `","code":"123456","client_public_key":"key","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
target string
|
|
body string
|
|
wantClass PublicRouteClass
|
|
}{
|
|
{
|
|
name: "send email",
|
|
method: http.MethodPost,
|
|
target: "/api/v1/public/auth/send-email-code",
|
|
body: oversizedJSONBody,
|
|
wantClass: PublicRouteClassPublicAuth,
|
|
},
|
|
{
|
|
name: "confirm email",
|
|
method: http.MethodPost,
|
|
target: "/api/v1/public/auth/confirm-email-code",
|
|
body: oversizedConfirmJSONBody,
|
|
wantClass: PublicRouteClassPublicAuth,
|
|
},
|
|
{
|
|
name: "healthz body",
|
|
method: http.MethodGet,
|
|
target: "/healthz",
|
|
body: `x`,
|
|
wantClass: PublicRouteClassPublicMisc,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
observer := &recordingPublicRequestObserver{}
|
|
authService := &recordingAuthServiceClient{
|
|
sendEmailCodeResult: SendEmailCodeResult{ChallengeID: "challenge-123"},
|
|
confirmEmailCodeResult: ConfirmEmailCodeResult{
|
|
DeviceSessionID: "device-session-123",
|
|
},
|
|
}
|
|
handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{
|
|
AuthService: authService,
|
|
Observer: observer,
|
|
})
|
|
|
|
req := httptest.NewRequest(tt.method, tt.target, strings.NewReader(tt.body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(recorder, req)
|
|
|
|
assert.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code)
|
|
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
|
assert.Equal(t, `{"error":{"code":"request_too_large","message":"request body exceeds the configured limit"}}`, recorder.Body.String())
|
|
assert.Equal(t, 0, authService.sendEmailCodeCalls)
|
|
assert.Equal(t, 0, authService.confirmEmailCodeCalls)
|
|
assert.Equal(t, []malformedObservation{{
|
|
class: tt.wantClass,
|
|
reason: PublicMalformedRequestReasonOversizedBody,
|
|
}}, observer.snapshot())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPublicAntiAbuseRejectsInvalidMethodsForBrowserShapes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newPublicHandler(ServerDependencies{})
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
target string
|
|
accept string
|
|
wantAllow string
|
|
}{
|
|
{
|
|
name: "asset path",
|
|
method: http.MethodPost,
|
|
target: "/assets/app.js",
|
|
wantAllow: "GET, HEAD",
|
|
},
|
|
{
|
|
name: "bootstrap request",
|
|
method: http.MethodPost,
|
|
target: "/",
|
|
accept: "text/html",
|
|
wantAllow: "GET, HEAD",
|
|
},
|
|
{
|
|
name: "head probe rejected",
|
|
method: http.MethodHead,
|
|
target: "/healthz",
|
|
wantAllow: http.MethodGet,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := httptest.NewRequest(tt.method, tt.target, nil)
|
|
if tt.accept != "" {
|
|
req.Header.Set("Accept", tt.accept)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(recorder, req)
|
|
|
|
assert.Equal(t, http.StatusMethodNotAllowed, recorder.Code)
|
|
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
|
assert.Equal(t, tt.wantAllow, recorder.Header().Get("Allow"))
|
|
assert.Equal(t, `{"error":{"code":"method_not_allowed","message":"request method is not allowed for this route"}}`, recorder.Body.String())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPublicAntiAbuseBrowserClassBucketsStayIsolatedFromPublicAuth(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
burstRequest *http.Request
|
|
}{
|
|
{
|
|
name: "browser asset",
|
|
burstRequest: httptest.NewRequest(http.MethodGet, "/assets/app.js", nil),
|
|
},
|
|
{
|
|
name: "browser bootstrap",
|
|
burstRequest: func() *http.Request {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Accept", "text/html")
|
|
return req
|
|
}(),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := config.DefaultPublicHTTPConfig()
|
|
cfg.AntiAbuse.BrowserAsset.RateLimit = config.PublicRateLimitConfig{
|
|
Requests: 1,
|
|
Window: time.Hour,
|
|
Burst: 1,
|
|
}
|
|
cfg.AntiAbuse.BrowserBootstrap.RateLimit = config.PublicRateLimitConfig{
|
|
Requests: 1,
|
|
Window: time.Hour,
|
|
Burst: 1,
|
|
}
|
|
cfg.AntiAbuse.PublicAuth.RateLimit = config.PublicRateLimitConfig{
|
|
Requests: 100,
|
|
Window: time.Hour,
|
|
Burst: 100,
|
|
}
|
|
authService := &recordingAuthServiceClient{
|
|
sendEmailCodeResult: SendEmailCodeResult{
|
|
ChallengeID: "challenge-123",
|
|
},
|
|
}
|
|
handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})
|
|
|
|
tt.burstRequest.RemoteAddr = "192.0.2.10:1234"
|
|
|
|
firstBurst := httptest.NewRecorder()
|
|
handler.ServeHTTP(firstBurst, tt.burstRequest.Clone(tt.burstRequest.Context()))
|
|
|
|
secondBurst := httptest.NewRecorder()
|
|
handler.ServeHTTP(secondBurst, tt.burstRequest.Clone(tt.burstRequest.Context()))
|
|
|
|
authReq := httptest.NewRequest(http.MethodPost, "/api/v1/public/auth/send-email-code", strings.NewReader(`{"email":"pilot@example.com"}`))
|
|
authReq.Header.Set("Content-Type", "application/json")
|
|
authReq.RemoteAddr = "192.0.2.10:1234"
|
|
authResp := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(authResp, authReq)
|
|
|
|
assert.Equal(t, http.StatusNotFound, firstBurst.Code)
|
|
assert.Equal(t, http.StatusTooManyRequests, secondBurst.Code)
|
|
assert.Equal(t, http.StatusOK, authResp.Code)
|
|
assert.Equal(t, `{"challenge_id":"challenge-123"}`, authResp.Body.String())
|
|
assert.Equal(t, 1, authService.sendEmailCodeCalls)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPublicAntiAbuseSendEmailIdentityThrottle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := config.DefaultPublicHTTPConfig()
|
|
cfg.AntiAbuse.PublicAuth.RateLimit = config.PublicRateLimitConfig{
|
|
Requests: 100,
|
|
Window: time.Hour,
|
|
Burst: 100,
|
|
}
|
|
cfg.AntiAbuse.SendEmailCodeIdentity.RateLimit = config.PublicRateLimitConfig{
|
|
Requests: 1,
|
|
Window: time.Hour,
|
|
Burst: 1,
|
|
}
|
|
|
|
authService := &recordingAuthServiceClient{
|
|
sendEmailCodeResult: SendEmailCodeResult{
|
|
ChallengeID: "challenge-123",
|
|
},
|
|
}
|
|
handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})
|
|
|
|
first := sendEmailCodeRequest(`{"email":"pilot@example.com"}`)
|
|
second := sendEmailCodeRequest(`{"email":"pilot@example.com"}`)
|
|
third := sendEmailCodeRequest(`{"email":"other@example.com"}`)
|
|
|
|
firstResp := httptest.NewRecorder()
|
|
handler.ServeHTTP(firstResp, first)
|
|
|
|
secondResp := httptest.NewRecorder()
|
|
handler.ServeHTTP(secondResp, second)
|
|
|
|
thirdResp := httptest.NewRecorder()
|
|
handler.ServeHTTP(thirdResp, third)
|
|
|
|
assert.Equal(t, http.StatusOK, firstResp.Code)
|
|
assert.Equal(t, http.StatusTooManyRequests, secondResp.Code)
|
|
assert.Equal(t, "3600", secondResp.Header().Get("Retry-After"))
|
|
assert.Equal(t, http.StatusOK, thirdResp.Code)
|
|
assert.Equal(t, 2, authService.sendEmailCodeCalls)
|
|
thirdInput := authService.sendEmailCodeInput
|
|
assert.Equal(t, "other@example.com", thirdInput.Email)
|
|
}
|
|
|
|
func TestPublicAntiAbuseConfirmEmailIdentityThrottle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := config.DefaultPublicHTTPConfig()
|
|
cfg.AntiAbuse.PublicAuth.RateLimit = config.PublicRateLimitConfig{
|
|
Requests: 100,
|
|
Window: time.Hour,
|
|
Burst: 100,
|
|
}
|
|
cfg.AntiAbuse.ConfirmEmailCodeIdentity.RateLimit = config.PublicRateLimitConfig{
|
|
Requests: 1,
|
|
Window: time.Hour,
|
|
Burst: 1,
|
|
}
|
|
|
|
authService := &recordingAuthServiceClient{
|
|
confirmEmailCodeResult: ConfirmEmailCodeResult{
|
|
DeviceSessionID: "device-session-123",
|
|
},
|
|
}
|
|
handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})
|
|
|
|
first := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`)
|
|
second := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`)
|
|
third := confirmEmailCodeRequest(`{"challenge_id":"challenge-456","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`)
|
|
|
|
firstResp := httptest.NewRecorder()
|
|
handler.ServeHTTP(firstResp, first)
|
|
|
|
secondResp := httptest.NewRecorder()
|
|
handler.ServeHTTP(secondResp, second)
|
|
|
|
thirdResp := httptest.NewRecorder()
|
|
handler.ServeHTTP(thirdResp, third)
|
|
|
|
assert.Equal(t, http.StatusOK, firstResp.Code)
|
|
assert.Equal(t, http.StatusTooManyRequests, secondResp.Code)
|
|
assert.Equal(t, "3600", secondResp.Header().Get("Retry-After"))
|
|
assert.Equal(t, http.StatusOK, thirdResp.Code)
|
|
assert.Equal(t, 2, authService.confirmEmailCodeCalls)
|
|
assert.Equal(t, "challenge-456", authService.confirmEmailCodeInput.ChallengeID)
|
|
}
|
|
|
|
func TestPublicAntiAbuseMalformedTelemetry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
wantReason PublicMalformedRequestReason
|
|
wantRecords int
|
|
}{
|
|
{
|
|
name: "empty body",
|
|
body: ``,
|
|
wantReason: PublicMalformedRequestReasonEmptyBody,
|
|
wantRecords: 1,
|
|
},
|
|
{
|
|
name: "malformed json",
|
|
body: `{"email":`,
|
|
wantReason: PublicMalformedRequestReasonMalformedJSON,
|
|
wantRecords: 1,
|
|
},
|
|
{
|
|
name: "invalid json value",
|
|
body: `{"email":123}`,
|
|
wantReason: PublicMalformedRequestReasonInvalidJSONValue,
|
|
wantRecords: 1,
|
|
},
|
|
{
|
|
name: "unknown field",
|
|
body: `{"email":"pilot@example.com","extra":"x"}`,
|
|
wantReason: PublicMalformedRequestReasonUnknownField,
|
|
wantRecords: 1,
|
|
},
|
|
{
|
|
name: "multiple objects",
|
|
body: `{"email":"pilot@example.com"}{"email":"pilot@example.com"}`,
|
|
wantReason: PublicMalformedRequestReasonMultipleJSONObjects,
|
|
wantRecords: 1,
|
|
},
|
|
{
|
|
name: "validation error does not count as malformed",
|
|
body: `{"email":"not-an-email"}`,
|
|
wantRecords: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
observer := &recordingPublicRequestObserver{}
|
|
authService := &recordingAuthServiceClient{}
|
|
handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{
|
|
AuthService: authService,
|
|
Observer: observer,
|
|
})
|
|
|
|
req := sendEmailCodeRequest(tt.body)
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(recorder, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
|
assert.Equal(t, tt.wantRecords, len(observer.snapshot()))
|
|
assert.Equal(t, 0, authService.sendEmailCodeCalls)
|
|
if tt.wantRecords == 1 {
|
|
assert.Equal(t, malformedObservation{
|
|
class: PublicRouteClassPublicAuth,
|
|
reason: tt.wantReason,
|
|
}, observer.snapshot()[0])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInMemoryPublicRequestLimiterCleansExpiredBuckets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Unix(1000, 0)
|
|
limiter := newInMemoryPublicRequestLimiter()
|
|
limiter.now = func() time.Time {
|
|
return now
|
|
}
|
|
limiter.cleanupInterval = time.Second
|
|
|
|
policy := config.PublicRateLimitConfig{
|
|
Requests: 1,
|
|
Window: time.Minute,
|
|
Burst: 1,
|
|
}
|
|
|
|
firstDecision := limiter.Reserve("bucket-1", policy)
|
|
secondDecision := limiter.Reserve("bucket-2", policy)
|
|
require.True(t, firstDecision.Allowed)
|
|
require.True(t, secondDecision.Allowed)
|
|
require.Len(t, limiter.entries, 2)
|
|
|
|
now = now.Add(3 * time.Minute)
|
|
|
|
thirdDecision := limiter.Reserve("bucket-3", policy)
|
|
require.True(t, thirdDecision.Allowed)
|
|
assert.Len(t, limiter.entries, 1)
|
|
_, exists := limiter.entries["bucket-3"]
|
|
assert.True(t, exists)
|
|
}
|
|
|
|
func sendEmailCodeRequest(body string) *http.Request {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/auth/send-email-code", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = "192.0.2.10:1234"
|
|
|
|
return req
|
|
}
|
|
|
|
func confirmEmailCodeRequest(body string) *http.Request {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/auth/confirm-email-code", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = "192.0.2.10:1234"
|
|
|
|
return req
|
|
}
|
|
|
|
type malformedObservation struct {
|
|
class PublicRouteClass
|
|
reason PublicMalformedRequestReason
|
|
}
|
|
|
|
type recordingPublicRequestObserver struct {
|
|
mu sync.Mutex
|
|
observations []malformedObservation
|
|
}
|
|
|
|
func (o *recordingPublicRequestObserver) RecordMalformedRequest(class PublicRouteClass, reason PublicMalformedRequestReason) {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
|
|
o.observations = append(o.observations, malformedObservation{
|
|
class: class,
|
|
reason: reason,
|
|
})
|
|
}
|
|
|
|
func (o *recordingPublicRequestObserver) snapshot() []malformedObservation {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
|
|
return append([]malformedObservation(nil), o.observations...)
|
|
}
|