Files
pad/internal/server/decode_json_nul_test.go
T
xarmian 0b0398b2c0 feat(mcp): count admitted MCP requests by method and client (TASK-2307) (#2003)
* feat(mcp): count authenticated MCP requests by method and client (TASK-2307)

The go-sdk port would run its Streamable HTTP transport stateless, and a
stateless transport answers GET with 405, which ends the standalone SSE
stream. No unit test can say whether a real client opens that stream, so
before any engineering time goes into the port this measures it:
pad_mcp_http_requests_total{mount, method, client}, incremented on entry for
every authenticated request on /mcp and /mcp/chatgpt.

On entry, because pad_http_requests_total records a request when it ends,
and a GET holding an SSE stream open ends when the stream does. After
MCPBearerAuth, so refused requests stay with pad_mcp_preauth_denied_total.
The client label is a closed set read from the User-Agent (claude-code,
claude, codex, openai, cursor, windsurf, vscode, mcp-remote, mcp-inspector,
python, node, go, curl, okhttp, other, none), never the header itself.

* fix(mcp): read the User-Agent through requestUserAgent (TASK-2307)

The BUG-2803 call-site guard refuses a raw User-Agent read; the class is
computed from the sanitised value. The guard's exact count moves from 7
call sites to 8, re-pinned with the reason: like fromNativeShell, the counter
only substring-matches the header into a closed label and binds nothing.

* docs(mcp): name what the request counter admits, not 'every authenticated request' (TASK-2307)

Codex round 1: a caller refused by the per-token rate limit inside
MCPBearerAuth is authenticated and is not counted. That is the intended
boundary (a refused GET opens no stream, and the refusal is already counted
in pad_mcp_authz_denials_total{reason="rate_limited"}), so the comment, the
metric help and docs/deployment.md now say what is counted.

* test(mcp): a rate-limited MCP request is not counted (TASK-2307)

Codex round 2: the documented boundary (MCPBearerAuth's per-token 429 is
not an admitted request) had no test. Counted equals admitted; the mutant
moving the counter ahead of auth counts the 429 and fails.
2026-10-10 00:50:31 -04:00

1559 lines
66 KiB
Go

package server
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/artifact"
"github.com/PerpetualSoftware/pad/internal/textguard"
)
// rawJSONRequest sends a RAW body string. A test cannot marshal a Go map
// here: marshalling would escape the backslash and send the literal text
// instead of the escape, so every probe would be testing the harmless case.
func rawJSONRequest(srv *Server, method, path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.0.2.1:1234"
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
return rr
}
// escNULLiteral is the six-character JSON escape that decodes to a NUL,
// assembled from bytes. Written as a Go literal it is one backslash away from
// being the NUL itself, and every layer between an editor and the compiler is
// a chance for that to happen silently — which is the whole subject here.
var escNULLiteral = string([]byte{'\\', 'u', '0', '0', '0', '0'})
func TestBodyDecodesNUL(t *testing.T) {
esc := escNULLiteral
cases := []struct {
name string
body string
want bool
}{
{"plain body, no escape anywhere", `{"title":"hello"}`, false},
{"escape in a string value", `{"title":"a` + esc + `b"}`, true},
{"escape as the whole value", `{"title":"` + esc + `"}`, true},
{"escape in an OBJECT KEY", `{"a` + esc + `b":"v"}`, true},
{"escape nested in a fields map", `{"fields":{"k":"a` + esc + `b"}}`, true},
{"escape inside an array element", `{"tags":["ok","a` + esc + `b"]}`, true},
// The reason this is not a substring search. A doubled backslash is
// an escaped BACKSLASH followed by the literal characters u0000, so
// the decoded string holds no NUL. This product stores markdown, and
// a document explaining JSON escapes is an ordinary thing to write.
{"doubled backslash decodes to literal text, NOT a NUL",
`{"content":"write it as \\` + "u0000" + ` in JSON"}`, false},
{"doubled backslash twice", `{"a":"\\` + "u0000" + `","b":"\\` + "u0000" + `"}`, false},
// A NUL escape that follows an escaped backslash IS real: the first
// two backslashes consume each other, so the third begins a fresh
// escape. The doubled-backslash cases above must not be read as
// "any preceding backslash makes it safe".
{"escaped backslash then a REAL escape", `{"a":"\\` + esc + `"}`, true},
{"other escapes are untouched", `{"a":"tab\there é \n"}`, false},
{"base64-looking payload with no escape", `{"b":"AQACAAAA"}`, false},
// Malformed input answers false: the caller's decode reports the JSON
// error, so this function never has to phrase one.
{"malformed JSON carrying the escape", `{"a":"` + esc, false},
{"empty body", ``, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := bodyDecodesNUL([]byte(tc.body)); got != tc.want {
t.Errorf("bodyDecodesNUL(%q) = %v, want %v", tc.body, got, tc.want)
}
})
}
}
// TestBodyDecodesNULAgreesWithTheDecoder is the differential check: for every
// case above, the verdict must match what actually lands in a decoded Go
// value. Asserting the function against hand-written expectations pins my
// reading of JSON escaping; asserting it against encoding/json pins the
// property the fix depends on.
func TestBodyDecodesNULAgreesWithTheDecoder(t *testing.T) {
esc := escNULLiteral
bodies := []string{
`{"title":"hello"}`,
`{"title":"a` + esc + `b"}`,
`{"a` + esc + `b":"v"}`,
`{"fields":{"k":"a` + esc + `b"}}`,
`{"tags":["ok","a` + esc + `b"]}`,
`{"content":"write it as \\` + "u0000" + ` in JSON"}`,
`{"a":"\\` + esc + `"}`,
`{"a":"tab\there é \n"}`,
}
for _, body := range bodies {
var v any
if err := json.Unmarshal([]byte(body), &v); err != nil {
t.Fatalf("fixture %q does not decode: %v", body, err)
}
want := anyHasNUL(v)
if got := bodyDecodesNUL([]byte(body)); got != want {
t.Errorf("bodyDecodesNUL(%q) = %v, but the decoded value %s a NUL",
body, got, map[bool]string{true: "CONTAINS", false: "does not contain"}[want])
}
}
}
func anyHasNUL(v any) bool {
switch t := v.(type) {
case string:
return strings.ContainsRune(t, 0)
case map[string]any:
for k, sub := range t {
if strings.ContainsRune(k, 0) || anyHasNUL(sub) {
return true
}
}
case []any:
for _, sub := range t {
if anyHasNUL(sub) {
return true
}
}
}
return false
}
// TestDecodeJSONRefusesNULThroughTheHandler exercises the BINDING, not the
// predicate: a real request through the real router, so the test has an
// opinion about whether decodeJSON is what handlers actually call. Its
// control leg is the same request with an ordinary value — without one, a 400
// proves nothing, since a malformed body would produce the same status
// (this filing's original measurement made exactly that mistake).
//
// Runs on SQLite, where the underlying insert would SUCCEED with a truncated
// value rather than error. That is deliberate: on SQLite the refusal can only
// come from this fix, so a green here cannot be the database doing the work.
func TestDecodeJSONRefusesNULThroughTheHandler(t *testing.T) {
srv := testServer(t)
rr := rawJSONRequest(srv, "POST", "/api/v1/workspaces/",
`{"name":"NUL probe","slug":"nulprobe","template":"startup"}`)
if rr.Code != http.StatusOK && rr.Code != http.StatusCreated {
t.Fatalf("fixture workspace: %d %s", rr.Code, rr.Body.String())
}
rr = rawJSONRequest(srv, "POST", "/api/v1/workspaces/nulprobe/collections/",
`{"name":"Probes","slug":"probes"}`)
if rr.Code != http.StatusOK && rr.Code != http.StatusCreated {
t.Fatalf("fixture collection: %d %s", rr.Code, rr.Body.String())
}
const itemsPath = "/api/v1/workspaces/nulprobe/collections/probes/items"
esc := escNULLiteral
control := rawJSONRequest(srv, "POST", itemsPath, `{"title":"a-plain-b"}`)
if control.Code != http.StatusCreated && control.Code != http.StatusOK {
t.Fatalf("control leg must succeed, got %d: %s", control.Code, control.Body.String())
}
for _, tc := range []struct {
name string
body string
}{
{"title", `{"title":"a` + esc + `b"}`},
{"content", `{"title":"ok","content":"a` + esc + `b"}`},
{"fields value", `{"title":"ok","fields":{"k":"a` + esc + `b"}}`},
} {
t.Run(tc.name, func(t *testing.T) {
rr := rawJSONRequest(srv, "POST", itemsPath, tc.body)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "NUL") {
t.Errorf("400 body should name the cause, got: %s", rr.Body.String())
}
})
}
// The doubled-backslash body must still be ACCEPTED end to end. This is
// the leg that fails if anyone ever "simplifies" the check into a
// substring search.
ok := rawJSONRequest(srv, "POST", itemsPath,
`{"title":"escapes","content":"write it as \\`+"u0000"+` in JSON"}`)
if ok.Code != http.StatusCreated && ok.Code != http.StatusOK {
t.Fatalf("literal escape text must be accepted, got %d: %s", ok.Code, ok.Body.String())
}
}
// TestDecodeJSONKeepsEmptyBodyEOF pins a contract a caller depends on:
// handlers_playbooks.go treats errors.Is(err, io.EOF) as "no arguments
// supplied" and runs the playbook anyway. json.Decoder answered io.EOF on an
// empty body; json.Unmarshal answers a SyntaxError, which that check cannot
// see. Without this, an empty-body playbook run starts returning 400.
func TestDecodeJSONKeepsEmptyBodyEOF(t *testing.T) {
for _, body := range []string{"", " ", "\n\t "} {
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
var v map[string]any
err := decodeJSON(req, &v)
if err == nil {
t.Fatalf("empty body %q: expected an error", body)
}
if !errors.Is(err, io.EOF) {
t.Errorf("empty body %q: error must wrap io.EOF, got %v", body, err)
}
}
}
// jsonEncode returns s as a JSON string literal — the wire form of a field
// that crosses as a JSON-ENCODED STRING (an item's fields, a collection's
// schema, a workspace's settings). Using encoding/json to build it, rather
// than hand-writing the backslashes, is deliberate: the escaping rules are
// the subject under test and hand-written fixtures would pin my reading of
// them instead of the real ones.
func jsonEncode(t *testing.T, s string) string {
t.Helper()
b, err := json.Marshal(s)
if err != nil {
t.Fatalf("encode %q: %v", s, err)
}
return string(b)
}
// TestBodyDecodesNULNestedDocuments covers codex round 1's P1 on BUG-2803:
// several fields arrive as JSON-ENCODED STRINGS, so the escape survives the
// OUTER decode as literal text and reappears when the destination re-parses
// the string as JSON. Postgres refuses that with SQLSTATE 22P05 (unsupported
// Unicode escape sequence) rather than the 22021 the rest of this family
// produces — a different error precisely because the outer string is pure
// ASCII and never trips the text-encoding check.
func TestBodyDecodesNULNestedDocuments(t *testing.T) {
esc := escNULLiteral
innerWithNUL := `{"k":"a` + esc + `b"}` // decodes to a NUL when parsed
innerLiteral := `{"k":"a\\` + "u0000" + `b"}` // a doubled backslash: literal text
innerPlain := `{"k":"plain"}`
cases := []struct {
name string
body string
want bool
}{
{"fields as a JSON-encoded string carrying the escape",
`{"title":"x","fields":` + jsonEncode(t, innerWithNUL) + `}`, true},
{"schema as a JSON-encoded string carrying the escape",
`{"name":"c","schema":` + jsonEncode(t, innerWithNUL) + `}`, true},
{"settings as a JSON-encoded string carrying the escape",
`{"name":"w","settings":` + jsonEncode(t, innerWithNUL) + `}`, true},
{"a JSON-encoded ARRAY carrying the escape",
`{"tags":` + jsonEncode(t, `["ok","a`+esc+`b"]`) + `}`, true},
// codex round 4: the BACKSLASH itself can be written as an escape, so
// the raw body carries no literal six-character sequence anywhere
// while the outer decode manufactures one inside the nested document.
// This is the case the old substring fast path let through — measured
// as a 201 through the real router before the gate became a backslash
// check.
{"escape spelled obliquely, via an escaped backslash",
`{"fields":"{\"k\":\"a` + string([]byte{'\\', 'u', '0', '0', '5', 'c'}) + `u0000b\"}"}`, true},
// Depth 2 under an ORDINARY key is ACCEPTED, and that is a measured
// decision rather than a gap: the handler parses `fields` once, so
// the inner text is re-escaped when the blob is written and Postgres
// never sees an escape. Probed on Postgres 17 with the check
// disabled — the same body imports 201. Only the document Postgres
// itself parses can carry a fatal one (codex round 7).
{"twice-encoded under an ordinary key is safe",
`{"fields":` + jsonEncode(t, `{"inner":`+jsonEncode(t, innerWithNUL)+`}`) + `}`, false},
// ...but a JSON-ENCODED key INSIDE the document still recurses, so
// this is a key rule applied at every level, not a depth limit.
// ...and a key that LOOKS like a wire key inside caller data is not
// one: a collection may declare a user field named `schema`, so the
// list is consulted only outside caller data (codex round 8).
{"a user field named like a wire key does not restart the descent",
`{"fields":` + jsonEncode(t, `{"schema":`+jsonEncode(t, innerWithNUL)+`}`) + `}`, false},
// Controls. These must stay ACCEPTED: the recursion must not turn
// "contains the six characters somewhere" into a refusal.
{"fields as a JSON-encoded string, ordinary content",
`{"title":"x","fields":` + jsonEncode(t, innerPlain) + `}`, false},
{"nested document whose escape is a DOUBLED backslash",
`{"fields":` + jsonEncode(t, innerLiteral) + `}`, false},
// codex round 6: these fields also accept their NATURAL shape, in
// which the elements are ordinary strings the server marshals itself.
// Nothing re-parses them, so an element that merely LOOKS like a
// document must not be treated as one — this refused a free-form tag
// whose whole value happened to be JSON.
{"tags as a natural ARRAY whose element is a JSON document",
`{"title":"x","tags":["release",` + jsonEncode(t, innerWithNUL) + `]}`, false},
{"fields as a natural OBJECT whose value is a JSON document",
`{"title":"x","fields":{"k":` + jsonEncode(t, innerWithNUL) + `}}`, false},
// ...while the JSON-ENCODED spelling of the same field still is.
{"tags as a JSON-encoded STRING carrying the escape",
`{"title":"x","tags":` + jsonEncode(t, `["a`+esc+`b"]`) + `}`, true},
{"a string that starts like JSON but does not parse",
`{"content":` + jsonEncode(t, `{"k":"a`+esc+`b"`) + `}`, false},
{"prose mentioning the escape is not a JSON document",
`{"content":` + jsonEncode(t, `write a NUL as `+esc+` in JSON`) + `}`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := bodyDecodesNUL([]byte(tc.body)); got != tc.want {
t.Errorf("bodyDecodesNUL(%s) = %v, want %v", tc.body, got, tc.want)
}
})
}
}
// TestBodyDecodesNULLeavesTextFieldsAlone is codex round 2's finding on
// BUG-2803, pinned so it cannot come back.
//
// The first version of the nesting check recursed into ANY string that parsed
// as a JSON document. That refused a plain-text `content` value holding a
// JSON snippet which merely MENTIONS the escape — a value this server
// accepted before the fix, stores in a text column that has no problem with
// it, and emits again in an export. Refusing input the server itself produced
// is a worse failure than the narrow door the unscoped recursion closed, so
// the recursion is now scoped to the keys that actually carry JSON documents.
func TestBodyDecodesNULLeavesTextFieldsAlone(t *testing.T) {
doc := `{"k":"a` + escNULLiteral + `b"}`
for _, key := range []string{"content", "title", "summary", "body", "description"} {
body := `{"` + key + `":` + jsonEncode(t, doc) + `}`
if bodyDecodesNUL([]byte(body)) {
t.Errorf("%s is a text field: a JSON document in its value must not be re-parsed "+
"(codex round 2 — the server emits such values in exports and must accept them back)", key)
}
}
// The same document under a JSON-ENCODED key is still refused, so the
// legs differ only in the key and this is not just "recursion removed".
if !bodyDecodesNUL([]byte(`{"fields":` + jsonEncode(t, doc) + `}`)) {
t.Error("under a JSON-encoded key the same document must still be refused")
}
}
// TestJSONEncodedFieldKeysCoversTheModels keeps jsonEncodedFieldKeys from
// going stale in silence, which is the whole objection to a list-based rule.
// It derives the set from the wire model — a Go string field with a json tag
// whose comment says it holds JSON — and fails when one is not covered. A key
// missing from the list reopens a door; an extra key is harmless (see the
// over-inclusion note at jsonEncodedFieldKeys), so this asserts one direction
// only, deliberately.
func TestJSONEncodedFieldKeysCoversTheModels(t *testing.T) {
entries, err := os.ReadDir("../models")
if err != nil {
t.Fatalf("read models dir: %v", err)
}
// A string field, a json tag, and a trailing comment mentioning JSON.
decl := regexp.MustCompile("string\\s+`json:\"([a-z_]+)[\",][^`]*`\\s*//[^\\n]*JSON")
found := map[string]string{}
scanned := 0
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
scanned++
src, err := os.ReadFile(filepath.Join("../models", name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
for _, m := range decl.FindAllStringSubmatch(string(src), -1) {
found[m[1]] = name
}
}
if scanned < 10 || len(found) < 5 {
t.Fatalf("derivation looks broken: scanned %d files, found %d JSON-encoded fields", scanned, len(found))
}
for key, file := range found {
if !jsonEncodedFieldKeys[key] {
t.Errorf("%s declares %q as a JSON-encoded string, but it is not in jsonEncodedFieldKeys — "+
"a NUL escape nested inside it would reach the database unchecked (BUG-2803)", file, key)
}
}
}
// TestBodyDecodesNULDescendsExactlyOneLevel replaces an earlier depth-bound
// test. The walk no longer carries a depth counter, because it no longer
// needs one: the key list is consulted only outside caller data, so a
// document reached through a listed key is walked with the list disabled and
// nothing below it can start a second descent.
//
// This pins that property directly rather than pinning a bound, and it is the
// test that fails if someone reintroduces flag inheritance: an escape one
// level below the parsed document must be ACCEPTED (measured safe on Postgres
// — the inner text is re-escaped when the blob is written), while the same
// escape IN that document must be refused.
func TestBodyDecodesNULDescendsExactlyOneLevel(t *testing.T) {
inner := `{"k":"a` + escNULLiteral + `b"}`
atTheParsedLayer := `{"fields":` + jsonEncode(t, inner) + `}`
if !bodyDecodesNUL([]byte(atTheParsedLayer)) {
t.Error("an escape in the document Postgres parses must be refused")
}
oneDeeper := `{"fields":` + jsonEncode(t, `{"inner":`+jsonEncode(t, inner)+`}`) + `}`
if bodyDecodesNUL([]byte(oneDeeper)) {
t.Error("an escape BELOW the parsed document is safe and must be accepted")
}
// A user field named like a wire key must not restart the descent.
shadowed := `{"fields":` + jsonEncode(t, `{"schema":`+jsonEncode(t, inner)+`}`) + `}`
if bodyDecodesNUL([]byte(shadowed)) {
t.Error("a user field named `schema` inside a fields blob is caller data, not a wire key")
}
natural := `{"fields":{"schema":` + jsonEncode(t, inner) + `}}`
if bodyDecodesNUL([]byte(natural)) {
t.Error("a user field named `schema` in a NATURAL fields object is caller data too")
}
}
// TestDecodeJSONRefusesNestedNULThroughTheHandler is the wiring leg for the
// nested case, on SQLite for the same reason as the single-layer one: there
// the write would SUCCEED, so a green cannot be the database doing the work.
func TestDecodeJSONRefusesNestedNULThroughTheHandler(t *testing.T) {
srv := testServer(t)
rr := rawJSONRequest(srv, "POST", "/api/v1/workspaces/",
`{"name":"Nested","slug":"nested","template":"startup"}`)
if rr.Code != http.StatusOK && rr.Code != http.StatusCreated {
t.Fatalf("fixture workspace: %d %s", rr.Code, rr.Body.String())
}
rr = rawJSONRequest(srv, "POST", "/api/v1/workspaces/nested/collections/",
`{"name":"Probes","slug":"probes"}`)
if rr.Code != http.StatusOK && rr.Code != http.StatusCreated {
t.Fatalf("fixture collection: %d %s", rr.Code, rr.Body.String())
}
const itemsPath = "/api/v1/workspaces/nested/collections/probes/items"
control := rawJSONRequest(srv, "POST", itemsPath,
`{"title":"nested control","fields":`+jsonEncode(t, `{"k":"plain"}`)+`}`)
if control.Code != http.StatusCreated && control.Code != http.StatusOK {
t.Fatalf("control leg must succeed, got %d: %s", control.Code, control.Body.String())
}
bad := rawJSONRequest(srv, "POST", itemsPath,
`{"title":"nested probe","fields":`+jsonEncode(t, `{"k":"a`+escNULLiteral+`b"}`)+`}`)
if bad.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for a JSON-encoded fields string carrying the escape, got %d: %s",
bad.Code, bad.Body.String())
}
}
// TestTruncateBindableText covers codex round 5's second class on BUG-2803:
// a value that PASSED the body check becomes unbindable on its way to the
// store, because a plain s[:n] can split a rune and leave a partial sequence.
//
// The failure is invisible with ASCII fixtures, which is why four review
// rounds aimed at the input side did not reach it — so these cases are built
// from multi-byte runes deliberately, and each asserts the OUTPUT is valid
// UTF-8 rather than merely short.
func TestTruncateBindableText(t *testing.T) {
// é is 2 bytes, 中 is 3, 𝄞 is 4 — one case per continuation length, so a
// walk-back that is off by one cannot pass them all.
for _, tc := range []struct {
name string
s string
limit int
}{
{"two-byte rune straddling the cut", strings.Repeat("é", 80), 121},
{"three-byte rune straddling the cut", strings.Repeat("中", 80), 121},
{"four-byte rune straddling the cut", strings.Repeat("𝄞", 80), 121},
{"cut exactly on a boundary", strings.Repeat("é", 80), 120},
{"ascii", strings.Repeat("a", 300), 120},
{"shorter than the limit", "café", 120},
{"limit of zero", "café", 0},
} {
t.Run(tc.name, func(t *testing.T) {
got := truncateBindableText(tc.s, tc.limit)
if len(got) > tc.limit {
t.Errorf("result is %d bytes, over the %d limit", len(got), tc.limit)
}
// The point of the whole helper: the RESULT must still be
// storable. A plain slice fails exactly here.
if !bindableText(got) {
t.Errorf("result is not bindable text: %q", got)
}
if !strings.HasPrefix(tc.s, got) {
t.Errorf("result %q is not a prefix of the input", got)
}
// And it must keep as much as the limit allows. Without this,
// an implementation returning "" for every input passes every
// assertion above — it is bindable, within the limit, and a
// prefix (codex round 13).
if len(tc.s) <= tc.limit {
if got != tc.s {
t.Errorf("input fits the limit and must be returned unchanged, got %q", got)
}
} else if lost := tc.limit - len(got); lost >= 4 {
t.Errorf("dropped %d bytes to respect a %d-byte limit; at most one rune (max 4 bytes) "+
"should be lost to the boundary", lost, tc.limit)
}
})
}
// The counterfactual, stated as a test rather than as a comment: the
// naive slice this replaces really does produce unbindable output for the
// same input. Without this leg the cases above would pass against an
// implementation that simply returned the input unchanged when short —
// they would never demonstrate that anything was wrong.
s := strings.Repeat("é", 80)
if bindableText(s[:121]) {
t.Fatal("the fixture cannot reproduce the defect: a plain byte slice of it is still valid UTF-8, " +
"so this test would pass against a broken implementation")
}
}
// TestRequestUserAgentIsBindableText covers codex round 5's third class on
// BUG-2803: the User-Agent header reaches activities.user_agent as text, and
// no rule in this file sees a header. (Sessions are NOT a text sink — they
// store only ua_hash, which is why the accounting list above exempts those
// reads; an earlier version of this sentence claimed sessions.user_agent
// stored text, contradicting that exemption — codex closing round 4.)
//
// The disposition here is SANITISE, not refuse — a header is metadata this
// server chose to record, not something the caller asked for, so a malformed
// one must not turn a fine request into a 400. That difference from every
// other check in this file is the thing worth pinning.
func TestRequestUserAgentIsBindableText(t *testing.T) {
for _, tc := range []struct {
name string
ua string
want string
}{
{"ordinary", "pad-cli/1.0", "pad-cli/1.0"},
{"non-ascii is preserved", "café/1.0 中", "café/1.0 中"},
{"invalid UTF-8 is dropped", "pad\xffcli", "padcli"},
{"NUL is dropped", "pad\x00cli", "padcli"},
{"empty stays empty", "", ""},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("User-Agent", tc.ua)
got := requestUserAgent(req)
if got != tc.want {
t.Errorf("requestUserAgent = %q, want %q", got, tc.want)
}
if !bindableText(got) {
t.Errorf("result is not bindable text: %q", got)
}
})
}
// The counterfactual: the raw header really is unbindable, so these cases
// would not pass against a helper that simply returned it unchanged.
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("User-Agent", "pad\xffcli")
if bindableText(req.Header.Get("User-Agent")) {
t.Fatal("the fixture cannot reproduce the defect: the raw header is already bindable text")
}
}
// TestBodyDecodesNULGateAgreesWithAnUngatedWalk is the instrument that keeps
// the fast path honest.
//
// The gate is an argument, not an observation: to manufacture the
// six-character NUL escape inside a decoded string, every one of its
// characters must arrive either literally from the raw bytes — in which case
// the raw contains the escape, which begins with \u00 — or from a \u escape
// of its own, and the three characters involved (backslash U+005C, 'u'
// U+0075, '0' U+0030) all sit below U+0100, so every such escape also begins
// with \u00. Hence: no \u00 in the raw bytes, no NUL at any depth, however
// spelled.
//
// The FIRST version of that argument was wrong in exactly this way — it said
// "the escape has only one spelling", which is true inside a decoded string
// and false of the raw bytes, and codex round 4 turned that into a live
// bypass. So the argument does not get to stand on its own reasoning: this
// test runs the gated function against an UNGATED walk over a corpus built to
// attack it, and any disagreement is a bypass.
func TestBodyDecodesNULGateAgreesWithAnUngatedWalk(t *testing.T) {
ungated := func(raw []byte) bool {
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return false
}
return mapModelDecodesNUL(v, false)
}
esc := escNULLiteral // the six-character NUL escape
bs := string([]byte{'\\', 'u', '0', '0', '5', 'c'}) // escapes to a BACKSLASH
bsUpper := string([]byte{'\\', 'u', '0', '0', '5', 'C'}) // same, upper-case hex
uEsc := string([]byte{'\\', 'u', '0', '0', '7', '5'}) // escapes to the letter u
zero := string([]byte{'\\', 'u', '0', '0', '3', '0'}) // escapes to the digit 0
doubled := `\\` + "u0000" // literal text, no NUL
corpus := []string{
`{"title":"plain"}`,
`{"title":"quotes \" and a backslash \\ but no escape"}`,
`{"fields":"{\"k\":\"plain\"}"}`,
`{"fields":"{\"k\":\"a` + esc + `b\"}"}`,
`{"fields":"{\"k\":\"a` + bs + `u0000b\"}"}`,
`{"fields":"{\"k\":\"a` + bsUpper + `u0000b\"}"}`,
`{"fields":"{\"k\":\"a\\` + uEsc + `0000b\"}"}`,
`{"fields":"{\"k\":\"a\\u00` + zero + `0b\"}"}`,
`{"fields":"{\"k\":\"a` + doubled + `b\"}"}`,
`{"content":"{\"k\":\"a` + esc + `b\"}"}`,
`{"title":"a` + esc + `b"}`,
`{"a` + esc + `b":"key"}`,
`{"tags":["ok","a` + esc + `b"]}`,
`{"fields":"[1,2,\"a` + esc + `b\"]"}`,
`{"title":"unicode é 中"}`,
`{"title":"a control escape that is not a NUL: \\u0001"}`,
`{"fields":"{\"k\":\"ab\"}"}`,
}
for i, body := range corpus {
got, want := bodyDecodesNUL([]byte(body)), ungated([]byte(body))
if got != want {
t.Errorf("corpus[%d] %s\n gated=%v ungated=%v — the fast path is a BYPASS for this input",
i, body, got, want)
}
}
// The corpus must contain both answers, or agreement proves nothing.
var trues, falses int
for _, body := range corpus {
if ungated([]byte(body)) {
trues++
} else {
falses++
}
}
if trues < 3 || falses < 3 {
t.Fatalf("corpus is one-sided (%d refuse, %d accept); agreement would be vacuous", trues, falses)
}
}
// TestDocumentActivityStoresBindableUserAgent is the WIRING leg for the
// User-Agent sanitiser (CONVE-19, and codex round 6's second finding: a unit
// test vouches for the helper, not for anything calling it).
//
// It drives a real request through the router with a malformed header and
// reads the STORED value out of the activities row, so reverting the
// production call site fails this even though the helper still works.
//
// It targets the ACTIVITY sink deliberately. The round-5 enumeration also
// named "sessions.user_agent", and I wired the three login paths to the
// sanitiser before reading store.CreateSession — which HASHES the header and
// stores no text at all. That change was reverted: login would have stored
// sha256(sanitised) while middleware_auth still compares sha256(RAW),
// breaking session validation for any client with a non-UTF-8 User-Agent.
// A sink named in a review is a pointer to verify, not a finding.
func TestDocumentActivityStoresBindableUserAgent(t *testing.T) {
srv := testServer(t)
ws := createWSForTest(t, srv)
post := func(t *testing.T, ua, title string) {
t.Helper()
body, _ := json.Marshal(map[string]any{"title": title})
req := httptest.NewRequest("POST", "/api/v1/workspaces/"+ws+"/documents/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", ua)
req.RemoteAddr = "192.0.2.1:1234"
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK && rr.Code != http.StatusCreated {
t.Fatalf("create document = %d: %s", rr.Code, rr.Body.String())
}
}
// Control first: an ordinary header must be stored VERBATIM, so a
// sanitiser that mangled everything would fail here rather than pass.
post(t, "pad-cli/1.0 café", "control doc")
post(t, "pad-cli/1.0 bad\xffbyte\x00here", "probe doc")
rows, err := srv.store.DB().Query(`SELECT user_agent FROM activities WHERE user_agent IS NOT NULL`)
if err != nil {
t.Fatalf("read activities: %v", err)
}
defer rows.Close()
var seenControl, seenSanitised int
for rows.Next() {
var ua sql.NullString
if err := rows.Scan(&ua); err != nil {
t.Fatalf("scan: %v", err)
}
if !ua.Valid {
continue
}
if !bindableText(ua.String) {
t.Errorf("a stored activity user_agent is not bindable text: %q", ua.String)
}
switch ua.String {
case "pad-cli/1.0 café":
seenControl++
case "pad-cli/1.0 badbytehere":
seenSanitised++
}
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
if seenControl == 0 {
t.Error("the control header was not stored verbatim — the sanitiser is doing too much")
}
if seenSanitised == 0 {
t.Error("no activity carries the sanitised header; the call site is not wired to requestUserAgent")
}
}
// TestBodyDecodesNULUnderAJSONEncodedKeyChecksBothWays is codex round 9's P1,
// which was a REGRESSION this branch introduced in its round-8 restructure.
//
// Taking the JSON-encoded branch for a listed key used to skip the plain
// "does this string contain a NUL" check, so a direct NUL in a `fields` value
// — the very first door this change closed — was accepted again. The two
// questions are different and both must be asked: does the string ITSELF
// carry a NUL, and does the document it carries contain an escape.
func TestBodyDecodesNULUnderAJSONEncodedKeyChecksBothWays(t *testing.T) {
esc := escNULLiteral
// A direct escape in the fields STRING (not inside a nested document):
// the outer decode turns it into a real NUL in that string.
direct := `{"title":"x","fields":"a` + esc + `b"}`
if !bodyDecodesNUL([]byte(direct)) {
t.Error("a NUL in the fields string itself must be refused")
}
// And the nested form still is, so this is not just the plain check.
nested := `{"title":"x","fields":` + jsonEncode(t, `{"k":"a`+esc+`b"}`) + `}`
if !bodyDecodesNUL([]byte(nested)) {
t.Error("an escape inside the fields document must be refused")
}
// Control: an ordinary fields string is accepted, so the legs above are
// not passing because everything under a listed key is refused.
if bodyDecodesNUL([]byte(`{"title":"x","fields":"plain"}`)) {
t.Error("an ordinary fields string must be accepted")
}
}
// TestArtifactBindableTextAcceptsLiteralEscapeText is codex round 9's P2: the
// artifact check searched the MARSHALLED bytes for the escape, and a value
// holding the six LITERAL characters marshals to a doubled backslash which
// still contains that sequence — so valid content was refused. Artifacts are
// documentation; text about a JSON escape is exactly what one carries.
func TestArtifactBindableTextAcceptsLiteralEscapeText(t *testing.T) {
esc := escNULLiteral
ok := artifact.Artifact{
Title: "Escapes",
Body: "write a NUL as " + esc + " in JSON",
Fields: map[string]any{
"note": "also " + esc + " here",
},
}
if !artifactIsBindableText(ok) {
t.Error("literal escape TEXT in an artifact is valid content and must be accepted")
}
// The counterfactual: a real NUL in the same places is still refused, so
// the leg above is not passing because the check does nothing.
for name, bad := range map[string]artifact.Artifact{
"title": {Title: "a\x00b"},
"body": {Title: "t", Body: "a\x00b"},
"fields": {Title: "t", Fields: map[string]any{"k": "a\x00b"}},
} {
if artifactIsBindableText(bad) {
t.Errorf("a real NUL in %s must be refused", name)
}
}
}
// TestTestEmailRefusesUnusableBody is the handler-level leg codex round 13
// found missing. Reverting handlers_admin.go to `err != nil || input.To == ""`
// — which defaulted EVERY decode failure to the admin's own address — passed
// every other test in this file, because they only exercise decodeJSON.
//
// The distinction being pinned is between two things that used to collapse
// into one: an ABSENT body legitimately means "send it to me", while a body
// that is present and REFUSED must not be reinterpreted as that default.
func TestTestEmailRefusesUnusableBody(t *testing.T) {
srv := testServer(t)
token := bootstrapFirstUser(t, srv, "admin-mail@example.com", "Admin")
mock := mockMailerooEndpoint(t, http.StatusOK, true)
configureEmailForTest(srv, mock.URL, "http://localhost:7777")
post := func(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest("POST", "/api/v1/admin/test-email", r)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:1234"
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
return rr
}
// Control 1: no body at all still means "send it to me".
if rr := post(t, ""); rr.Code != http.StatusOK {
t.Fatalf("absent body must still send, got %d: %s", rr.Code, rr.Body.String())
}
// Control 2: an ordinary body still works.
if rr := post(t, `{"to":"someone@example.com"}`); rr.Code != http.StatusOK {
t.Fatalf("ordinary body must send, got %d: %s", rr.Code, rr.Body.String())
}
// The case: a body that is valid JSON but carries an unusable value.
rr := post(t, `{"to":"a`+escNULLiteral+`b@example.com"}`)
if rr.Code != http.StatusBadRequest {
t.Errorf("a refused body must answer 400, not be reinterpreted as the default recipient; got %d: %s",
rr.Code, rr.Body.String())
}
}
// TestTextSafeHelpersAreUsedAtEveryCallSite closes the gap codex round 14
// named: reverting a single call site back to the unsafe form leaves every
// behavioural test green, because the surviving fixtures are ASCII and the
// helpers' own unit tests do not care who calls them.
//
// Testing each site behaviourally would need a fixture per site (an OAuth
// connection, a cloud login, four audit paths). This asserts the WIRING
// statically instead — the same technique as
// TestEveryRequestBodyReaderIsAccountedFor, and the same bargain: a reverted
// call site fails here immediately, and a new one has to be decided on
// deliberately rather than added invisibly.
//
// It asserts in both directions: an unsafe form anywhere fails, and finding
// none of the safe form also fails, so a scan that silently matched nothing
// cannot pass forever.
func TestTextSafeHelpersAreUsedAtEveryCallSite(t *testing.T) {
// A plain byte slice of a caller string can split a rune and produce
// invalid UTF-8; truncateBindableText is the only allowed form.
unsafeTruncate := regexp.MustCompile(`\b(name|suggested|input\.Name|Name)\s*=\s*\w+(\.\w+)*\[:\d+\]`)
// The raw header reaches a text column; requestUserAgent is the allowed
// form. The two HASH sites are exempt and named below.
unsafeUA := regexp.MustCompile(`(r|req)\.UserAgent\(\)|(r|req)\.Header\.Get\("User-Agent"\)`)
// Exempt sites, with counts, so a NEW raw read in one of these files
// still fails rather than hiding behind a blanket exemption.
uaHashExempt := map[string]int{
// All four reads here feed a HASH, not a text column: one
// session-fingerprint comparison plus three CreateSession calls,
// and store.CreateSession hashes the header (sessions.ua_hash) and
// stores no text. sha256 over arbitrary bytes is well defined, and
// sanitising before hashing would be actively harmful — login would
// store sha256(sanitised) while the comparison still hashes the RAW
// header, so every session from a client with a non-UTF-8
// User-Agent would fail validation.
"handlers_auth.go": 4,
// The same fingerprint comparison on the session-validation path.
"middleware_auth.go": 1,
// requestUserAgent itself: this is the one read that is allowed to
// be raw, because it is the thing doing the sanitising.
"middleware_request_text.go": 1,
}
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read package dir: %v", err)
}
var safeTruncateUses, safeUAUses int
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
src, err := os.ReadFile(filepath.Join(".", name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
text := string(src)
safeTruncateUses += strings.Count(text, "truncateBindableText(")
safeUAUses += strings.Count(text, "requestUserAgent(")
for _, m := range unsafeTruncate.FindAllString(text, -1) {
t.Errorf("%s: %q slices a caller string by BYTES, which can split a rune and produce "+
"invalid UTF-8 downstream of validation — use truncateBindableText (BUG-2803)", name, m)
}
found := len(unsafeUA.FindAllString(text, -1))
if allowed := uaHashExempt[name]; found > allowed {
t.Errorf("%s: %d raw User-Agent read(s), %d exempt. A raw header reaching a text column "+
"must go through requestUserAgent; only the session-hash comparisons are exempt (BUG-2803)",
name, found, allowed)
}
}
// Exact counts, not lower bounds — a floor let a real call site vanish
// while the threshold stayed satisfied (codex closing round 3). Each
// total includes the helper's own declaration (the scan counts raw
// occurrences), so today that is 1 declaration + 4 call sites for each.
// MEASURED against this package, same discipline as the reader counts
// above: a changed number is the signal to re-justify, in either
// direction.
if safeTruncateUses != 5 {
t.Errorf("truncateBindableText occurrences = %d, want 5 (1 declaration + 4 call sites); "+
"a site was added or removed — re-justify and re-pin", safeTruncateUses)
}
// The fifth requestUserAgent call site is fromNativeShell (TASK-3293),
// which only substring-matches the mobile shells' marker to choose
// plan-limit copy and binds nothing to a column. It goes through the
// helper because this scan admits no other raw reader.
// The sixth is the app-grant revoke's audit row (TASK-3399), the same
// audit shape as the MCP connection revoke beside it: the sanitised
// header into activities.user_agent.
// The seventh is the CLI sign-in request (TASK-2253): the sanitised
// header into cli_auth_sessions.requester_user_agent, which the approval
// page shows labelled as the requester's own claim.
// The eighth is the MCP request counter (TASK-2307), which only
// substring-matches the header into a closed client-class label and
// binds nothing to a column, like fromNativeShell.
if safeUAUses != 9 {
t.Errorf("requestUserAgent occurrences = %d, want 9 (1 declaration + 8 call sites); "+
"a site was added or removed — re-justify and re-pin", safeUAUses)
}
}
// TestOAuthRegisterRefusesNULBody is the last of codex round 14's three
// unwired-call-site findings. Reverting handlers_oauth.go to a bare
// json.NewDecoder — losing both the refusal and the size cap — would
// otherwise leave the suite green, and the static body-reader scan cannot
// catch it because that file is already listed for its FORM-body reads.
//
// It also pins the message split: a body carrying a NUL is valid JSON, so
// answering "Request body must be JSON" sends a client hunting a syntax error
// it does not have.
func TestOAuthRegisterRefusesNULBody(t *testing.T) {
srv := testServer(t)
srv.SetCloudMode("cloud-secret-for-test")
o, err := newTestOAuthServer(t, srv)
if err != nil {
t.Fatalf("oauth.NewServer: %v", err)
}
srv.SetOAuthServer(o)
post := func(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("POST", "/oauth/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.0.2.7:1234"
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
return rr
}
// Control: a well-formed registration must still be accepted, so a 400
// below cannot be the endpoint refusing everything.
control := post(t, `{"redirect_uris":["https://example.com/cb"],"client_name":"Probe"}`)
if control.Code >= 400 {
t.Fatalf("control registration must succeed, got %d: %s", control.Code, control.Body.String())
}
rr := post(t, `{"redirect_uris":["https://example.com/cb"],"client_name":"a`+escNULLiteral+`b"}`)
if rr.Code != http.StatusBadRequest {
t.Fatalf("a NUL-bearing registration must answer 400, got %d: %s", rr.Code, rr.Body.String())
}
if strings.Contains(rr.Body.String(), "must be JSON") {
t.Errorf("the body IS valid JSON; the message must not send the client after a syntax error: %s",
rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "NUL") {
t.Errorf("the refusal should name the cause, got %s", rr.Body.String())
}
}
// TestBodyDecodesNULMatchesKeysLikeTheDecoder is codex round 16: encoding/json
// matches an incoming key to a struct field by an exact match first and a
// CASE-INSENSITIVE one otherwise, so `{"Fields":...}` lands in
// ItemCreate.Fields exactly as `{"fields":...}` does. A case-sensitive lookup
// in the walk skipped the nested check for a body the handler then accepted,
// and the database answered the original 500.
//
// Measured before the fix: `fields` refused, `Fields` and `FIELDS` accepted.
func TestBodyDecodesNULMatchesKeysLikeTheDecoder(t *testing.T) {
inner := `{"k":"a` + escNULLiteral + `b"}`
// The last two are Unicode simple-fold spellings: encoding/json matches
// them to the same struct field, and lower-casing does NOT (codex round
// 17). U+017F LONG S folds to 's'; U+212A KELVIN SIGN folds to 'k'.
for _, key := range []string{"fields", "Fields", "FIELDS", "fIeLdS", "Schema", "TAGS",
"\u017Fchema", "\u017FCHEMA"} {
body := `{"title":"x","` + key + `":` + jsonEncode(t, inner) + `}`
if !bodyDecodesNUL([]byte(body)) {
t.Errorf("key %q reaches the same struct field as its lower-case spelling and must be "+
"walked the same way", key)
}
}
// Control: a key that is not a wire key in ANY casing stays caller data,
// so this is case-insensitive matching rather than matching everything.
notAKey := `{"title":"x","Notes":` + jsonEncode(t, inner) + `}`
if bodyDecodesNUL([]byte(notAKey)) {
t.Error("an unlisted key must not be treated as JSON-encoded whatever its casing")
}
}
// TestBodyScanClosesTheMapModelGaps pins the answers to the four measured
// disagreements between the old map[string]any scan and encoding/json's typed
// decode (BUG-2803 rounds 16-17), as BUG-2812's token scan gives them. They are
// documented on the scan ("WHAT CHANGED IN BUG-2812"); this keeps that block
// true. It replaced TestBodyDecodesNULKnownMapModelDisagreements, which
// asserted the two WRONG answers on purpose so that it would fail when this
// landed, and it did.
func TestBodyScanClosesTheMapModelGaps(t *testing.T) {
// (1) A NUL in a shadowed duplicate. The typed decode MERGES a repeated
// map member, so the NUL would reach the store. Every occurrence is a
// token, so it is seen, and the repeat is reported too.
dupKey := `{"fields_patch":{"orphan":"a` + escNULLiteral + `b"},"fields_patch":{"status":"open"}}`
if got := scanRequestBody([]byte(dupKey), foldMembers); !got.nul || got.repeat != "fields_patch" {
t.Errorf("(1) want the shadowed NUL AND the repeat, got %+v", got)
}
// (2) A number no float64 holds made the tree decode fail and the scan
// answer "no NUL", while the typed decode skipped the unknown field and
// stored the NUL title. Numbers are never converted now.
scanFail := `{"title":"a` + escNULLiteral + `b","ignored":1e999}`
if !bodyDecodesNUL([]byte(scanFail)) {
t.Error("(2) a NUL title beside an out-of-range number is not detected")
}
// (2b) The same hole through a different door: jsontext refuses a lone
// surrogate escape that encoding/json accepts (as U+FFFD). Had the scan
// called that body malformed it would have reported nothing, and the NUL
// beside it would have passed.
surrogate := `{"title":"a` + escNULLiteral + `b","x":"\ud800"}`
if !bodyDecodesNUL([]byte(surrogate)) {
t.Error("(2b) a NUL title beside a lone surrogate escape is not detected")
}
// (3) ACCEPTED over-refusal, unchanged: a field no handler reads is
// scanned. Observable compatibility change, stated in BUG-2803's release
// note.
unknown := `{"title":"valid","future_field":"a` + escNULLiteral + `b"}`
if !bodyDecodesNUL([]byte(unknown)) {
t.Error("(3) an unknown field carrying a NUL escape is no longer refused; update the " +
"disposition on scanRequestBody and the release note's compatibility line")
}
// (4) A case-variant duplicate is refused, now as a repeated request
// member rather than as an accepted over-refusal.
caseDup := `{"title":"a` + escNULLiteral + `b","TITLE":"safe"}`
if got := scanRequestBody([]byte(caseDup), foldMembers); got.repeat != "title" {
t.Errorf("(4) want the case-variant repeat reported as title, got %+v", got)
}
// Premise for (1) and (2): with the disagreement mechanism removed the
// payload is still detected, so neither leg passes against a scan that
// detects nothing.
if !bodyDecodesNUL([]byte(`{"fields_patch":{"orphan":"a` + escNULLiteral + `b"}}`)) {
t.Fatal("premise failed: the duplicate-key payload is not detectable when spelled once")
}
}
// TestBodyScanRefusesRepeatedRequestMembers pins where a repeat counts
// (BUG-2812 ruling, day 80), and that it is judged on EVERY body rather than
// only on one that carries an escape.
func TestBodyScanRefusesRepeatedRequestMembers(t *testing.T) {
cases := []struct {
name, body, want string
}{
{"no escape anywhere: still judged", `{"title":"a","title":"b"}`, "title"},
{"top level folds case", `{"title":"a","Title":"b"}`, "title"},
{"top level folds Unicode the way encoding/json does", `{"k":1,"` + "\u212a" + `":2}`, "k"},
{"nested exact repeat", `{"fields_patch":{"status":"a","status":"b"}}`, "status"},
{"nested case variants are two map keys", `{"fields_patch":{"status":"a","Status":"b"}}`, ""},
{"inside an array element", `{"items":[{"id":"1","id":"2"}]}`, "id"},
{"sibling objects share names without repeating", `{"items":[{"id":"1","t":"a"},{"id":"2","t":"b"}]}`, ""},
{"a parent's name after a child closes", `{"a":{"x":1},"x":2,"b":{"a":1}}`, ""},
// One level down both are compared exactly, so a child's names that
// outlived the child would collide with the parent's (at the top level
// the parent's are folded and cannot).
{"a nested parent's name after a child closes", `{"p":{"a":{"x":1},"x":2}}`, ""},
{"a parent repeat across a child", `{"a":1,"c":{"a":2},"A":3}`, "a"},
{"caller data under a JSON-encoded key is exempt", `{"fields":{"a":1,"a":2}}`, ""},
{"caller data carried as a string is not parsed for repeats", `{"fields":"{\"a\":1,\"a\":2}"}`, ""},
{"a distinct member is not a repeat", `{"title":"a","titles":"b"}`, ""},
{"malformed: nothing is reported, the decode phrases it", `{"title":"a","title":"b"`, ""},
{"trailing value: malformed", `{"title":"a","title":"b"} {}`, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := scanRequestBody([]byte(c.body), foldMembers).repeat; got != c.want {
t.Errorf("repeat = %q, want %q", got, c.want)
}
})
}
}
// TestDecodeJSONRefusesARepeatedMember drives the refusal through the real
// router, on a member that no per-struct check guards, so the 400 can only come
// from the body scan.
func TestDecodeJSONRefusesARepeatedMember(t *testing.T) {
srv := testServer(t)
ws := createWSWithCollections(t, srv)
it := createTaskWithFields(t, srv, ws, "orig", `{"status":"open"}`)
path := "/api/v1/workspaces/" + ws + "/items/" + it.Slug
rr := rawJSONRequest(srv, "PATCH", path, `{"title":"first","title":"second"}`)
if rr.Code != http.StatusBadRequest || !strings.Contains(rr.Body.String(), "title appears more than once") {
t.Fatalf("want 400 naming title, got %d %s", rr.Code, rr.Body.String())
}
if got, _ := srv.store.GetItem(it.ID); got == nil || got.Title != "orig" {
t.Fatalf("a refused write changed the title: %+v", got)
}
// Control: the same request with the member once is accepted.
if rr := rawJSONRequest(srv, "PATCH", path, `{"title":"second"}`); rr.Code != http.StatusOK {
t.Fatalf("control: %d %s", rr.Code, rr.Body.String())
}
}
// TestUnknownFieldRefusalThroughTheHandler is the WIRING leg for release-note
// item 10 (CONVE-19: a direct-call test vouches for the component, not its
// binding). TestBodyDecodesNULKnownMapModelDisagreements proves the scan
// RETURNS true for an unknown field carrying a NUL escape; the release note
// claims the API answers 400. Those are different claims, and only this one
// is the one an operator or client author reads.
//
// The control leg is what makes the 400 mean something: the SAME unknown
// field with an ordinary value must still be ACCEPTED, so this pins "refused
// for the NUL" rather than "refused for being unknown" — the handler does not
// reject unknown fields, and if it ever started to, the release note's
// explanation of the change would be wrong even though its status code
// stayed right.
func TestUnknownFieldRefusalThroughTheHandler(t *testing.T) {
srv := testServer(t)
rr := rawJSONRequest(srv, "POST", "/api/v1/workspaces/",
`{"name":"Unknown field probe","slug":"unkprobe","template":"startup"}`)
if rr.Code != http.StatusOK && rr.Code != http.StatusCreated {
t.Fatalf("fixture workspace: %d %s", rr.Code, rr.Body.String())
}
rr = rawJSONRequest(srv, "POST", "/api/v1/workspaces/unkprobe/collections/",
`{"name":"Probes","slug":"probes"}`)
if rr.Code != http.StatusOK && rr.Code != http.StatusCreated {
t.Fatalf("fixture collection: %d %s", rr.Code, rr.Body.String())
}
const itemsPath = "/api/v1/workspaces/unkprobe/collections/probes/items"
// Control: an unknown field is ordinarily IGNORED, not refused. On main
// this is the only behaviour there is — decodeJSONWithLimit unmarshals
// straight into the typed value, which drops the key.
control := rawJSONRequest(srv, "POST", itemsPath,
`{"title":"ok","future_field":"harmless"}`)
if control.Code != http.StatusCreated && control.Code != http.StatusOK {
t.Fatalf("an unknown field with an ordinary value must still be accepted, got %d: %s",
control.Code, control.Body.String())
}
// Release-note item 10: the same unknown field carrying a NUL escape is
// now refused, though the value reaches nothing. The scan has no
// destination type by design and cannot tell a forward-compatible field
// from a real one.
got := rawJSONRequest(srv, "POST", itemsPath,
`{"title":"ok","future_field":"a`+escNULLiteral+`b"}`)
if got.Code != http.StatusBadRequest {
t.Fatalf("release note item 10 says an unknown field carrying a NUL escape answers 400; "+
"got %d: %s — update the note or the check", got.Code, got.Body.String())
}
if !strings.Contains(got.Body.String(), "NUL") {
t.Errorf("the 400 should name the cause, got: %s", got.Body.String())
}
}
// TestDecodeJSONTrimsOnlyJSONWhitespace pins that the empty-body shortcut uses
// JSON's whitespace set, not Go's (codex round 22).
//
// bytes.TrimSpace uses unicode.IsSpace, which strips \v, \f, U+00A0 and more.
// encoding/json accepts none of those. So a body of just "\v" trimmed to
// EMPTY here and returned io.EOF, and an EOF-tolerant caller — playbook run
// treats errors.Is(err, io.EOF) as "no arguments supplied" and runs anyway —
// took a syntactically invalid body for an ABSENT one.
//
// The four legs matter in pairs: real JSON whitespace must still shortcut to
// EOF (or the playbook contract breaks), and non-JSON whitespace must NOT (or
// the divergence is still there).
func TestDecodeJSONTrimsOnlyJSONWhitespace(t *testing.T) {
for _, tc := range []struct {
name string
body string
wantEOF bool
}{
{"space and tab and newline", " \t\r\n", true},
{"empty", "", true},
{"vertical tab", "\v", false},
{"form feed", "\f", false},
{"non-breaking space", "\u00a0", false},
} {
t.Run(tc.name, func(t *testing.T) {
var v map[string]any
req := httptest.NewRequest("POST", "/x", strings.NewReader(tc.body))
err := decodeJSON(req, &v)
if err == nil {
t.Fatalf("expected an error for body %q", tc.body)
}
gotEOF := errors.Is(err, io.EOF)
if gotEOF != tc.wantEOF {
if tc.wantEOF {
t.Errorf("body %q is JSON whitespace and must read as an ABSENT body (io.EOF), "+
"or the playbook-run empty-body contract breaks; got %v", tc.body, err)
} else {
t.Errorf("body %q is NOT JSON whitespace — encoding/json would reject it — so it "+
"must NOT be reported as an absent body; an EOF-tolerant caller would proceed "+
"on invalid input. got io.EOF", tc.body)
}
}
})
}
}
// independentDecodesNUL is a SECOND implementation of the walker's contract,
// written for the test and deliberately not calling mapModelDecodesNUL.
//
// It exists because TestBodyDecodesNULGateAgreesWithAnUngatedWalk compares the
// gated function against an "ungated" reference that calls the SAME production
// walker (codex round 22). That comparison is valid for what it claims - it
// pins the raw-prefix GATE - but it cannot see a defect in the walker itself,
// because a walker bug is present identically on both sides and cancels.
//
// The two share encoding/json and jsonEncodedFieldKeys. They deliberately do
// not share traversal, descent, or key-matching code, which is where every
// walker defect in this unit actually lived (rounds 1, 2, 4, 16, 17).
func independentDecodesNUL(t *testing.T, raw []byte) bool {
t.Helper()
var root any
if err := json.Unmarshal(raw, &root); err != nil {
return false
}
hasNUL := func(s string) bool { return strings.ContainsRune(s, 0) }
// Iterative, explicit stack - a different shape from the production
// recursion, so a recursion-shaped bug cannot reproduce here by accident.
type frame struct {
v any
nested bool // already inside a re-parsed document; do not descend again
}
stack := []frame{{root, false}}
for len(stack) > 0 {
f := stack[len(stack)-1]
stack = stack[:len(stack)-1]
switch cur := f.v.(type) {
case string:
if hasNUL(cur) {
return true
}
case []any:
for _, e := range cur {
stack = append(stack, frame{e, f.nested})
}
case map[string]any:
for k, v := range cur {
if hasNUL(k) {
return true
}
if sv, ok := v.(string); ok {
if hasNUL(sv) {
return true
}
// One level of descent into a listed key's JSON document,
// and only when not already nested. Matched case-folded,
// the way encoding/json matches a wire key to a field.
if !f.nested {
for listed := range jsonEncodedFieldKeys {
if !strings.EqualFold(k, listed) {
continue
}
// ANY valid JSON document, scalars included.
//
// This was narrowed to objects and arrays in
// BUG-2803 codex round 27, to make the oracle
// agree with production. The disagreement was
// real; the direction of the fix was not. Nobody
// measured which walker matched POSTGRES, and the
// answer is that the oracle's original wider rule
// did:
//
// SELECT ('"a<escape>b"')::jsonb;
// ERROR: unsupported Unicode escape sequence
//
// measured on Postgres 17 during DOC-2823 S1. A
// bare JSON string is a complete jsonb document;
// it is refused there and was stored on SQLite,
// which is the dialect split the S1 guard exists
// to close. Production was widened to match, and
// this oracle is restored to what it always said.
//
// The lesson worth keeping: two implementations
// made to agree are not thereby correct. Round 27
// had two walkers and no authority, so agreement
// was the only available test and it picked the
// wrong survivor.
trimmed := strings.TrimSpace(sv)
if trimmed == "" {
break
}
var inner any
if json.Unmarshal([]byte(trimmed), &inner) == nil {
stack = append(stack, frame{inner, true})
}
break
}
}
continue
}
// A NATURAL object or array under a listed key is USER DATA:
// the server marshals it itself and nothing re-parses it, so
// a listed key appearing INSIDE it is an ordinary field name,
// not a document marker. Production expresses this by passing
// inUserData=true; this oracle must too, or the two disagree
// on {"fields":{"schema":"...escape text..."}} — production
// correctly says no, and an oracle that says yes would fail
// the comparison and blame the production walker.
//
// Measured: before this, production=false / oracle=true on
// exactly that body. My corpus omitted it, so the oracle
// passed while being wrong (codex round 26). A one-sidedness
// check that only asks whether BOTH answers appear does not
// catch a corpus that misses a whole branch of the contract.
nested := f.nested
if !f.nested {
for listed := range jsonEncodedFieldKeys {
if strings.EqualFold(k, listed) {
nested = true
break
}
}
}
stack = append(stack, frame{v, nested})
}
}
}
return false
}
// TestBodyDecodesNULAgainstAnIndependentOracle checks the WALKER, which the
// gate differential structurally cannot (codex round 22: shared-oracle blind
// spot). Disagreement means one of the two is wrong and both get read - the
// point is that a single shared bug can no longer hide.
func TestBodyDecodesNULAgainstAnIndependentOracle(t *testing.T) {
esc := escNULLiteral
bs := string([]byte{'\\', 'u', '0', '0', '5', 'c'})
doubled := `\\` + "u0000"
corpus := []string{
`{"title":"plain"}`,
`{"title":"quotes \" and a backslash \\ but no escape"}`,
`{"fields":"{\"k\":\"plain\"}"}`,
`{"fields":"{\"k\":\"a` + esc + `b\"}"}`,
`{"fields":"{\"k\":\"a` + bs + `u0000b\"}"}`,
`{"fields":"{\"k\":\"a` + doubled + `b\"}"}`,
`{"title":"a` + esc + `b"}`,
`{"a` + esc + `b":"key"}`,
`{"tags":["ok","a` + esc + `b"]}`,
`{"fields":"[1,2,\"a` + esc + `b\"]"}`,
`{"Fields":"{\"k\":\"a` + esc + `b\"}"}`,
`{"content":"{\"k\":\"a` + esc + `b\"}"}`,
`{"title":"unicode é 中"}`,
`{"nested":{"deep":{"deeper":"a` + esc + `b"}}}`,
// The branch the corpus used to miss entirely: a NATURAL object under
// a listed key, carrying escape TEXT that never becomes a NUL because
// nothing re-parses it. Both sides must answer FALSE here.
`{"fields":{"schema":"{\"k\":\"a` + doubled + `b\"}"}}`,
// And its counterpart, where the listed key's value IS a string and
// the descent is correct. Both sides must answer TRUE.
`{"fields":"{\"k\":\"a` + esc + `b\"}"}`,
// A JSON SCALAR under a listed key. Both sides must answer TRUE, and
// this case CHANGED ANSWER when the shape test was widened — correctly.
//
// The doubling is at the OUTER level: after the outer decode the fields
// value is a 10-character JSON string carrying a LIVE escape, so
// descending one layer finds a NUL. It answered FALSE before only
// because production refused to descend into scalars at all, and the
// old comment explained it as "the escape stays literal text", which
// was never the reason (codex round 2).
//
// Verified against the authority rather than argued, because I first
// re-pinned it FALSE from that stale comment and was wrong:
//
// fields value after the outer decode: a 10-char JSON string
// SELECT ('"a<escape>b"')::jsonb;
// ERROR: unsupported Unicode escape sequence
`{"fields":"\"a` + doubled + `b\""}`,
// The same scalar with a LIVE escape. Both sides must answer TRUE, and
// nothing pinned that until now: a bare JSON string is a complete jsonb
// document, measured refused by Postgres 17 with "unsupported Unicode
// escape sequence".
`{"fields":"\"a` + esc + `b\""}`,
`{"title":"a control escape that is not a NUL: \\u0001"}`,
}
// Bodies whose answer is known INDEPENDENTLY of either walker, so the test
// can catch the two of them agreeing on a wrong answer — which is exactly
// what happened before: BUG-2803 round 27 made them agree by narrowing the
// oracle, and both were then wrong about scalars for a release (codex
// round 2).
pinned := map[string]bool{
`{"fields":"\"a` + esc + `b\""}`: true,
`{"fields":"\"a` + doubled + `b\""}`: true,
`{"fields":"{\"k\":\"a` + esc + `b\"}"}`: true,
}
var sawTrue, sawFalse bool
for i, body := range corpus {
got := bodyDecodesNUL([]byte(body))
want := independentDecodesNUL(t, []byte(body))
if got != want {
t.Errorf("corpus[%d] %s\n production=%v independent=%v — one of the two walkers is "+
"wrong; read both before assuming it is the oracle", i, body, got, want)
}
if expected, ok := pinned[body]; ok && got != expected {
t.Errorf("corpus[%d] %s\n both walkers answer %v, but the correct answer is %v — "+
"agreement is not correctness", i, body, got, expected)
}
if want {
sawTrue = true
} else {
sawFalse = true
}
}
// The corpus must contain BOTH answers, or agreement proves nothing: two
// walkers that always say false agree perfectly.
if !sawTrue || !sawFalse {
t.Fatalf("corpus is one-sided (sawTrue=%v sawFalse=%v); agreement over it is vacuous",
sawTrue, sawFalse)
}
}
// mapModelDecodesNUL is the gate's walk as it stood before BUG-2812: a walk of
// the body decoded into map[string]any. It is kept here, and only here, as the
// UNGATED oracle for the differential below. On the corpus, which has no
// repeated keys and no out-of-range numbers, the map model and the token scan
// must agree; the two cases where they may not are the ones BUG-2812 fixed,
// pinned by TestBodyScanClosesTheMapModelGaps.
func mapModelDecodesNUL(v any, inUserData bool) bool {
// S1 (DOC-2823): once classing is switched OFF there is nothing
// request-specific left to do, and the walk is textguard's — literally the
// same traversal this function used to carry inline. Delegating rather than
// keeping a copy is the point of extracting the package: two walks of the
// same shape, in two layers, is the disagreement this cluster exists
// because of.
if inUserData {
return textguard.ValueDecodesNUL(v)
}
switch t := v.(type) {
case string:
return textguard.ContainsNUL(t)
case map[string]any:
for k, sub := range t {
if textguard.ContainsNUL(k) {
return true
}
if !inUserData && isJSONEncodedFieldKey(k) {
if str, isString := sub.(string); isString {
// BOTH checks. The nested walk answers "is there an
// escape inside the document this string carries"; it
// does NOT answer "does this string itself contain a
// NUL", and taking the JSON-encoded branch used to skip
// the plain check entirely — so a direct NUL in a
// `fields` value was accepted, reopening the door this
// whole change exists to close (codex round 9, a
// regression introduced by the round-8 restructure).
if textguard.ContainsNUL(str) || nestedDocumentDecodesNUL(str) {
return true
}
continue
}
// The field's NATURAL shape: an array or object whose
// elements the server marshals itself. Nothing re-parses
// them, so everything below is caller data.
if mapModelDecodesNUL(sub, true) {
return true
}
continue
}
if mapModelDecodesNUL(sub, inUserData) {
return true
}
}
case []any:
for _, sub := range t {
if mapModelDecodesNUL(sub, inUserData) {
return true
}
}
}
return false
}
// TestRepeatFoldFollowsTheTopLevelDestination pins which objects fold member
// names, from the destination's top-level kind (OpenCode review round 1
// on BUG-2812 found that "the top level is always a struct" was false: two
// endpoints decode into a map, and three into a slice of structs).
func TestRepeatFoldFollowsTheTopLevelDestination(t *testing.T) {
type s struct{ A string }
for _, c := range []struct {
name string
dst any
want repeatFold
}{
{"struct", &s{}, foldMembers},
{"pointer to pointer to struct", func() any { p := &s{}; return &p }(), foldMembers},
{"slice of structs", &[]s{}, foldElementMembers},
{"slice of struct pointers", &[]*s{}, foldElementMembers},
{"map", &map[string]string{}, foldNone},
{"slice of maps", &[]map[string]int{}, foldNone},
{"interface", new(any), foldNone},
{"nil", nil, foldNone},
} {
if got := repeatFoldFor(c.dst); got != c.want {
t.Errorf("%s: %v, want %v", c.name, got, c.want)
}
}
for _, c := range []struct {
name, body string
fold repeatFold
want string
}{
{"struct: case variants are one field", `{"slug":"a","Slug":"b"}`, foldMembers, "slug"},
{"map: case variants are two keys", `{"Foo":"a","foo":"b"}`, foldNone, ""},
{"map: an exact repeat still counts", `{"foo":"a","foo":"b"}`, foldNone, "foo"},
{"slice of structs: each element folds", `[{"slug":"a","Slug":"b"}]`, foldElementMembers, "slug"},
{"slice of structs: elements do not share names", `[{"slug":"a"},{"Slug":"b"}]`, foldElementMembers, ""},
{"struct destination, array body: nothing folds", `[{"slug":"a","Slug":"b"}]`, foldMembers, ""},
} {
if got := scanRequestBody([]byte(c.body), c.fold).repeat; got != c.want {
t.Errorf("%s: repeat = %q, want %q", c.name, got, c.want)
}
}
}
// TestReorderRefusesACaseVariantRepeat is the door the review named: PUT
// /workspaces/reorder decodes into a slice of structs, where encoding/json
// reads "Slug" into the slug field and the first value is lost.
func TestReorderRefusesACaseVariantRepeat(t *testing.T) {
srv := testServer(t)
owner := mustCreateUser(t, srv, "owner-reorder-2812@example.com", "Owner", "admin")
ws := mustCreateOwnedWorkspace(t, srv, "Reorder 2812", owner)
tok, err := srv.store.CreateSession(owner.ID, "go-test", "192.0.2.1", "go-test", 24*time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
// json.RawMessage marshals compacted but keeps both members.
body := json.RawMessage(`[{"slug":"` + ws.Slug + `","Slug":"other","sort_order":1}]`)
rr := doAuthedJSON(srv, http.MethodPut, "/api/v1/workspaces/reorder", body, tok)
if rr.Code != http.StatusBadRequest || !strings.Contains(rr.Body.String(), "slug appears more than once") {
t.Fatalf("want 400 naming slug, got %d %s", rr.Code, rr.Body.String())
}
control := json.RawMessage(`[{"slug":"` + ws.Slug + `","sort_order":1}]`)
if rr := doAuthedJSON(srv, http.MethodPut, "/api/v1/workspaces/reorder", control, tok); rr.Code >= 300 {
t.Fatalf("control: %d %s", rr.Code, rr.Body.String())
}
}