Skip to content

State the two requirements the specification relied on and never made - #13

Merged
christosgkoros merged 3 commits into
mainfrom
spec/injection-and-duplicate-members
Sep 16, 2026
Merged

christosgkoros merged 3 commits into
mainfrom
spec/injection-and-duplicate-members

Conversation

@christosgkoros

Copy link
Copy Markdown
Collaborator

What this changes, and why

Two normative requirements the specification relied on and never stated. Both were surfaced by writing the security considerations for the IANA media type registration of application/vnd.openpredicate+json: RFC 6838 §4.6 makes that section mandatory and requires a registration reusing an existing format to describe the issues specific to that format. The honest answer meant writing, in a document that cites SPEC.md as the published specification, that the specification does not address them.

Argued in decisions/0002.

1. Injection — §7

The premise of this project is that a JSON predicate compiles to a backend query, and every operand in it came from the caller. SPEC.md contained no occurrence of injection, bound, or parameter in that sense — so the requirement that decides whether an implementation is safe or catastrophic was missing from the one place an independent implementer reads. The position already existed everywhere else: SECURITY.md lists "an injection or escaping defect in the filter-to-SQL compiler" as in scope, and experiments/filter-to-sql has bound every operand from the first commit.

§7 now requires operands to be bound, or escaped by the backend's own facility, and never concatenated into a statement.

It also adds the asymmetric rule for field paths, which is the part worth reviewing. An identifier generally cannot be bound as a parameter, so the symmetric rule is unavailable and the temptation is to escape the path instead. This rejects that: a path MUST be resolved against the exposed field set (§3.5) and anything unrecognised rejected with unknown-field (§8). Refusing an unknown path is a whitelist decidable from information the implementation already has; escaping one is a guess about the backend's quoting rules, and quoting rules are where identifier injection lives.

2. Duplicate member names — new §2.3

RFC 8259 §4 says object member names SHOULD be unique and declines to define the outcome otherwise. That is tolerable for a document and not for a predicate, because this design deliberately separates validation from evaluation — a JSON Schema approves the filter and something else executes it. Those are two parsers. Given

{ "age": { "$gt": 18, "$gt": 65 } }

a validator keeping the first member approves a filter an evaluator keeping the last then executes, and neither component has malfunctioned. For a predicate carrying tenancy or visibility, that is an authorization bypass assembled entirely out of conforming parts.

§2.3 requires rejection with malformed-query, not a precedence rule. Defining "last wins" would make the outcome predictable and fix nothing: the two parsers in a deployment are usually not both ours, so a rule stated here cannot make a third-party JSON parser obey it. Rejection also fails loudly, which is the stance §2.1 and §7 already take.

This cannot be expressed in the schema, and that is the point. JSON Schema constrains a parsed instance; by then the duplication has collapsed and JSON.parse has chosen a winner. So the requirement binds the parser, and §2.3 says so.

Class of change

  • Editorial
  • Substantive, compatible
  • Normative — changes what MUST happen

If normative

  • A decision record under decisions/ is part of this PR — 0002
  • CHANGELOG.md entry with a migration note
  • The schema $id version is bumped if the grammar moved — it did not move. The schema is byte-identical and the $id still names v0.4.0. Both requirements constrain implementations rather than the grammar, and §9 ties the $id to the grammar: the same reasoning that left the $id alone through 0.5.0 and 0.6.0
  • SPEC.md, the schema's operator descriptions and the README operator table all agree — no operator changed, so none were touched
  • The dissent of anyone who objected is recorded — no objections yet; this is the record inviting them

Checks

  • npm test passes locally — 138
  • npm run test:experiment passes — 155, including the three new ones
  • New or changed operators have a fixture — none changed
  • x-profiles still covers exactly the operators the grammar defines
  • Generator output unchanged — the generator emits the grammar, and the grammar did not move
  • No new runtime dependencies

Serving and release

  • This does not move the grammar version, so no new schema directory is needed. The site vendors SPEC.md, so it needs node sync.mjs <tag> at the next release for the new text to appear at openpredicate.tech/spec — a tag alone does not finish a release

Migration

Nothing changes for a caller except one case that never had a defined meaning: a filter containing a duplicate member name was previously accepted according to whichever reading the implementation's parser took, and is now rejected with malformed-query. No such filter had a specified meaning, so none can be said to have worked.

For an implementer, both are no-ops if operands are already bound and the parser already rejects duplicates. Otherwise the first describes a vulnerability rather than introducing one, and the second needs a check at the read boundary — JSON.parse cannot report duplicates, so it means a streaming parser, a reviver trick, or a pre-scan. That cost is recorded in the decision record rather than hidden.

What the tests do and do not cover

The injection requirement is now checkable: five hostile operands, each carrying a unique sentinel, go through every operator family taking a string operand, in both dialects, and each must either be refused with a QueryProblem or be bound — never interpolated, never silently dropped, since a dropped predicate widens the result set. Refusal counts as the stronger answer; $like legitimately refuses a lone \' as an invalid escape (§5.5).

Verified by mutation, not just by passing: making Ctx.p() interpolate strings fails all three new tests, and the compiler was restored clean afterwards.

Two earlier versions of this test were wrong in instructive ways, both now recorded in comments so the next person does not repeat them — probing for SELECT 1 collided with the compiler's own EXISTS (SELECT 1 FROM json_each(...)), and probing for the raw operand failed on $contains, which correctly escapes a literal % to \% so it cannot act as a wildcard.

The duplicate-member requirement gains no test, for the structural reason above. A normative requirement no test can reach is weaker than one a test can, and the asymmetry is recorded in the decision record rather than glossed over.

Open question for review

decisions/0002 settles on malformed-query for a duplicate member rather than a sixth problem type, on the grounds that it is a structural error and that five conditions each meaning something distinct beat six where two overlap. If you think a client would act differently on the distinction, that is the thing to push back on.


Generated with Claude Code

christosgkoros and others added 2 commits September 15, 2026 19:52
Writing the security considerations for the IANA media type registration
surfaced these. RFC 6838 section 4.6 makes that section mandatory, and the
honest answer required writing, in a document that cites SPEC.md as the
published specification, that the specification does not address them.

Injection first. The premise of this project is that a JSON predicate compiles
to a backend query, and every operand in it came from the caller -- so the one
obligation that decides whether an implementation is safe or catastrophic was
absent from the normative text, while the position existed everywhere else:
SECURITY.md lists an injection defect in the filter-to-SQL compiler as in scope,
and that compiler has bound every operand from the first commit. Section 7 now
requires operands to be bound or escaped by the backend's own facility, never
concatenated.

Field paths get the asymmetric rule, because an identifier usually cannot be
bound. A path MUST be resolved against the exposed field set and anything
unrecognised rejected with unknown-field, rather than escaped and passed
through. Refusing an unknown path is a whitelist; escaping one is a guess about
the backend's quoting rules, and that is where identifier injection lives.

Then duplicate member names. RFC 8259 says names SHOULD be unique and leaves
the outcome undefined otherwise. That is tolerable for a document and not for a
predicate, because this design deliberately validates with a JSON Schema and
evaluates somewhere else -- two parsers see the filter, and where they resolve
{"$gt": 18, "$gt": 65} differently the filter that was approved is not the
filter that ran, with neither component having malfunctioned. For a predicate
carrying tenancy that is an authorization bypass assembled out of conforming
parts. New section 2.3 requires rejection with malformed-query rather than a
precedence rule: a rule cannot make a third-party parser obey it, and a filter
whose meaning depends on a behaviour the specification cannot enforce should not
be accepted. The subsection also records that the section 7 limits bound
evaluation rather than parsing.

The grammar is unchanged: no operator moves, the schema is byte-identical and
the $id still names v0.4.0. Both requirements constrain implementations rather
than the grammar, which is the reasoning that left the $id alone through 0.5.0
and 0.6.0.

The injection half is made checkable. Five hostile operands, each carrying a
unique sentinel, go through every operator family that takes a string operand
in both dialects, and each must either be refused with a QueryProblem or be
bound -- never interpolated, never silently dropped. Refusal is the stronger
answer, and $like legitimately refuses a lone backslash-quote. Verified by
mutation: making the compiler interpolate strings fails all three tests. The
duplicate-member half gains no test, because JSON Schema constrains a parsed
instance and by then the duplication has collapsed -- which is the reason it
has to be normative prose at the parse boundary, and is recorded as such.

Co-Authored-By: Claude <noreply@anthropic.com>
…orced

The new section 7 requirement is checked in
experiments/filter-to-sql/compile.test.mjs, and CI ran only `npm test`, which
globs tests/*.test.mjs. So the test proving that operands are bound rather than
interpolated was not running anywhere except locally, and a test that does not
run enforces nothing.

The compiler remains an exercise rather than a deliverable; that is a statement
about what it is for, not a reason to leave its assertions unchecked. The suite
takes about 300ms.

Co-Authored-By: Claude <noreply@anthropic.com>
@christosgkoros

Copy link
Copy Markdown
Collaborator Author

Follow-up pushed: CI ran only npm test, which globs tests/*.test.mjs, so test:experiment — where the new §7 assertion actually lives — was not running anywhere except locally. The PR claimed the requirement was checkable while CI was not checking it. npm run test:experiment is now a step in the test job (~300ms).

The compiler stays an exercise rather than a deliverable; that is a statement about what it is for, not a reason to leave its assertions unrun.

The previous commit put `npm run test:experiment` in CI on all three matrix
versions, and the Node 20 job failed at module load: harness.mjs imports
node:sqlite, a builtin that does not exist before Node 22.5, so nothing in the
file ran. package.json declares engines.node >=20.10.0, so Node 20 belongs in
the matrix -- the assumption that needed stating was the test file's, not the
matrix's.

The harness is now loaded through a guarded dynamic import, and the four tests
that need rows back from a database carry a shared skip option that is false
whenever node:sqlite is present. Only harness.mjs needs the builtin;
compile.mjs, cases.mjs and dataset.mjs do not. So Node 20 still runs the
compiler-level half, including both section 7 tests asserting that hostile
operands are bound rather than interpolated, which is why the step was added to
CI in the first place. Node 22 and 24 keep executing the full corpus against
the real database, which is where the evidence for the result sets comes from.

The guard is narrow: it swallows ERR_UNKNOWN_BUILTIN_MODULE and rethrows
everything else, so a harness that breaks for any other reason still fails
loudly rather than reporting 149 tests as skipped.

Verified on Node 26 (155 pass, 0 skipped) and with the probe pointed at a
nonexistent builtin to simulate Node 20 (exit 0, 6 pass, 149 skipped with the
reason attached). No real Node 20 was available locally.

Co-Authored-By: Claude <noreply@anthropic.com>
@christosgkoros
christosgkoros merged commit 525d3c3 into main Sep 16, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant