Skip to content

/analytics/query 仍把 RLS 策略字段名回显给调用方 —— read-scope 拒收的泄漏在姐妹面上没堵,#5367 只堵了 dataset 路由 #5811

Closed

Description

@os-zhuang

现状

#5367(维护者 2026-08-06 裁决,方案 B)把 read-scope-sql.ts 的十处 fail-closed 拒收改成 READ_SCOPE_COMPILE_FAILED / 500,并让 POST /analytics/dataset/query withhold 任何声明了服务端故障(status >= 500 且带 code)的生产方 message。理由之一是这些 message 点名 RLS 策略的字段名与比较值:

[read-scope-sql] unsafe field identifier "secret_policy_field" — refusing to build read scope (fail-closed).
[read-scope-sql] unsupported operator "$regex" on "owner_email" (fail-closed).
[read-scope-sql] "approved_by_manager" has a nested/relation value which is not supported in a read scope (fail-closed).

这些内容来自管理员写的 sharing rule / permission set,由 security 服务编译后经 getReadScope 交给 analytics —— 调用方(租户)从来没写过它们,也不该能从错误体里读出来。

同一批拒收在姐妹面上没有被堵。 /analytics/query 经 dispatcher-plugin.errorResponseBase(packages/runtime/src/dispatcher-plugin.ts:445)退出,它的 message 判据是:

const message =
    httpStatus >= 500 && looksLikeInternalErrorLeak(raw)
        ? INTERNAL_ERROR_MESSAGE
        : raw || 'Internal Server Error';

而 looksLikeInternalErrorLeak(packages/types/src/error-leak.ts:47)是对 SQL/驱动措辞 的启发式(sqlite_、SQLSTATE、以 SELECT/INSERT INTO/UPDATE/DELETE FROM 开头、constraint failed/unique constraint/foreign key)。实测把上面十一种 message 形态逐条喂进去:

