fix: input validation gaps (#68)
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped

- enforce server-side body cap via http.MaxBytesReader: oversized JSON
  bodies are rejected with 413 instead of being fully decoded first
- negative burn_after_reads rejected with 400 (zero still = default 1)
- limit=0 explicitly maps to default page size; shared parseLimit clamp
  for /api/public and /api/mine (huge/non-numeric values too)
- negative/non-numeric offset clamped to 0 via parseOffset (was
  pass-through)
- regression tests in issue68_validation_test.go

expires_at/expires_in validation intentionally excluded: covered by #60.
This commit is contained in:
agent
2026-09-09 09:21:25 -05:00
parent 03bf327f6b
commit 94a4a3c2ec
3 changed files with 306 additions and 15 deletions
+198
View File
@@ -0,0 +1,198 @@
package api
// Regression tests for #68 input-validation gaps: negative/oversized content
// lengths (413), limit=0 → default page size, unchecked query params
// (negative offset), and negative burn_after_reads.
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func createPasteRaw(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// A request whose decoded content exceeds the admin-tunable cap is rejected
// with 413 and a clear message (content under the body cap, over the
// content cap).
func TestCreatePasteContentOverCap413(t *testing.T) {
s := testServer(t)
h := s.routes()
// content over MaxContentBytes (5MiB) but under body cap (+4KiB): send
// just over the content cap so the per-field check fires first.
content := strings.Repeat("a", 5*1024*1024+10)
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"%s"}`, content))
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("got %d want 413: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "content exceeds max") {
t.Fatalf("unclear error message: %s", rec.Body.String())
}
}
// A request whose entire body is larger than the server-side body cap is cut
// off by http.MaxBytesReader and answered with 413, not decoded into memory
// (#68: previously a giant body was fully buffered, then rejected only at
// the per-field check — actually the decode happened before any check).
func TestCreatePasteBodyOverCap413(t *testing.T) {
s := testServer(t)
// shrink the content cap so the body cap is small too
ss := s.settings.get()
ss.MaxContentBytes = 64 * 1024
if err := s.settings.set(ss); err != nil {
t.Fatal(err)
}
h := s.routes()
content := strings.Repeat("a", 200*1024) // 200KiB > 64KiB+4KiB body cap
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"%s","title":"x"}`, content))
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("got %d want 413: %s", rec.Code, rec.Body.String())
}
}
// Negative content lengths cannot be expressed via JSON, but a negative
// expires-style numeric payload must not crash; more importantly the
// burn_after_reads field: negative values are rejected with a clear message.
func TestCreatePasteNegativeBurnAfterReads(t *testing.T) {
s := testServer(t)
h := s.routes()
rec := createPasteRaw(t, h, `{"content":"hi","burn_after_reads":-5}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("got %d want 400: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "burn_after_reads") {
t.Fatalf("unclear error message: %s", rec.Body.String())
}
// 0 and positive values still work (0 = default single read, per #49)
rec = createPasteRaw(t, h, `{"content":"hi","burn_after_reads":0,"burn_after_read":true}`)
if rec.Code != http.StatusCreated {
t.Fatalf("zero burn_after_reads: got %d want 201: %s", rec.Code, rec.Body.String())
}
}
// limit=0 on list endpoints returns the default page size (existing clamp
// treats <=0 as default; #68 asks this be explicit and tested).
func TestListLimitZeroUsesDefault(t *testing.T) {
s := testServer(t)
h := s.routes()
// seed 3 public pastes
for i := 0; i < 3; i++ {
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"p%d"}`, i))
if rec.Code != 201 {
t.Fatalf("seed: got %d: %s", rec.Code, rec.Body.String())
}
}
for _, q := range []string{"/api/public?limit=0", "/api/public"} {
req := httptest.NewRequest("GET", q, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("%s: got %d", q, rec.Code)
}
var got struct {
Limit int `json:"limit"`
Total int `json:"total"`
Items []struct{ ID string } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if got.Limit != 25 || len(got.Items) != 3 {
t.Fatalf("%s: limit=%d items=%d, want limit 25 and all 3 items", q, got.Limit, len(got.Items))
}
}
}
// Huge limit values are clamped to the max page size (already the behavior;
// regression-tested here per #68 "validate unchecked params").
func TestListLimitHugeClamped(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("GET", "/api/public?limit=999999999", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var got struct {
Limit int `json:"limit"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if got.Limit != 25 {
t.Fatalf("limit=%d, want clamped to 25", got.Limit)
}
}
// Negative offset previously passed through unchecked to SQL (harmless in
// SQLite, but invalid); it must be clamped to 0 (#68).
func TestListNegativeOffsetClamped(t *testing.T) {
s := testServer(t)
h := s.routes()
for i := 0; i < 2; i++ {
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"p%d"}`, i))
if rec.Code != 201 {
t.Fatalf("seed: got %d", rec.Code)
}
}
req := httptest.NewRequest("GET", "/api/public?offset=-999", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("got %d", rec.Code)
}
var got struct {
Offset int `json:"offset"`
Total int `json:"total"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if got.Offset != 0 || got.Total != 2 {
t.Fatalf("offset=%d total=%d, want offset 0 and total 2", got.Offset, got.Total)
}
// /api/mine too (needs the viewer cookie)
req = httptest.NewRequest("GET", "/api/mine?offset=-5", nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "offclamp"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var mine struct {
Offset int `json:"offset"`
}
json.Unmarshal(rec.Body.Bytes(), &mine)
if mine.Offset != 0 {
t.Fatalf("mine offset=%d, want 0", mine.Offset)
}
}
// Non-numeric limit/offset fall back to defaults instead of 500s.
func TestListGarbageParams(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("GET", "/api/public?limit=abc&offset=xyz", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("got %d", rec.Code)
}
var got struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if got.Limit != 25 || got.Offset != 0 {
t.Fatalf("limit=%d offset=%d, want 25/0", got.Limit, got.Offset)
}
}