Skip to content

fix(plugin-auth): better-auth 的 contains 下译为 $contains,比较值不再当正则求值 (#5710) - #5812

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-5710-auth-contains-flip
Aug 6, 2026
Merged

fix(plugin-auth): better-auth 的 contains 下译为 $contains,比较值不再当正则求值 (#5710)#5812
baozhoutao merged 2 commits into
mainfrom
claude/issue-5710-auth-contains-flip

Conversation

@baozhoutao

@baozhoutao baozhoutao commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #5710

packages/plugins/plugin-auth/src/objectql-adapter.tsconvertWhere() 把 better-auth 的 contains 译成 { field: { $regex: value } },一个未转义、来自调用方的比较值坐进了正则的模式位,而且发生在认证路径上。本 PR 把这一支翻成 $contains

前提复核(origin/main@1624f4a)

  • 生产者仍在:objectql-adapter.ts:137filter[fieldName] = { $regex: condition.value }(翻转前)。
  • 活体消费面仍在:driver-memory/src/filter-refusal.ts:145-158SUPPORTED_FIELD_OPERATORS 仍为 new Set([...FILTER_OPERATORS, '$regex', '$options']),注释原话仍是「Refusing it here would break a live producer」。
  • 真实调用方仍在:GET /api/v1/auth/admin/list-users(auth-route-ledger.ts:161)把 searchOperator(默认 contains)与 filterOperator 直接推进 where(better-auth dist/plugins/admin/routes.mjs:358-368)。
  • 前提成立,按裁决实施。

目标算子:$contains,不是 $icontains(动手前验证的前提)

派发令要求先验证「$icontains 仍是纯声明面」再动手 —— 已验证,前提成立:

  • packages/spec/src/data/filter.zod.ts:1086-1096FILTER_OPERATORS 不含 $icontains(只有 $contains/$notContains/$startsWith/$endsWith);
  • 该文件 1066-1081 行把这件事写成了刻意分期:$icontains 「joins this array in the PR that implements it (drivers: $regex 响亮拒收 + $icontains 各后端实现(#4706 裁决 B 案 · 驱动半边) #5702), not before」;
  • 因此若翻成 $icontains,driver-memory 的形状门会先拒收(fail-closed),而任何绕过拒收的面会走匹配器 default: break —— 谓词被静默丢弃,过滤放大成全匹配,在认证读路径上是越权读。

所以本单的目标算子是 $contains;$icontains 的执行面归 #5702

大小写语义(派发令要求写进正文的判断)

上游期望:better-auth 的 Where 类型自带 mode?: "sensitive" | "insensitive",默认 "sensitive"(@better-auth/core/src/db/adapter/index.ts:324-343)。也就是说 better-auth 的 contains 默认要的就是大小写敏感的字面子串,这与 #5701 Q2=A 裁定的 $contains 契约(大小写敏感)同向。spec 自己的退役处方也明写了这个二选一:RETIRED_FILTER_OPERATORS.$regex.why —— 「Write $icontains for the case-insensitive substring search …, or $contains for a case-sensitive one」。

一处要诚实纠正的说法:issue 正文与派发令都写了「裸 $regex 在 memory 上本来也是大小写敏感的,所以翻转对该 driver 等价」。这句话只对 driver-memory 的参考匹配器(memory-matcher.tsmatch(),用 String.includes)成立,对查询路径不成立:

  • 查询路径 normalizeFieldOperators(memory-driver.ts:939-941)把 $contains 降为 new RegExp(escapeRegex(v), 'i') —— 转义 + i 标志;
  • 而裸 $regex 交给 mingo 时没有任何 flags,是大小写敏感的。

所以在 driver-memory 的实际查询路径上,本次翻转把大小写行为从「敏感」改成了「不敏感(全 Unicode 折叠)」。这不是本 PR 引入的越契约行为,而是该 driver 今天既有的状态 —— scripts/check-driver-conformance.mjs 的 driver-memory DEBT 行已按测量写明这一点,并指向 #5702 去把它对齐回敏感。本 PR 不钉这条大小写行为(钉了会与 #5702 的对齐相冲突),只把它记录在此。

反向验证:先红后绿(实测)

新 pin 先在未翻转的代码上跑,四条按预期红:

FAIL src/auth-contains-filter.test.ts > emits `$contains`, never a bare `$regex`, for a `contains` search
AssertionError: expected { name: { '$regex': 'a.b' } } to deeply equal { name: { '$contains': 'a.b' } }

FAIL ... > does not read `.` as a wildcard — `a.b` matches `a.b`, not `axb`
AssertionError: expected [ 'a.b', 'axb' ] to deeply equal [ 'a.b' ]

FAIL ... > does not read `^` as an anchor
AssertionError: expected [ { name: 'a.b', …(6) }, …(1) ] to deeply equal []

FAIL ... > matches a value that is not a legal regex, instead of failing on it
SyntaxError: Invalid regular expression: /(/: Unterminated group
  ❯ new Query mingo/cjs/query.js:44:10
  ❯ _InMemoryDriver.find packages/drivers/driver-memory/src/memory-driver.ts:293:30

Test Files  1 failed | 34 passed (35)
     Tests  4 failed | 789 passed (793)

翻转后全绿(Test Files 35 passed (35) / Tests 793 passed (793))。

第四条要单独说明:issue 预测非法模式会「静默不匹配」,那是参考匹配器那一面的行为(catch { return false });实测查询路径是直接抛 SyntaxError(mingo 在编译 Query 时构造 RegExp),连 ADR-0112 信封都没进。两种都是坏答案,只是坏法不同 —— 按实测记录,而不是按预测记录。

测试落点

packages/plugins/plugin-auth/src/auth-contains-filter.test.ts,两面:

  1. 契约面(spy engine):发出的过滤器就是 { name: { $contains: 'a.b' } },且整棵树里没有 $regex;另加一条词表 pin(FILTER_OPERATORS$contains、不含 $regex)—— 这是「五后端都必须求值」这句话的凭据。
  2. 行为面(真实 InMemoryDriver,经 createObjectQLAdapterFactory 的 better-auth 适配器):a.b 只命中 a.b 不命中 axb;^a 一行不中;( 按字面命中 x(y;无元字符的 xb 照旧命中 axb

只有契约面看不出「拼对了但求值错了」,只有行为面说不清是哪个算子换来的结果,所以两面都要。测试没有落进 spec 的 FILTER_TEXT_CASES 共享一致性表 —— 那是 #5701/#5702 的领地,这里钉的是 adapter 这一个实现面。

为跑行为面,plugin-auth 增加了 @objectstack/driver-memorydevDependency(不进发布产物)。测试里的引擎替身只声明三个读动词,写动词是刻意不声明的:种子数据直接经 driver 写入,避免手写一个本测试既不需要、也无法遵守的 delete/update dispatch 契约(check:engine-double-contract)。

#5702 的影响(必答项)

变简单,且是「解锁」而非「减负」:#5702 的「校验期响亮拒收 $regex」原本被这一个活体生产者挡着(filter.zod.ts:1150-1155「Hard order: #5710 flips the producer, then #5702 turns these strings into refusals」、check-driver-conformance.mjs:279-282#5710 flips that producer before any of these four cells may be cleared」)。本 PR 合并后:

一处#5702 的备注(不另立单,因为这几行正是 #5702 要改的):packages/spec/src/data/filter-text-conformance.ts:272{ $regex, $options } 说成「The exact shape plugin-auth's adapter can emit」。实测该 adapter 从不发 $options(它只写 { $regex: value }),所以那条用例钉的是一个本 adapter 产生不出来的形状 —— 用例本身仍有价值(拒收要同时点名两个拼写),只是这句归因不准。

越界发现(已另立单,本 PR 不修)

同一个 convertWhere() 上另有两个与本单不同类的缺陷,均按 Prime Directive #10 单独立单:

验证命令

pnpm --filter @objectstack/plugin-auth test        # 35 files / 793 tests passed
pnpm --filter @objectstack/plugin-auth typecheck   # tsc --noEmit,无输出
node scripts/check-nul-bytes.mjs                   # OK (5678 files)
node scripts/check-engine-double-contract.mjs      # OK — 37 pinned, 165 DEBT, 2 exempt

…t a bare `$regex` (#5710)

`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 6, 2026 6:21am

Request Review

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth.

10 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests tooling labels Aug 6, 2026
…of erasing it to `any` (#5710)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv
@baozhoutao
baozhoutao marked this pull request as ready for review August 6, 2026 06:33
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 7a40b7a Aug 6, 2026
25 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-5710-auth-contains-flip branch August 6, 2026 06:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-auth: better-auth 的 contains 被译成裸 $regex,用户输入当正则求值 —— 且是 $regex 退役唯一挡路的生产者

2 participants