ECHOED   [read-scope-sql] unsafe field identifier "owner id" — refusing to buil…
ECHOED   [read-scope-sql] unsafe alias identifier "crm opportunity" — refusing …
ECHOED   [read-scope-sql] read scope must be a filter object (fail-closed).
ECHOED   [read-scope-sql] "$and" requires an array (fail-closed).
ECHOED   [read-scope-sql] unsupported top-level operator "$nor" (fail-closed).
ECHOED   [read-scope-sql] bare array value for "region" — use { $in: [...] } (f…
ECHOED   [read-scope-sql] "owner" has a nested/relation value which is not supp…
ECHOED   [read-scope-sql] $in for "region" needs an array (fail-closed).
ECHOED   [read-scope-sql] $nin for "region" needs an array (fail-closed).
ECHOED   [read-scope-sql] $between for "amount" needs [min,max] (fail-closed).
ECHOED   [read-scope-sql] unsupported operator "$regex" on "owner" (fail-closed…

11/11 全部 ECHOED —— 即 errorResponseBase 会把策略内容原样写进 error.message,状态码是 500(err.status 被采纳)但正文没有被收口。

read-scope-sql.ts 的 compileScopedFilterToSql 在 NativeSQLStrategy.applyReadScope 与 ObjectQLStrategy 的回显 SQL 两条路上都会跑,而这两条路都服务 /analytics/query,所以这条面是真实可达的,不是理论问题。

为什么没在 #5367 里一起做

#5367 的题目是 dataset 路由那串 message 正则名单的退休。这条泄漏在本单之前就存在、且在另一条路由上,把它一起改会把一次可见行为变更(该面 message 从可读变为收口)夹带进一个以「退休正则名单」为题的 PR。按 PD #10 单独立项。

PR #5808(#5367 的第二个 PR)里的规则是就地写在 rest-server.ts ③ 分支内的三行判断,故意没有抽成共享谓词 —— 只有一个消费者时提升就是推测性面。本单是第二个消费者出现的时刻。

建议方向(不预判)

无论选哪个都要注意不能把 withhold 放宽到「所有 5xx」:#5667 刻意保留了未声明 5xx 的可读性(自产故障 "no strategy can handle query …" 应当照旧可读),PR #5808 的用例两侧都钉了这一点,新实现要沿用。

验收建议

  1. 规则落地后,/analytics/query 面加端到端用例:read-scope 拒收 → 500,error.message 为 Internal server error,body 不含策略字段名,error.details.code 为 READ_SCOPE_COMPILE_FAILED(errorResponseBase 已经会把 err.code 放进 details,The dispatcher puts the HTTP status in error.code and parks the real code in details — pinned in #3687, still unfixed #3842),完整原文在日志/errorReporter 侧。
  2. 反向:未声明 5xx 在该面仍然可读(fix(service-analytics,rest): analytics dimension 的源字段闸门 —— 不存在的 dimension 答 400 INVALID_FIELD,dataset 500 不再回显 SQL (#5520) #5667 分级不被吃掉)。
  3. changeset 写清这是可见行为变更(该面 message 由可读变收口),照 analytics 的 filter 拒收到不了调用方:service 侧多数拒收没有 ADR-0112 信封,REST 面又用 message 正则嗅探,一律答 500 #5352 changeset 里 "Observable behaviour change" 那一段的写法。

参考

Activity

  1. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    pm:queueReady for the PM dispatch loop
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    and removed
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Aug 6, 2026
  2. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    存量裁决轮(维护者 2026-08-06 委托,session_01LeEfA7CFwbJb7JJmXm2KM3):裁定入队,并按落点锚定改标 domain:services → domain:cli(推荐方向 A 的落点在 packages/types + runtime,均属 cli 行)。前提经 origin/main 核实仍成立:dispatcher-plugin.ts:468-472 判据未变(仅 httpStatus>=500 && looksLikeInternalErrorLeak),read-scope 拒收 message(含 RLS 策略字段名)11/11 原样回显——安全信息泄漏;declaresServerFault 谓词全域零命中,尚未提升。分诊座位如认为锚定有误请改回。


    Generated by Claude Code

  3. added
    target:v17Release blocker for v17 — on the board until fixed, dropped as no longer valid, or accepted for GA
    on Aug 6, 2026
  4. hotlong commented on Aug 7, 2026

    @hotlong
    Contributor

    更正注记:本单正文里的 error.details.code 是写错的落点(#6123)

    本单已完成关闭(PR #6122),这条只更正正文里一处会误导后续读者的说法,不改正文、不重开。出处与裁决见 #6123。

    正文第三处同误说法——「验收建议」第 1 条:

    error.details.code 为 READ_SCOPE_COMPILE_FAILED(errorResponseBase 已经会把 err.code 放进 details,#3842)

    以及「参考」小节的 `:480`-`:483`(`details.code`)。

    实际落点是 error.code,error.details 根本不存在。 括号里那半句是对的——errorResponseBase 确实把 err.code 放进 details——但那只是进程内的暂存,不是线上位置。它交给共享构造器 buildApiError(packages/runtime/src/error-envelope.ts:117)之后:

    const { code: promoted, details } = splitSemanticCode(input.details);
    return {
        code: input.code ?? promoted ?? standardErrorCodeForHttpStatus(input.httpStatus),
        message: input.message,
        httpStatus: input.httpStatus,
        ...(input.extra ?? {}),
        ...(details !== undefined ? { details } : {}),
    };

    splitSemanticCode 取走 code 后 rest 为空,按其注释「undefined rather than {} so a details payload that held nothing but the code disappears from the wire」返回 details: undefined,于是最后一行的展开被跳过、details 键整个消失。真实 500 body:

    {"success":false,"error":{"code":"READ_SCOPE_COMPILE_FAILED",
     "message":"Internal server error","httpStatus":500}}

    PR #6122 的实现本身没有跟着错。 它落地的用例 packages/runtime/src/analytics-query-read-scope-withhold.test.ts:218 按实测断言在 error.code 上,并在上方写明了提升机制——所以受影响的只有本单正文这段文字,验收结论不变。

    为什么值得单独更正:error.details.code 在这条路上从来读不到,而按本仓惯例,一句指向不存在的键的说明,正是下一个 agent grep 不到之后自己发明 ?? 兜底的起点(#6123 的立单理由)。

    同族的另两处文字(#5367 的 changeset + read-scope-sql.ts 文件头)由 #6123 的 PR 一并更正。

    会话:session_015a5qkLzpGXhLL2F5gvJ7dD


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch loop

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions