Repository navigation
State the two requirements the specification relied on and never made - #13
Merged
Merged
Conversation
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>
Collaborator
Author
|
Follow-up pushed: CI ran only 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 citesSPEC.mdas 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.mdcontained 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.mdlists "an injection or escaping defect in the filter-to-SQL compiler" as in scope, andexperiments/filter-to-sqlhas 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.parsehas chosen a winner. So the requirement binds the parser, and §2.3 says so.Class of change
MUSThappenIf normative
decisions/is part of this PR —0002CHANGELOG.mdentry with a migration note$idversion is bumped if the grammar moved — it did not move. The schema is byte-identical and the$idstill namesv0.4.0. Both requirements constrain implementations rather than the grammar, and §9 ties the$idto the grammar: the same reasoning that left the$idalone through 0.5.0 and 0.6.0SPEC.md, the schema's operatordescriptions and the README operator table all agree — no operator changed, so none were touchedChecks
npm testpasses locally — 138npm run test:experimentpasses — 155, including the three new onesx-profilesstill covers exactly the operators the grammar definesServing and release
SPEC.md, so it needsnode sync.mjs <tag>at the next release for the new text to appear atopenpredicate.tech/spec— a tag alone does not finish a releaseMigration
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.parsecannot 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
QueryProblemor be bound — never interpolated, never silently dropped, since a dropped predicate widens the result set. Refusal counts as the stronger answer;$likelegitimately 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 1collided with the compiler's ownEXISTS (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/0002settles onmalformed-queryfor 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