check / check (push) Failing after 1s
The http.Server now sets ReadHeaderTimeout (10s), which bounds the slow header dribble that ReadTimeout alone does not, and IdleTimeout (120s), which bounds keep-alive reuse. Server construction moved into a small helper so a test can assert the timeouts without binding a listener. POST / and POST /generate bodies are capped at 1 MiB and an oversized body returns 413. What a reader would trip over: the CSRF library reads its token from the form and swallows a parse error, so a cap applied only inside it would surface as 403. The body limit therefore parses the form under the cap before the CSRF check; the parsed form is reused afterwards. A test covers an oversized body that carries a valid token. Judgement call: WriteTimeout stays at 60s; it also bounds how long a large image may take to send over a slow link. Model: opus-4-8 (implementation, review); fable-5-1 (landing message)
178 lines
4.8 KiB
Go
178 lines
4.8 KiB
Go
package handlers
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"sneak.berlin/go/pixa/internal/config"
|
|
"sneak.berlin/go/pixa/internal/encurl"
|
|
"sneak.berlin/go/pixa/internal/session"
|
|
)
|
|
|
|
// Form field names and a throwaway source image URL for the body-limit
|
|
// tests.
|
|
const (
|
|
sourceURLField = "url"
|
|
testSourceURL = "https://example.com/a.jpg"
|
|
)
|
|
|
|
// newBodyLimitTestRouter mirrors the production wiring for the form POST
|
|
// routes (see server.SetupRoutes): LimitBody sits in front of the CSRF
|
|
// middleware, which sits in front of the handlers. maxBytes is the body
|
|
// cap under test, so a test can trip the limit with a small body.
|
|
func newBodyLimitTestRouter(
|
|
t *testing.T, maxBytes int64,
|
|
) (*Handlers, http.Handler) {
|
|
t.Helper()
|
|
|
|
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
|
|
|
sessMgr, err := session.NewManager(testSigningKey)
|
|
if err != nil {
|
|
t.Fatalf("session.NewManager() error = %v", err)
|
|
}
|
|
|
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
|
if err != nil {
|
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
|
}
|
|
|
|
protect, err := newCSRFProtect(testSigningKey, cfg.Debug)
|
|
if err != nil {
|
|
t.Fatalf("newCSRFProtect() error = %v", err)
|
|
}
|
|
|
|
h := &Handlers{
|
|
log: slog.New(slog.DiscardHandler),
|
|
config: cfg,
|
|
sessMgr: sessMgr,
|
|
encGen: encGen,
|
|
csrfProtect: protect,
|
|
}
|
|
|
|
r := chi.NewRouter()
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(h.LimitBody(maxBytes))
|
|
r.Use(h.CSRF())
|
|
r.Get("/", h.HandleRoot())
|
|
r.Post("/", h.HandleRoot())
|
|
r.Post("/generate", h.HandleGenerateURL())
|
|
})
|
|
|
|
return h, r
|
|
}
|
|
|
|
// TestOversizedLoginPostRejectedBeforeCSRF is the core regression: an
|
|
// oversized POST / carrying an otherwise valid CSRF cookie and token must
|
|
// be rejected with 413. If the body limit ran after CSRF, the truncated
|
|
// body would read as a missing token and return 403; if it ran after the
|
|
// handler, a valid token would return 303. Getting 413 proves the limit
|
|
// fires before CSRF parses the form.
|
|
func TestOversizedLoginPostRejectedBeforeCSRF(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, srv := newBodyLimitTestRouter(t, 16)
|
|
|
|
cookies, token := csrfCredentials(t, srv, nil)
|
|
|
|
rec := postForm(srv, "/", cookies, url.Values{
|
|
loginKeyField: {testSigningKey},
|
|
csrfTokenField: {token},
|
|
})
|
|
|
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Errorf("oversized POST / status = %d, want %d",
|
|
rec.Code, http.StatusRequestEntityTooLarge)
|
|
}
|
|
}
|
|
|
|
// TestOversizedGeneratePostRejectedBeforeCSRF is the same regression for
|
|
// POST /generate, which also parses a form behind CSRF.
|
|
func TestOversizedGeneratePostRejectedBeforeCSRF(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h, srv := newBodyLimitTestRouter(t, 16)
|
|
|
|
sessionCookie := newSessionCookie(t, h)
|
|
|
|
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
|
cookies = append(cookies, sessionCookie)
|
|
|
|
rec := postForm(srv, "/generate", cookies, url.Values{
|
|
sourceURLField: {testSourceURL},
|
|
csrfTokenField: {token},
|
|
})
|
|
|
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Errorf("oversized POST /generate status = %d, want %d",
|
|
rec.Code, http.StatusRequestEntityTooLarge)
|
|
}
|
|
}
|
|
|
|
// TestWithinLimitLoginPostSucceeds verifies the limit does not disturb a
|
|
// normal request: under the production cap, a valid login still parses and
|
|
// establishes a session (303). This guards against the body limit
|
|
// consuming or corrupting the form the CSRF check and handler depend on.
|
|
func TestWithinLimitLoginPostSucceeds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, srv := newBodyLimitTestRouter(t, MaxFormBytes)
|
|
|
|
cookies, token := csrfCredentials(t, srv, nil)
|
|
|
|
rec := postForm(srv, "/", cookies, url.Values{
|
|
loginKeyField: {testSigningKey},
|
|
csrfTokenField: {token},
|
|
})
|
|
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("within-limit POST / status = %d, want %d",
|
|
rec.Code, http.StatusSeeOther)
|
|
}
|
|
|
|
var authed bool
|
|
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name == session.CookieName && c.Value != "" {
|
|
authed = true
|
|
}
|
|
}
|
|
|
|
if !authed {
|
|
t.Error("within-limit valid login did not set a session cookie")
|
|
}
|
|
}
|
|
|
|
// TestWithinLimitGeneratePostSucceeds is the same non-regression check for
|
|
// POST /generate.
|
|
func TestWithinLimitGeneratePostSucceeds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h, srv := newBodyLimitTestRouter(t, MaxFormBytes)
|
|
|
|
sessionCookie := newSessionCookie(t, h)
|
|
|
|
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
|
cookies = append(cookies, sessionCookie)
|
|
|
|
rec := postForm(srv, "/generate", cookies, url.Values{
|
|
sourceURLField: {testSourceURL},
|
|
"format": {"jpeg"},
|
|
csrfTokenField: {token},
|
|
})
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("within-limit POST /generate status = %d, want %d",
|
|
rec.Code, http.StatusOK)
|
|
}
|
|
|
|
if !strings.Contains(rec.Body.String(), "/v1/e/") {
|
|
t.Error("within-limit generate response did not contain a generated URL")
|
|
}
|
|
}
|