From 2a3d14ef04876a45b51c093c282d5d53dbe9fd96 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 06:08:48 +0000 Subject: [PATCH 1/2] fix(plugin-auth): translate better-auth `contains` to `$contains`, not a bare `$regex` (#5710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `convertWhere()` emitted `{ field: { $regex: value } }` for better-auth's `contains`, putting an unescaped caller-supplied comparand into a PATTERN position on the authentication path. driver-memory evaluated it as a real RegExp (`a.b` matched `axb`; an unbalanced `(` threw on the mingo path and silently matched nothing on the reference matcher), while driver-sql / -sqlite-wasm / -turso compiled it to an escaped substring LIKE — one auth query, two answers. `$contains` is in the spec's FILTER_OPERATORS and means a literal substring on every backend, which is what better-auth's `contains` means (`Where.mode` defaults to `"sensitive"`, matching the #5701 Q2=A ruling). This also retires the last live producer of `$regex`, which is the ordering constraint #5702's loud refusal waits on. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv --- .changeset/auth-contains-literal-substring.md | 27 +++ packages/plugins/plugin-auth/package.json | 1 + .../src/auth-contains-filter.test.ts | 172 ++++++++++++++++++ .../plugin-auth/src/objectql-adapter.ts | 29 ++- pnpm-lock.yaml | 3 + 5 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 .changeset/auth-contains-literal-substring.md create mode 100644 packages/plugins/plugin-auth/src/auth-contains-filter.test.ts diff --git a/.changeset/auth-contains-literal-substring.md b/.changeset/auth-contains-literal-substring.md new file mode 100644 index 0000000000..265f6c8beb --- /dev/null +++ b/.changeset/auth-contains-literal-substring.md @@ -0,0 +1,27 @@ +--- +"@objectstack/plugin-auth": patch +--- + +fix(plugin-auth): better-auth 的 `contains` 下译为 `$contains`,比较值不再当正则求值 (#5710) + +`convertWhere()` 把 better-auth 的 `contains` 译成 `{ field: { $regex: value } }`, +于是一个**未转义、来自调用方**的比较值(`/admin/list-users` 的 `searchValue`、 +SCIM 过滤值)坐进了正则的**模式位**。它的含义随后端分叉: + +- `driver-memory` 用 `new RegExp(value)` 求值 —— `contains('a.b')` 命中 `axb`, + `^x` 变成锚定,而值里一个不配对的 `(` 让模式非法(mingo 查询路径直接抛 + `SyntaxError`,参考匹配器则吞成静默零命中); +- `driver-sql` / `driver-sqlite-wasm` / `driver-turso` 编成子串 + `LIKE '%value%'`(`%`/`_`/`\` 有转义、带显式 `ESCAPE`),元字符是字面量。 + +同一个认证查询,在应用测试常用的内存替身上和生产的 SQL 后端上给出**不同答案**, +且分叉发生在认证路径上。 + +现在这一支发出 `$contains` —— 协议 `FILTER_OPERATORS` 里的算子,五后端都必须按 +**字面子串**求值,正是 better-auth `contains` 的本意(其 `Where.mode` 默认 +`"sensitive"`,与 #5701 Q2=A 裁定的 `$contains` 大小写敏感契约同向)。 + +**对使用方的影响**:凭 `/admin/list-users?searchValue=…` 之类接口依赖「元字符按正则 +生效」的调用会改变结果 —— 那是本次修复的缺陷本身,不是可依赖的行为。搜索 +`a.b` 从此只命中含字面 `a.b` 的行,不再命中 `axb`;含非法正则字符的搜索值不再 +报错或静默返回空,而是按字面子串匹配。 diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 95b520d392..7855855ba6 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -33,6 +33,7 @@ "jose": "^6.2.5" }, "devDependencies": { + "@objectstack/driver-memory": "workspace:*", "@objectstack/objectql": "workspace:*", "@types/node": "^26.1.2", "hono": "^4.12.34", diff --git a/packages/plugins/plugin-auth/src/auth-contains-filter.test.ts b/packages/plugins/plugin-auth/src/auth-contains-filter.test.ts new file mode 100644 index 0000000000..f6097b0d85 --- /dev/null +++ b/packages/plugins/plugin-auth/src/auth-contains-filter.test.ts @@ -0,0 +1,172 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#5710] better-auth's `contains` is a LITERAL substring search — the adapter + * must not translate it into a bare `$regex`. + * + * `convertWhere()` used to emit `{ field: { $regex: condition.value } }`, which + * puts an unescaped, caller-supplied comparand (`/admin/list-users`' + * `searchValue`, a SCIM filter value) into a PATTERN position. What that value + * then means depended on the backend under the auth path: + * + * - driver-memory compiled it to `new RegExp(value)` — `a.b` matched `axb`, + * `^x` anchored, and an unbalanced `(` was an illegal pattern; + * - driver-sql / -sqlite-wasm / -turso compiled it to a substring + * `LIKE '%value%'` with `%`/`_`/`\` escaped — metacharacters literal. + * + * So one better-auth query answered differently on the memory double an app's + * tests run against and on the SQL backend production runs (#4706's shape, on + * the authentication path). These pins hold the operator at `$contains` — a + * member of the spec's `FILTER_OPERATORS`, i.e. one every backend is required + * to evaluate, as a literal substring. + * + * Two faces, deliberately: the first pins WHAT the adapter emits (the contract), + * the second pins what a real backend then ANSWERS (the behaviour). The first + * alone cannot see a translation that is spelled right and evaluated wrong; the + * second alone cannot say which operator earned the result. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { FILTER_OPERATORS } from '@objectstack/spec/data'; +import type { IDataEngine } from '@objectstack/core'; +import { createObjectQLAdapterFactory } from './objectql-adapter'; + +/** Keeps the driver's own lifecycle logging out of the test output. */ +const silentLogger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +} as any; + +/** + * A read-only engine facade over a REAL `InMemoryDriver`. + * + * Only the three read verbs the `contains` path uses are declared. The write + * verbs are deliberately absent rather than stubbed: seeding goes through the + * driver directly (below), so a hand-written `delete`/`update` here would be a + * dispatch contract this test neither needs nor is able to honour + * (`check:engine-double-contract`, #4550). + */ +function memoryReadEngine(driver: InMemoryDriver): IDataEngine { + return { + find: (object: string, query?: any) => driver.find(object, (query ?? {}) as any), + findOne: (object: string, query?: any) => driver.findOne(object, (query ?? {}) as any), + count: (object: string, query?: any) => driver.count(object, (query ?? {}) as any), + } as unknown as IDataEngine; +} + +const NOW = new Date('2026-08-06T00:00:00.000Z').toISOString(); + +/** Rows whose `name`s differ only in how a regex would read the comparand. */ +const SEED = [ + { id: 'u_literal', name: 'a.b', email: 'literal@example.com' }, + { id: 'u_wildcard', name: 'axb', email: 'wildcard@example.com' }, + { id: 'u_paren', name: 'x(y', email: 'paren@example.com' }, +]; + +async function seededAdapter() { + const driver = new InMemoryDriver({ logger: silentLogger }); + await driver.connect(); + for (const row of SEED) { + await driver.create('sys_user', { ...row, emailVerified: false, createdAt: NOW, updatedAt: NOW }); + } + const adapter: any = (createObjectQLAdapterFactory(memoryReadEngine(driver)) as any)({} as any); + return { driver, adapter }; +} + +/** `findMany` with a single better-auth `contains` condition on `name`. */ +function containsQuery(value: string) { + return { + model: 'user', + where: [{ field: 'name', value, operator: 'contains', connector: 'AND' }], + limit: 100, + } as any; +} + +describe('[#5710] convertWhere: better-auth `contains` → `$contains`', () => { + let engine: IDataEngine; + + beforeEach(() => { + engine = { + insert: vi.fn().mockResolvedValue({ id: '1' }), + findOne: vi.fn().mockResolvedValue(null), + find: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + } as unknown as IDataEngine; + }); + + it('emits `$contains`, never a bare `$regex`, for a `contains` search', async () => { + const adapter: any = (createObjectQLAdapterFactory(engine) as any)({} as any); + await adapter.findMany(containsQuery('a.b')); + + const [object, query] = (engine.find as any).mock.calls[0]; + expect(object).toBe('sys_user'); + expect(query.where).toEqual({ name: { $contains: 'a.b' } }); + // Spelled out separately: `toEqual` above would still pass if a second, + // regex-shaped limb were added under a different field. + expect(JSON.stringify(query.where)).not.toContain('$regex'); + }); + + it('emits an operator every backend is required to evaluate', () => { + // The whole point of the flip: `$contains` is in the protocol's runtime + // allowlist, `$regex` never was — it survived only because this adapter + // produced it (driver-memory's `filter-refusal.ts` says so in as many + // words), which is why #5702's loud refusal is ordered after this PR. + expect(FILTER_OPERATORS).toContain('$contains'); + expect(FILTER_OPERATORS).not.toContain('$regex'); + }); + + it('leaves the other operators untouched', async () => { + const adapter: any = (createObjectQLAdapterFactory(engine) as any)({} as any); + await adapter.findMany({ + model: 'user', + where: [ + { field: 'email', value: 'a@b.com', operator: 'eq', connector: 'AND' }, + { field: 'name', value: ['x', 'y'], operator: 'in', connector: 'AND' }, + ], + limit: 100, + } as any); + + const [, query] = (engine.find as any).mock.calls[0]; + expect(query.where).toEqual({ email: 'a@b.com', name: { $in: ['x', 'y'] } }); + }); +}); + +describe('[#5710] the comparand is a literal substring on a real backend', () => { + it('does not read `.` as a wildcard — `a.b` matches `a.b`, not `axb`', async () => { + const { adapter } = await seededAdapter(); + const rows: any[] = await adapter.findMany(containsQuery('a.b')); + + // The pin, stated in both directions: the metacharacter row is NOT matched + // (a bare `$regex` matched it through `.`), and the literal row still is. + expect(rows.map((r) => r.name)).toEqual(['a.b']); + }); + + it('does not read `^` as an anchor', async () => { + const { adapter } = await seededAdapter(); + const rows: any[] = await adapter.findMany(containsQuery('^a')); + + // As a pattern, `^a` matched `a.b` and `axb`. As a substring, nothing here + // contains the two characters `^a`. + expect(rows).toEqual([]); + }); + + it('matches a value that is not a legal regex, instead of failing on it', async () => { + const { adapter } = await seededAdapter(); + const rows: any[] = await adapter.findMany(containsQuery('(')); + + // `new RegExp('(')` throws — under `$regex` this comparand could not + // produce an answer at all (the mingo path threw, the reference matcher + // swallowed it into a silent no-match). It is just a character now. + expect(rows.map((r) => r.name)).toEqual(['x(y']); + }); + + it('answers an ordinary metacharacter-free search unchanged', async () => { + const { adapter } = await seededAdapter(); + const rows: any[] = await adapter.findMany(containsQuery('xb')); + + expect(rows.map((r) => r.name)).toEqual(['axb']); + }); +}); diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.ts b/packages/plugins/plugin-auth/src/objectql-adapter.ts index eccd7fe52f..1926859ed4 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -134,7 +134,34 @@ function convertWhere(where: CleanedWhere[]): Record { } else if (condition.operator === 'lte') { filter[fieldName] = { $lte: condition.value }; } else if (condition.operator === 'contains') { - filter[fieldName] = { $regex: condition.value }; + // [#5710] `$contains`, NOT `$regex`. better-auth's `contains` is a + // LITERAL substring search (`Where.mode` defaults to `"sensitive"`, and + // the value comes straight from a caller — `/admin/list-users`' + // `searchValue`), while `$regex` puts that value in a PATTERN position. + // + // What the bare `$regex` did, per backend, to one `contains('a.b')`: + // - driver-memory: `new RegExp('a.b')` — `.` is a wildcard, so it + // matched `axb`; an unbalanced `(` in the value made the pattern + // illegal (a throw on the mingo path, a silent no-match on the + // reference matcher's). + // - driver-sql / -sqlite-wasm / -turso: compiled to a substring + // `LIKE '%a.b%'` — metacharacters literal. + // One operator, two answers, and the divergence only shows up when the + // app's tests run on the memory double and production runs SQL (#4706). + // + // `$contains` is in the spec's `FILTER_OPERATORS` and every backend + // evaluates it as a literal substring (SQL side escapes `%`/`_`/`\` and + // emits an explicit `ESCAPE`), which is exactly better-auth's meaning. + // Case semantics follow the #5701 Q2=A ruling (`$contains` is + // case-SENSITIVE at the contract layer; the per-driver alignment is + // #5702's budget), which matches better-auth's own `mode: 'sensitive'` + // default. + // + // This is also the last live producer of `$regex` — it is what + // `driver-memory/src/filter-refusal.ts` means by "refusing it here would + // break a live producer", and the reason #5702's loud refusal is ordered + // after this flip. + filter[fieldName] = { $contains: condition.value }; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32f0d88ee4..4922e03e37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1432,6 +1432,9 @@ importers: specifier: ^6.2.5 version: 6.2.7 devDependencies: + '@objectstack/driver-memory': + specifier: workspace:* + version: link:../../drivers/driver-memory '@objectstack/objectql': specifier: workspace:* version: link:../../objectql From a190dfbc400af499a29542d4d1aedf2ddc2fa157 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 06:21:04 +0000 Subject: [PATCH 2/2] test(plugin-auth): type the memory-engine facade's query bag instead of erasing it to `any` (#5710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new pin's read-only engine facade forwarded its query with `as any`, which the query-options-erasure ratchet counts on the test surface too (267 -> 270). Declare the driver-side `QueryAST` and forward it unchanged — nothing here is deliberately off-contract, so no erasure is warranted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv --- .../plugin-auth/src/auth-contains-filter.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-contains-filter.test.ts b/packages/plugins/plugin-auth/src/auth-contains-filter.test.ts index f6097b0d85..7acd2d956c 100644 --- a/packages/plugins/plugin-auth/src/auth-contains-filter.test.ts +++ b/packages/plugins/plugin-auth/src/auth-contains-filter.test.ts @@ -29,6 +29,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { InMemoryDriver } from '@objectstack/driver-memory'; import { FILTER_OPERATORS } from '@objectstack/spec/data'; +import type { QueryAST } from '@objectstack/spec/data'; import type { IDataEngine } from '@objectstack/core'; import { createObjectQLAdapterFactory } from './objectql-adapter'; @@ -50,10 +51,13 @@ const silentLogger = { * (`check:engine-double-contract`, #4550). */ function memoryReadEngine(driver: InMemoryDriver): IDataEngine { + // The query bag is forwarded with its declared driver-side type and no `any` + // erasure: `query-options/no-any-erasure` (#4674/#4918) counts a test-side + // `find(obj, … as any)` too, and nothing here needs to be off-contract. return { - find: (object: string, query?: any) => driver.find(object, (query ?? {}) as any), - findOne: (object: string, query?: any) => driver.findOne(object, (query ?? {}) as any), - count: (object: string, query?: any) => driver.count(object, (query ?? {}) as any), + find: (object: string, query: QueryAST) => driver.find(object, query), + findOne: (object: string, query: QueryAST) => driver.findOne(object, query), + count: (object: string, query?: QueryAST) => driver.count(object, query), } as unknown as IDataEngine; }