mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
7376023ff2
* fix(server): the request-body gate reads tokens with jsontext, so a NUL behind a repeated key or beside 1e999 is refused, and a repeated request member is refused on every body (BUG-2812) The body gate and the store's JSON-column guard decoded into map[string]any and walked the tree. The tree is a lossy model: it keeps one value per repeated key, so a NUL in a shadowed copy passed (and the typed decode could merge it into storage), and a number no float64 holds made the decode fail, so the gate answered "no NUL" for a body the typed decode then accepted. Walking that tree also cost about 2x on any body that merely mentioned an escape. - internal/jsonscan wraps github.com/go-json-experiment/json/jsontext (pinned v0.0.0-20260820222146-c27c302e5fc3, BSD-3, The Go Authors). It is the module's only importer; its doc comment names the exit to stdlib encoding/json/jsontext. A differential test pins its acceptance to encoding/json v1's, which found that jsontext refuses a lone surrogate v1 accepts; that case falls back to v1's unquote. - scanRequestBody reads every body once, recording a NUL and a repeated request member separately and reading to the end, so malformed input still reports nothing. Repeats count in the request's own structure, and not in caller data under JSON-encoded keys (BUG-2896 collapses those). Names are case-folded where the destination is a struct: decodeJSONBytes reflects its destination's TOP-LEVEL type, so a struct folds its members, a slice of structs each element's, and a map (admin settings, admin user limits) none. Below the top level only an exact repeat counts. decodeJSONBytes and both bundle paths refuse either. - textguard's document walk and artifactIsBindableText read tokens too, so every layer refuses the shadowed NUL together: the one textguard.KnownGaps case moves into Corpus. - The store's suspect path stays, redundant but correct, by ruling; its deletion is its own unit. Its instruments now pin what is true: no refused value is a suspect, and the remaining suspects are the doubled-backslash class the destination accepts. - The map-model walk survives only as the differential's oracle. * fix(server): update vendorHash and the Postgres-only suspect instruments for the token scan (BUG-2812) The Postgres-backed legs of the suspect path fired when the shared predicate learned to see a NUL behind a repeated key: that value is now a violation everywhere, and the store's own guard refuses it before the destination cast. The preflight legs now assert that, the unmigrated- table fixture uses a doubled-backslash literal (still a suspect), and PostgreSQL's native refusal stays measured by TestNativePostgresAgreesWithTheCorpus. vendorHash is the value this PR's Nix job recomputed.
188 lines
7.9 KiB
Go
188 lines
7.9 KiB
Go
// Package textguard holds the ONE decoded-NUL predicate that every layer of
|
|
// Pad's NUL invariant shares.
|
|
//
|
|
// It exists because BUG-2803's whole history is layers disagreeing about one
|
|
// value. The HTTP gate refuses a request body whose JSON decodes to a NUL; the
|
|
// store's write guard refuses a bound parameter that does; Postgres refuses it
|
|
// natively; a SQLite trigger will refuse it in the database. Four answers to
|
|
// one question is only safe if they are one implementation, so this package is
|
|
// the implementation and each layer supplies only its own INPUT.
|
|
//
|
|
// WHAT IS SHARED AND WHAT IS NOT, because getting this line wrong is how the
|
|
// bug family breeds. Shared: whether a value, interpreted as text or as a JSON
|
|
// document, yields a NUL. NOT shared: how a layer decides that a given value
|
|
// IS a JSON document. The HTTP gate derives that from request-body KEY NAMES
|
|
// (fields, schema, settings - the keys whose string value something downstream
|
|
// re-parses); the store derives it from the COLUMN a parameter is bound to,
|
|
// via the 86-column classification. Those two derivations cannot be merged -
|
|
// one has keys, the other has columns - so they are made explicit instead:
|
|
// every entry point here takes the JSON-ness as an argument, and the four-way
|
|
// differential test pins that both derivations agree on one corpus.
|
|
package textguard
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"strings"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/jsonscan"
|
|
)
|
|
|
|
// escapePrefix is the only spelling of a NUL a JSON document can carry. Used
|
|
// as a cheap pre-filter: a value without it cannot decode to a NUL, so the
|
|
// expensive parse is skipped.
|
|
//
|
|
// It is a PRE-FILTER on the escape's presence, never a decision about its
|
|
// meaning. BUG-2803's recorded-wrong parity filter tried to decide from raw
|
|
// bytes whether an escape was live or literal and could not: a doubled
|
|
// backslash makes the same six characters literal text, and only a decode
|
|
// tells the two apart. Anything cheaper than a decode is layer-confused.
|
|
const escapePrefix = `\u00`
|
|
|
|
// ContainsNUL reports whether the string carries a real NUL byte.
|
|
//
|
|
// This is the universal half of the invariant and it needs no classing: no
|
|
// column of any type in any dialect wants one. Postgres refuses it outright
|
|
// (SQLSTATE 22021 / 22P05), SQLite stores it and then disagrees with itself
|
|
// about the value's length.
|
|
func ContainsNUL(s string) bool { return strings.ContainsRune(s, 0) }
|
|
|
|
// IsJSONDocument reports whether a string is a complete JSON OBJECT OR ARRAY -
|
|
// the shape a downstream consumer treats as a nested document.
|
|
//
|
|
// Objects and arrays only, deliberately, and this is the HTTP gate's rule
|
|
// unchanged: it decides whether a request field's string value is a nested
|
|
// document worth walking, and a bare scalar in that position is just a string
|
|
// the gate has already checked directly.
|
|
//
|
|
// The STORE cannot use this rule, because jsonb accepts a scalar as a complete
|
|
// document: a bare JSON string consisting of the NUL escape is a valid jsonb
|
|
// value that Postgres refuses and SQLite stores, which is the dialect split all
|
|
// over again (codex round 1, finding 1). DocumentDecodesNULAnyShape is the
|
|
// store's version. Two rules, two callers, and the difference is which question
|
|
// is asked - "is this a nested document" versus "will a jsonb parser read it".
|
|
func IsJSONDocument(s string) bool {
|
|
t := strings.TrimSpace(s)
|
|
if len(t) == 0 || (t[0] != '{' && t[0] != '[') {
|
|
return false
|
|
}
|
|
return json.Valid([]byte(t))
|
|
}
|
|
|
|
// DocumentDecodesNUL walks a JSON document carried as a string and reports
|
|
// whether anything in it decodes to a NUL - in a value or in a KEY.
|
|
//
|
|
// This is the layer Postgres itself parses, so an escape here is fatal where
|
|
// one a level deeper is not: a fields blob bound for jsonb is refused with
|
|
// 22P05 while the same escape inside a string the blob merely contains is
|
|
// stored intact.
|
|
//
|
|
// Returns false for a string that is not a JSON document at all, so a caller
|
|
// that is unsure may pass anything.
|
|
func DocumentDecodesNUL(s string) bool {
|
|
if !strings.Contains(s, escapePrefix) || !IsJSONDocument(s) {
|
|
return false
|
|
}
|
|
return TokensDecodeNUL([]byte(strings.TrimSpace(s)))
|
|
}
|
|
|
|
// DocumentDecodesNULAnyShape is DocumentDecodesNUL widened to every JSON
|
|
// document shape a jsonb column accepts, scalars included.
|
|
//
|
|
// The store's write guard uses this one. A bare JSON string, a number, a
|
|
// boolean and null are all complete documents to Postgres; the first of them
|
|
// can decode to a NUL, and the object/array-only rule walked straight past it.
|
|
func DocumentDecodesNULAnyShape(s string) bool {
|
|
t := strings.TrimSpace(s)
|
|
if t == "" || !strings.Contains(t, escapePrefix) || !json.Valid([]byte(t)) {
|
|
return false
|
|
}
|
|
return TokensDecodeNUL([]byte(t))
|
|
}
|
|
|
|
// TokensDecodeNUL walks a VALID JSON document token by token and reports
|
|
// whether any string in it, key or value, decodes to a NUL (BUG-2812).
|
|
//
|
|
// It replaced a decode into `any` followed by a walk of the tree, which got
|
|
// two things wrong because the tree is a lossy model of the document:
|
|
//
|
|
// - A REPEATED KEY. A map keeps one value per key, so the walk never saw a
|
|
// value a later duplicate shadowed. Postgres's jsonb parser reads every
|
|
// occurrence before it deduplicates, and refuses the shadowed escape, so
|
|
// the guard admitted what the database refused.
|
|
// - A NUMBER NO float64 HOLDS. `1e999` is valid JSON that json.Unmarshal
|
|
// into `any` refuses with a range error, so the walk answered "no NUL" for
|
|
// a document it never read.
|
|
//
|
|
// A token stream has neither loss: every occurrence arrives, and a number is
|
|
// never converted. It also builds no tree, which is the amplification half of
|
|
// the same bug (see package jsonscan for why that needs jsontext).
|
|
//
|
|
// The caller validates first. A document that fails part-way answers false,
|
|
// the same "not a JSON document" answer the entry points give malformed input.
|
|
func TokensDecodeNUL(doc []byte) bool {
|
|
sc := jsonscan.NewScanner(doc)
|
|
for {
|
|
k, str, err := sc.Next()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if k == jsonscan.String && bytes.IndexByte(str, 0) >= 0 {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
// ValueDecodesNUL walks an ALREADY-DECODED JSON value for a NUL in any string
|
|
// or any object key.
|
|
//
|
|
// No key-classing here, deliberately: at this depth every string is caller
|
|
// data, and a caller that needs classing does it before calling - which is the
|
|
// package's whole contract. The HTTP gate's key-classed walk lives with the
|
|
// gate, calls this for the unclassed subtrees, and is the reason this function
|
|
// is exported rather than folded into DocumentDecodesNUL.
|
|
func ValueDecodesNUL(v any) bool {
|
|
switch t := v.(type) {
|
|
case string:
|
|
return ContainsNUL(t)
|
|
case map[string]any:
|
|
for k, sub := range t {
|
|
if ContainsNUL(k) {
|
|
return true
|
|
}
|
|
if ValueDecodesNUL(sub) {
|
|
return true
|
|
}
|
|
}
|
|
case []any:
|
|
for _, sub := range t {
|
|
if ValueDecodesNUL(sub) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ParameterRefused reports whether a value bound as a SQL parameter violates
|
|
// the invariant, and is the store layer's single entry point.
|
|
//
|
|
// isJSON is the caller's classification, not a guess made here - see the
|
|
// package doc. When true the value is additionally walked as a JSON document,
|
|
// which is what catches the escape form that reaches Postgres's jsonb parser
|
|
// still spelled as an escape and is refused there with 22P05.
|
|
//
|
|
// The raw check runs REGARDLESS of isJSON: a JSON-classed parameter carrying a
|
|
// literal NUL byte is refused by the same rule as any other text, and skipping
|
|
// it for JSON-classed values is precisely the regression codex round 9 caught
|
|
// in the HTTP gate's own walk.
|
|
func ParameterRefused(value string, isJSON bool) bool {
|
|
if ContainsNUL(value) {
|
|
return true
|
|
}
|
|
// ANY SHAPE, not just objects and arrays: a jsonb column accepts a bare
|
|
// scalar as a complete document, and a scalar can decode to a NUL (codex
|
|
// round 1, finding 1).
|
|
return isJSON && DocumentDecodesNULAnyShape(value)
|
|
}
|