Skip to content

Commit ab24251

Browse files
committed
fix(driver-memory): $options without $regex is refused too — the one operator the allowlist would have leaked
Measured after the vocabulary landed: `{ field: { $options: 'i' } }` still escaped the ADR-0112 envelope on the live path (`unknown query operator $options`, no code, no status) while the reference matcher ignored it and matched EVERY row. #5324's exact shape, surviving for a single operator — because `$options` is in the vocabulary as a MODIFIER of `$regex`, not a predicate, so allowlisting the key without requiring its partner left the hole open for it alone. Refused when no `$regex` accompanies it, on both faces, and pinned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
1 parent 05a27ec commit ab24251

2 files changed

Lines changed: 41 additions & 0 deletions

File tree

‎packages/plugins/driver-memory/src/filter-refusal.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,28 @@ export function malformedBetweenError(field: string, value: unknown, path: strin
259259
);
260260
}
261261

262+
/**
263+
* [#5324] `$options` without the `$regex` it modifies.
264+
*
265+
* `$options` is in {@link SUPPORTED_FIELD_OPERATORS} as a MODIFIER, not a
266+
* predicate — it carries the regex flags (`memory-matcher` reads it as
267+
* `new RegExp(target, condition.$options)`, and objectql's `having` skips it for
268+
* the same reason). On its own it is not a filter at all, and the two faces
269+
* proved it: mingo raised `unknown query operator $options` — uncoded, the very
270+
* escape #5324 is about — while the matcher ignored it and matched EVERY row.
271+
* Allowlisting the key without requiring its partner would have left exactly one
272+
* operator still leaking out of the envelope.
273+
*/
274+
export function danglingRegexOptionsError(field: string, path: string): Error {
275+
return unsupportedFilterError(
276+
`Operator "$options" on field "${field}" at ${path} has no "$regex" to modify. "$options" ` +
277+
`carries the flags of a regex predicate (e.g. { "${field}": { "$regex": "abc", "$options": "i" } }); ` +
278+
`it is not a predicate on its own. It is refused rather than ignored because the two ` +
279+
`evaluation paths answered it differently — one raised an uncoded engine error, the other ` +
280+
`matched every row (#5324).`,
281+
);
282+
}
283+
262284
/** [#5324] `$and`/`$or` take a list of nodes; anything else is refused. */
263285
export function filterNodeListExpectedError(key: string, value: unknown, path: string): Error {
264286
return unsupportedFilterError(
@@ -365,6 +387,9 @@ function assertFieldConstraintShape(field: string, spec: unknown, path: string):
365387
throw malformedBetweenError(field, spec[op], `${path}.$between`);
366388
}
367389
}
390+
// `$options` is the one entry in the vocabulary that is a modifier rather than
391+
// a predicate, so it is the one that needs a companion.
392+
if (keys.includes('$options') && !keys.includes('$regex')) throw danglingRegexOptionsError(field, path);
368393
}
369394

370395
/**

‎packages/plugins/driver-memory/src/memory-filter-vocabulary-refusal.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,22 @@ describe('[#5324/#5328] a filter this driver cannot evaluate is refused, not ans
153153
});
154154
}
155155

156+
it('refuses "$options" with no "$regex" to modify, on both faces', async () => {
157+
// The one entry in the vocabulary that is a MODIFIER rather than a
158+
// predicate, and therefore the one that could be allowlisted into a fresh
159+
// leak: measured before this arm existed, `{ stage: { $options: 'i' } }`
160+
// still escaped as an uncoded `unknown query operator $options` on the live
161+
// path while the matcher ignored it and matched EVERY row — #5324's exact
162+
// shape, surviving for a single operator.
163+
const where = { stage: { $options: 'i' } };
164+
165+
const live = await liveRefusal(where);
166+
expectEnvelope(live);
167+
expect(live.message).toContain('has no "$regex" to modify');
168+
169+
expect(matcherRefusal(where).message).toBe(live.message);
170+
});
171+
156172
it('names the position, so a refusal deep in a scope tree is actionable', async () => {
157173
const err = await liveRefusal({ $or: [{ owner: 'u1' }, { $and: [{ stage: { $sounds_like: 'won' } }] }] });
158174
expectEnvelope(err);

0 commit comments

Comments
 (0)