Files
pad/internal/server/decode_json_test.go
T
xarmian baa1f75847 fix(server): cap JSON body + header size (TASK-663) (#184)
* fix(server): cap JSON body + header size (TASK-663)

decodeJSON called json.NewDecoder(r.Body).Decode(v) with no size limit.
Any client could POST a multi-GB JSON blob and watch Pad stream the
whole thing into one allocation — a single request could OOM the
process.

- internal/server/server.go: wrap r.Body in http.MaxBytesReader(..., 2 MB)
  inside decodeJSON. Every legitimate payload (item, collection, auth,
  etc.) is well under 100 KB so 2 MB is several orders of magnitude
  above real traffic. Factor out decodeJSONWithLimit(maxBytes) so
  future bulk-import endpoints can opt in to a larger cap without
  removing the wrapper.
- internal/server/server.go: set MaxHeaderBytes = 64 KiB on the
  http.Server (default is 1 MB). Plenty for cookies/auth/CORS while
  cheaply rejecting header-flood DoS.

Test: decode_json_test.go covers the 3 MiB body rejection, a happy
path, and a custom-limit override that rejects a 1 MiB body under a
256 KiB cap.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): bump workspace import JSON cap to 64 MiB per Codex P1

Codex flagged that handleImportWorkspace inherits the new 2 MiB default
cap, but WorkspaceExport contains full collections, items, comments,
and item_versions for the workspace — a realistic project backup
routinely exceeds 2 MiB, so existing exports stop re-importing.

Switch to decodeJSONWithLimit(64 << 20). 64 MiB is multiple orders of
magnitude above any realistic single-workspace backup while still far
from heap-exhaustion territory.
2026-04-21 22:47:09 -04:00

64 lines
2.1 KiB
Go

package server
import (
"bytes"
"net/http/httptest"
"strings"
"testing"
)
// TestDecodeJSON_RejectsOversizeBody ensures the default 2 MiB cap is
// enforced — without http.MaxBytesReader a multi-GB POST would stream
// into a single allocation and could OOM the process.
func TestDecodeJSON_RejectsOversizeBody(t *testing.T) {
// 3 MiB of harmless but oversize JSON.
body := []byte(`{"x":"` + strings.Repeat("a", 3<<20) + `"}`)
req := httptest.NewRequest("POST", "/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
var target map[string]any
err := decodeJSON(req, &target)
if err == nil {
t.Fatalf("expected oversize body to be rejected, got nil error")
}
if !strings.Contains(err.Error(), "request body too large") &&
!strings.Contains(err.Error(), "http: request body too large") {
// MaxBytesReader wraps the "request body too large" error into the
// json.Decoder failure, which bubbles up through the invalid-JSON
// wrap. Just verify SOME error surfaced — the exact wording is
// tied to stdlib internals.
t.Logf("got error: %v", err)
}
}
// TestDecodeJSON_AcceptsWithinLimit confirms the happy path still works.
func TestDecodeJSON_AcceptsWithinLimit(t *testing.T) {
req := httptest.NewRequest("POST", "/", bytes.NewReader([]byte(`{"name":"ok"}`)))
req.Header.Set("Content-Type", "application/json")
var target struct {
Name string `json:"name"`
}
if err := decodeJSON(req, &target); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if target.Name != "ok" {
t.Fatalf("got name=%q, want %q", target.Name, "ok")
}
}
// TestDecodeJSONWithLimit_CustomCap verifies callers can opt in to a
// larger cap (for bulk-import style endpoints).
func TestDecodeJSONWithLimit_CustomCap(t *testing.T) {
// 1 MiB body; below default 2 MiB but above our custom 256 KiB cap.
body := []byte(`{"x":"` + strings.Repeat("a", 1<<20) + `"}`)
req := httptest.NewRequest("POST", "/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
var target map[string]any
if err := decodeJSONWithLimit(req, &target, 256<<10); err == nil {
t.Fatal("expected 256 KiB cap to reject 1 MiB body, got nil")
}
}