Files
grendervill af03cfb063 feat(security): проверка Origin, строгий CSP и лимиты загрузок
- middleware OriginCheck: state-changing запросы с чужого Origin отклоняются
  (AGENT.md 9.7); запросы без Origin пропускаются — cookie уже SameSite=Lax
- CSP перечисляет директивы явно (script-src/worker-src/manifest-src 'self',
  style-src с inline для React, img-src с внешними https для аватаров вебхуков,
  connect-src с доменом файлов и LiveKit); добавлены Permissions-Policy,
  Cross-Origin-Opener-Policy и X-Permitted-Cross-Domain-Policies
- лимиты по AGENT.md 8.6: загрузки 10/мин и 100/сутки (вложения и аватары),
  реакции 20/мин; администратор инстанса лимиты обходит
- step-up: смена прав роли (в теле PATCH) и удаление сервера (перед вызовом
  /auth/step-up) требуют свежего подтверждения личности
- тесты: Origin (свой/чужой/GET), состав CSP, лимит реакций, step-up на права
  роли; исправлен вызов NewRateLimiter (второй аргумент — burst, не окно)
2026-09-22 00:40:10 +03:00

246 lines
8.0 KiB
Go

package httpx
import (
"context"
"encoding/json"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestWriteErrorUsesEnvelope(t *testing.T) {
rec := httptest.NewRecorder()
WriteError(rec, NewError(CodeNotFound, "no such resource"))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
t.Errorf("Content-Type = %q", got)
}
var body struct {
Error Error `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if body.Error.Code != CodeNotFound || body.Error.Message == "" {
t.Errorf("unexpected error payload: %+v", body.Error)
}
}
func TestHTTPStatusMapping(t *testing.T) {
cases := map[ErrorCode]int{
CodeInternalError: http.StatusInternalServerError,
CodeNotFound: http.StatusNotFound,
CodeBadRequest: http.StatusBadRequest,
CodeRateLimited: http.StatusTooManyRequests,
CodeNotReady: http.StatusServiceUnavailable,
ErrorCode("unknown.code"): http.StatusInternalServerError,
}
for code, want := range cases {
if got := NewError(code, "").HTTPStatus(); got != want {
t.Errorf("status for %q = %d, want %d", code, got, want)
}
}
}
func TestRequestIDIsGeneratedAndPropagated(t *testing.T) {
var seen string
handler := RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
seen = RequestIDFrom(r.Context())
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil))
if seen == "" {
t.Fatal("request id is empty inside the handler")
}
if rec.Header().Get("X-Request-Id") != seen {
t.Errorf("header %q does not match context value %q", rec.Header().Get("X-Request-Id"), seen)
}
}
func TestRequestIDIsReusedFromHeader(t *testing.T) {
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
req.Header.Set("X-Request-Id", "client-supplied-id")
rec := httptest.NewRecorder()
var seen string
RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
seen = RequestIDFrom(r.Context())
})).ServeHTTP(rec, req)
if seen != "client-supplied-id" {
t.Errorf("request id = %q, want client-supplied-id", seen)
}
}
func TestRecovererTurnsPanicIntoError(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
handler := Recoverer(logger)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
panic("boom")
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
if !json.Valid(rec.Body.Bytes()) {
t.Errorf("panic response is not json: %s", rec.Body.String())
}
}
func TestSecurityHeaders(t *testing.T) {
rec := httptest.NewRecorder()
SecurityHeaders(SecurityOptions{FilesDomain: "files.example.com"})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil))
headers := rec.Header()
for _, name := range []string{"Content-Security-Policy", "Referrer-Policy", "X-Content-Type-Options", "X-Frame-Options", "Cross-Origin-Resource-Policy"} {
if headers.Get(name) == "" {
t.Errorf("header %s is missing", name)
}
}
if headers.Get("Strict-Transport-Security") != "" {
t.Error("HSTS must not be sent over plain http")
}
}
func TestSecurityHeadersAddHSTSBehindProxy(t *testing.T) {
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-Proto", "https")
rec := httptest.NewRecorder()
SecurityHeaders(SecurityOptions{FilesDomain: "files.example.com"})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rec, req)
if rec.Header().Get("Strict-Transport-Security") == "" {
t.Error("HSTS header is missing for a proxied https request")
}
}
func TestJSONBodyLimitRejectsOversizedBody(t *testing.T) {
handler := JSONBodyLimit(16)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 64)
if _, err := r.Body.Read(buf); err != nil {
WriteErrorStatus(w, http.StatusRequestEntityTooLarge, CodeBadRequest, "too large")
return
}
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/", strings.NewReader(strings.Repeat("a", 128)))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413", rec.Code)
}
}
func TestClientIPPrefersForwardedForFromTrustedProxy(t *testing.T) {
_, network, err := net.ParseCIDR("10.0.0.0/8")
if err != nil {
t.Fatalf("parse cidr: %v", err)
}
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.5:1234"
// Единственный доверенный прокси (Caddy) дописывает клиента в конец цепочки.
req.Header.Set("X-Forwarded-For", "203.0.113.9, 198.51.100.7")
if got := ClientIP(req, []*net.IPNet{network}); got != "198.51.100.7" {
t.Errorf("ClientIP() = %q, want the last hop appended by the trusted proxy", got)
}
}
func TestClientIPIgnoresForwardedForFromUntrustedPeer(t *testing.T) {
_, network, err := net.ParseCIDR("10.0.0.0/8")
if err != nil {
t.Fatalf("parse cidr: %v", err)
}
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
req.RemoteAddr = "198.51.100.7:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.9")
if got := ClientIP(req, []*net.IPNet{network}); got != "198.51.100.7" {
t.Errorf("ClientIP() = %q, want the peer address", got)
}
}
func TestRateLimiterAllowsBurstThenBlocks(t *testing.T) {
limiter := NewRateLimiter(5, 5)
current := time.Unix(0, 0)
limiter.SetClock(func() time.Time { return current })
for i := range 5 {
if allowed, _ := limiter.Allow("1.2.3.4"); !allowed {
t.Fatalf("request %d must be allowed", i+1)
}
}
allowed, retryAfter := limiter.Allow("1.2.3.4")
if allowed {
t.Fatal("sixth request must be rate limited")
}
if retryAfter <= 0 {
t.Fatal("retry_after must be positive")
}
// Другой IP лимитом не затронут.
if allowed, _ := limiter.Allow("5.6.7.8"); !allowed {
t.Fatal("separate key must have its own bucket")
}
// Через 12 секунд на скорости 5/мин накапливается один токен.
current = current.Add(12 * time.Second)
if allowed, _ := limiter.Allow("1.2.3.4"); !allowed {
t.Fatal("bucket must refill over time")
}
}
func TestRateLimitMiddlewareReturns429(t *testing.T) {
limiter := NewRateLimiter(1, 1)
handler := limiter.Middleware(func(*http.Request) string { return "client" })(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
first := httptest.NewRecorder()
handler.ServeHTTP(first, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil))
if first.Code != http.StatusOK {
t.Fatalf("first request = %d, want 200", first.Code)
}
second := httptest.NewRecorder()
handler.ServeHTTP(second, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil))
if second.Code != http.StatusTooManyRequests {
t.Fatalf("second request = %d, want 429", second.Code)
}
if second.Header().Get("Retry-After") == "" {
t.Error("Retry-After header is missing")
}
var payload struct {
Error struct {
Code string `json:"code"`
RetryAfterMS int64 `json:"retry_after_ms"`
} `json:"error"`
}
if err := json.Unmarshal(second.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode body: %v", err)
}
if payload.Error.Code != "rate_limited" || payload.Error.RetryAfterMS <= 0 {
t.Fatalf("unexpected payload: %s", second.Body.String())
}
}