Skip to content

A filter with an operator outside VALID_AST_OPERATORS is silently dropped, not rejected — single-condition views return unfiltered results #3948

Description

@os-zhuang

A bare-triple filter whose operator is not in VALID_AST_OPERATORS is passed through unconverted and then skipped entirely by the SQL driver. No WHERE clause is emitted, no error is raised, and the caller gets every row. The user sees a list they believe is filtered and it isn't.

This is reachable today through an ordinary ObjectUI view-config affordance — it is not hypothetical.

The chain

Each hop verified by reading the code; the spec hop was executed.

1. isFilterAST gates a bare triple on the operator being known — packages/spec/src/data/filter.zod.ts:400:

if (filter.length >= 2 && typeof filter[1] === 'string') {
  return VALID_AST_OPERATORS.has(filter[1].toLowerCase());
}

2. The protocol treats that as a conversion gate, not a validation gate — packages/metadata-protocol/src/protocol.ts:2754-2757:

if (isFilterAST(parsedFilter)) {
  parsedFilter = parseFilterAST(parsedFilter);
}
options.where = parsedFilter;   // <-- unconverted array assigned as-is

3. applyFilters walks that array — packages/plugins/driver-sql/src/sql-driver.ts:3702-3708. For a bare triple the three elements are all strings, so each hits the string branch, matches neither and nor or, and is continued:

for (const item of filters) {
  if (typeof item === 'string') {
    if (item.toLowerCase() === 'or') nextJoin = 'or';
    else if (item.toLowerCase() === 'and') nextJoin = 'and';
    continue;                     // <-- 'close_date', 'before', '2024-01-01' all land here
  }
  if (Array.isArray(item)) { ... }
}

No Array.isArray branch is ever taken. No predicate is emitted.

driver-memory has the same shape (default: return null, memory-driver.ts:768).

Executed evidence

Against published @objectstack/spec:

["close_date","before","2024-01-01"]     isFilterAST=false   *** PASSED THROUGH UNCONVERTED ***
["close_date","after","2024-01-01"]      isFilterAST=false   *** PASSED THROUGH UNCONVERTED ***
["stage","not in",["won","lost"]]        isFilterAST=false   *** PASSED THROUGH UNCONVERTED ***
["status","=","active"]                  isFilterAST=true    converted -> {"status":"active"}

Why those three operators, and why it is reachable

VALID_AST_OPERATORS (filter.zod.ts:352) contains not_in and nin but not 'not in' (with a space), and contains neither before nor after.

ObjectUI's view-config builder emits exactly those three verbatim — packages/plugin-view/src/config/view-config-utils.ts:46:

'notIn':  'not in',     // space — VALID_AST_OPERATORS has not_in / nin
'before': 'before',     // absent
'after':  'after',      // absent

and data-objectstack's normalizeFilterOperator ends ?? op, so they reach the wire unchanged.

The trigger is the single-condition case. toSpecFilter (view-config-utils.ts:158) returns a bare triple when there is exactly one condition with AND logic:

if (triplets.length === 1 && logic === 'and') return triplets[0];

So: an author adds one date filter "before " to a view → the saved view returns every row, silently. Add a second condition and the shape becomes nested, which reaches the driver's wider switch and throws instead — the failure mode flips on how many conditions the user happened to add.

Scope of impact

Not a permission bypass, to be explicit about it. Row-scoping middleware $and-composes onto the same ast.where, so a scoped object yields {$and: [<raw array>, <scope>]}; the $and arm (sql-driver.ts:3987) calls applyFilterCondition on each element, and Object.entries(['close_date','before','2024-01-01']) produces keys "0"/"1"/"2" — it would attempt to filter on columns named 0, 1, 2 and error. The scope arm survives. (This arm is reasoned from the code, not executed.)

So the impact is: on objects without row-scoping, a silently unfiltered result set; on scoped objects, a confusing SQL error. Both are wrong; neither crosses a permission boundary.

Backends disagree on the failure mode

Path Unknown operator →
driver-sql, $-object throw (sql-driver.ts:4016)
driver-sql, nested AST throw (sql-driver.ts:3866)
driver-sql, bare triple silently drops the filter
driver-memory, AST silently drops the condition (memory-driver.ts:768)
driver-mongodb, AST passes $${op} raw to Mongo → server error
mapAnalyticsOperator silently becomes $eq (protocol.ts:3800)
matches-filter (RLS) false — fail closed
analytics preview-evaluator true — fail open (preview-evaluator.ts:44)

Six behaviours for one input class.

Suggested fix

  1. In applyFilters (sql-driver.ts:3702) and memory-driver.ts:768: an array item that is neither a recognised join keyword nor an array — and a bare triple whose operator is unrecognised — should throw, matching what the nested and $-object paths already do. Silently emitting no predicate is the one outcome that must not happen.
  2. Consider making protocol.ts:2754 reject rather than pass through: if the value looks like a filter array but isFilterAST says no, that is a malformed filter, not an opaque where.
  3. Independently, before / after / 'not in' are ObjectUI-side spellings that no spec vocabulary defines — tracked at Vocabulary consolidation (#2901): what shipped, and why Track C items 1–3 are not planned objectui#2945. They should be lowered client-side to < / > / not_in (ObjectUI's own ListView.tsx:75 already does exactly this for before/after; the view-config persistence path does not). Fixing that removes today's trigger but not the underlying silent-drop.

Why this blocks other work

Any narrowing of VALID_AST_OPERATORS — e.g. consolidating the filter-operator vocabularies — converts working bare-triple filters into silently unfiltered queries. Item 1 above should land before any such narrowing is attempted, otherwise the change is untestable: it passes CI, passes os build, and degrades quietly against stored view metadata.

Found while auditing spec-enum ↔ renderer coverage for objectstack-ai/objectui#2901.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions