Skip to content

fix(rest): a declared 5xx no longer ships its own message to the client (#5437) - #5464

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-5437-rest-5xx-sanitize
Aug 5, 2026
Merged

fix(rest): a declared 5xx no longer ships its own message to the client (#5437)#5464
baozhoutao merged 1 commit into
mainfrom
claude/issue-5437-rest-5xx-sanitize

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #5437

前提核验(在 origin/main 01c0baef 上)

issue 的成因描述仍然成立,行号已漂移:直通区间 400–599 在 packages/rest/src/rest-server.ts:831-832;两个活体产出方在 packages/metadata-protocol/src/protocol.ts:7462-7467OVERLAY_PERSISTENCE_FAILEDstatus = 500 字面量)与 :9813-9814Failed to delete customization overlaystatus 动态赋值);第三个同区间产出方 :5582status = 501NOT_IMPLEMENTED)也已核对。

落地方向:A,但消毒点在分支内,不是「落回 mapDataError

分诊裁定的是方向 A(收窄到 4xx、5xx 走消毒)。实现前先把「让 5xx 落回 mapDataError」这条字面路径实测了一遍,结论是它做不到裁定要的结果,因此消毒改在直通分支内完成,状态与 code 原样保留。实测输出(用真实 fixture 直接调 mapDataError):

输入 mapDataError 的答复
500 ...sys_metadata: SQLITE_ERROR: no such table: sys_metadata 404 OBJECT_NOT_FOUND / Object not found
500 ...: relation "sys_metadata" does not exist 404 OBJECT_NOT_FOUND
500 Failed to delete customization overlay: connect ECONNREFUSED 10.0.0.5:5432 400,驱动原文逐字保留
501 Atomic batch on 'showcase_account' requires ...(带 object 参数) 404 Object 'showcase_account' is not registered
503 Search index rebuild is in progress; retry in 30s. 400,原文逐字

三个问题:① mapDataError按消息文本推导状态的,服务端故障被改写成客户端错误,能力拒绝被改写成「对象不存在」;② 改写后的 404 落在 isExpectedDataStatus 里,这条故障连日志都不打了;③ 关键词一条都不命中的 5xx 落到 mapDataError 的兜底 { status: 400, error: raw } —— 原文照样出去,只是换了个 4xx 的壳。第 ③ 条正是分诊否决 B 时写的「判准必然漏」,只是漏在兜底而不是漏在启发式。分诊要求的 pin ③(501 家族同验)恰好就是证伪这条路径的那一条。

所以 5xx 的处置是:保留产出方声明的状态、保留 code、丢掉正文,正文换成 @objectstack/typesINTERNAL_ERROR_MESSAGE。无条件丢弃而不是过启发式 —— 加判准只是把问题挪成「这个方言启发式认不认识」,而这正是本 bug 的成因。

⛔ 未动:isSqlLeak / looksLikeInternalErrorLeak 本身、mapDataError#5436 的 4xx 截断。文件面只有 packages/rest

接受代价(已按分诊口径写进 changeset)

自撰 5xx 正文客户端将读到通用文案 + 原 codeOVERLAY_PERSISTENCE_FAILED 的「In-memory registry was updated but will be lost on restart」、原子批的「retry without options.atomic」都属此类。code 仍然随响应下发(SCREAMING_SNAKE 常量不是泄漏,也正是客户端该 key 的东西),按 code 判断的客户端不受影响。

日志侧补齐sendError 原先完全不打日志,5xx 一旦消毒就会从「客户端读得到」直接变成「没人读得到」。新增 logWithheldServerFault,只在「正文确实被扣下」时打一行;handleRouteError 对真故障已经打整个 error 对象,所以这行只补它判定为「预期」而静默的 502/503 那个口子 —— 一次故障恰好一行,不重复(有专门用例钉住)。

补齐 issue 正文的「未验证部分」

  • 真实走通:真实 ObjectQL + 真实 ObjectStackProtocolImplementation + 每个方法都抛驱动错的 mock 驱动,打真实路由 DELETE /api/v1/meta/:type/:name,走出真实的 Failed to delete customization overlay: SQLITE_ERROR: no such table: sys_metadata,断言客户端 body 里没有任何一段原文、状态仍是 500、原文完整进日志。SQLite 与 Postgres 两种措辞各一条。
  • 动态赋值 status 抽查:上面这条走的就是 (e as any).status = 500 那个产出方 —— status: 字面量 grep 找不到它,正是 issue 说没清点的形状。方法记录在测试文件顶部。
  • saveMetaItemOVERLAY_PERSISTENCE_FAILED 未做 live:走到它的 legacy 分支要求「既不可 overlay 又不可 runtime create」的类型且该项是 artifact-backed,运行时新建会先被 403 NOT_CREATABLE 拦下。改为按产出方原样构造消息形状覆盖,测试里写明了这一点,没有假装走通。

反向验证(方向先判后跑)

预判:把区间改回 < 600(恢复「短于 500 字符逐字、超长换 Request failed」)应当恰好红掉 5xx 消毒 pin,4xx 截断 pin 全绿。实跑一致 —— 10 红 14 绿,红的全部是 5xx 处置断言(含 499/500 边界那条,它按设计依赖 5xx 侧),#5423/#5436 的每一条 4xx 截断用例保持绿。

一条例外值得写明:「一次故障恰好一行日志」那条在还原后仍然绿。它不是修复 pin,而是防重复日志的守卫 —— 还原后正文照常直通,logWithheldServerFault 自然 no-op,只剩 handleRouteError 那一行。这条断言的方向就是「无论哪一侧都只有一行」,不是「改前后不同」。

fixture 处置

rest-4xx-message-truncation.test.ts 里那条「an over-long 5xx is DELIBERATELY still replaced」精确地钉住了本 PR 删掉的那条限枝(它断言的 'Request failed' 恰好来自被删的长度分支),按整体替换处理:重写为钉新的处置(不论长度一律扣下、code 保留),并指向新文件。#5423 的其余断言一字未动。

消费半径已扫:resolveErrorResponserest-server.ts 私有函数(对外只导出 mapDataError),依赖 @objectstack/rest 的 5 个包中没有任何测试驱动 RestServer,runtime / services 的 envelope conformance 走的是另一条边界(errorResponseBase)。

验证

  • pnpm --filter @objectstack/rest test45 files / 676 tests passed
  • pnpm --filter @objectstack/rest build(tsup DTS 即类型检查)→ success;node scripts/check-type-check-coverage.mjs → OK(@objectstack/rest 在 DEBT 账本内,无 typecheck 脚本,未新增误差)
  • node scripts/check-nul-bytes.mjs → OK,另对改动文件做了控制字符自扫

顺带记录

方向 C(产出方不插值驱动原文)按分诊不在本单。落地后另有一条本单实测撞见的旁证已单独立项:#5462 —— sys_metadata 整体不可用时,不带 status 的原始驱动错误被 looksLikeUnknownObject 误报成 404 OBJECT_NOT_FOUND,且 404 属「预期状态」因而一行日志都不留。那条不带显式 status,不受本 PR 影响。


🤖 Generated with Claude Code

https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh


Generated by Claude Code

…nt (#5437)

`resolveErrorResponse` — the value side of `sendError`, and therefore the error
path of every metadata / UI / discovery / batch route — passed an explicit
status straight through for the whole 400-599 band. A declared 5xx returned
`error.message` verbatim, past `isSqlLeak`, past `looksLikeInternalErrorLeak`,
past the `Internal data error` envelope, while `mapDataError`'s sibling branch
stops at 4xx on purpose and says why: "5xx messages keep going through the
sanitizing heuristics below so internal/SQL details never reach the client
verbatim". Two opposite verdicts on one question.

`metadata-protocol` interpolates the raw driver error into two client-facing
500s (overlay persist / delete), and a real driver line is far shorter than the
500-character bound that was the only thing standing here, so the whole thing
arrived intact. Length was never a proxy for leakage; on this side of the bound
it failed open.

The 5xx band now drops the message unconditionally and keeps the producer's
status and `code`. Unconditional rather than heuristic: a keyword gate only
moves the question to "does the predicate know this dialect". Sanitised in the
branch rather than by falling through to `mapDataError`, which derives status
from message TEXT — measured first, and it answers 404 OBJECT_NOT_FOUND for the
overlay 500s, 404 "Object '<name>' is not registered" for the atomic batch's
501, and 400 with the driver text still verbatim for anything its keywords miss.

The withheld text still reaches the log: `handleRouteError` already prints a
genuine fault, and a new line covers the 502/503 gap its predicate leaves.
4xx truncation (#5423 / #5436) is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh
@vercel

vercel Bot commented Aug 5, 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 5, 2026 1:04pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l labels Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest.

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

  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest)
  • content/docs/permissions/authentication.mdx (via @objectstack/rest)
  • content/docs/plugins/index.mdx (via @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest)
  • content/docs/releases/implementation-status.mdx (via @objectstack/rest)
  • content/docs/releases/v12.mdx (via @objectstack/rest)
  • content/docs/releases/v17.mdx (via @objectstack/rest)

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.

@baozhoutao
baozhoutao marked this pull request as ready for review August 5, 2026 13:07
@baozhoutao
baozhoutao enabled auto-merge August 5, 2026 13:07
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit 577cd27 Aug 5, 2026
24 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-5437-rest-5xx-sanitize branch August 5, 2026 13:17
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…e object asked for (objectstack-ai#5462) (objectstack-ai#5530)

`mapDataError`'s unknown-object heuristic asked whether a driver error
mentioned `no such table` / `relation ... does not exist` — never WHICH table
was missing. A business object that was never registered and the metadata plane
collapsing entirely are the same two words to that regex, so `sys_metadata`
becoming unreachable came back as `404 {"error":"Object not found","code":
"OBJECT_NOT_FOUND"}`: the caller was told to check the object name they typed.
And 404 is an `isExpectedDataStatus`, so `handleRouteError` printed no
"[REST] Unhandled error" — a total outage of the metadata plane left not one
line in the server log.

Reproduced in process on a real ObjectQL + ObjectStackProtocolImplementation
whose driver fails every access with `SQLITE_ERROR: no such table:
sys_metadata`: `PUT /api/v1/meta/object/acct` answered 404 with zero log lines.

The rule now: a missing-relation message is an unknown-object verdict only when
the relation it names is the object the request named. Attribution takes both
halves — a request object, and a relation name the phrasing actually carries
(schema qualifier stripped, compare case-insensitive). Prime Directive objectstack-ai#6
(object name IS table name, no `tableName` mapping) is what makes the
comparison sound rather than a guess. Anything unattributable is the sanitised
data-store fault the SQL-leak branch has always emitted: 500 DATABASE_ERROR,
which sits outside `isExpectedDataStatus` and so buys back the log line.

Unchanged on purpose: a genuine unknown object is still a quiet 404
OBJECT_NOT_FOUND from both producers (objectstack-ai#3770); the engine-authored limbs
(`unknown object`, `no driver available`, the quoted-name catch-all) keep the
old reading; and the declared-status band (objectstack-ai#5437/objectstack-ai#5464, objectstack-ai#5423/objectstack-ai#5436) answers in
`resolveErrorResponse` before the heuristic is reached at all.


Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…a missing item (objectstack-ai#5532) (objectstack-ai#5705)

The customization-overlay reads in `getMetaItems`/`getMetaItem` each wrapped
their sys_metadata access in a bare `catch {}` and answered with their own
empty value, so a metadata store the protocol could not reach was
indistinguishable from an item nobody ever customised. The emptiness then
travelled the read chain and each consumer named it differently and wrongly:
`getMetaItemCached` as `Metadata item <type>/<name> not found`, the
`state='draft'` read as `NO_DRAFT`/404, `getMetaItems` as `items: []`.

ADR-0110 D3: a miss and an outage are different facts with opposite meanings.
objectstack-ai#5108 fixed this in DatabaseLoader's plural read and objectstack-ai#5089 in listForIndex;
this is the same rule on the protocol's own overlay reads.

Discrimination is by error TYPE through `isMissingTableError` — the predicate
DatabaseLoader (objectstack-ai#5108) and SysMetadataRepository (objectstack-ai#4867) already ask, so a
driver quirk is taught to the platform once. The one benign reason (the table
is not provisioned yet) still falls through to the registry; everything else
throws 503 + SERVICE_UNAVAILABLE with the driver error as `cause`, which the
REST boundary's existing objectstack-ai#5437/objectstack-ai#5464 sanitising and logging already handle.

The terminal miss in `getMetaItemCached` is structured too: 404 +
RESOURCE_NOT_FOUND, so a plain miss stops falling out of `mapDataError`'s
catch-all as an unattributable 500 (and, pre-objectstack-ai#5489, as a 400 shipping the
internal wording verbatim).


Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
… (objectstack-ai#5774)

* fix(runtime): unknown /auth sub-paths get a clean 404 instead of a leaked internal TypeError

The dispatcher plugin mounted one legacy explicit route, POST ${prefix}/auth/login,
which handed better-auth the adapter's INTERNAL IHttpRequest (headers is a plain
object, not Headers). better-auth's fetch-style handler opens with
request.headers.get(...), so the route answered HTTP 500 with the raw
'request.headers.get is not a function' in the response body. /login is not a
better-auth endpoint at all, so the mount could never work for any caller.

- dispatcher-plugin.ts: delete the legacy route. Unknown auth sub-paths now fall
  to the /auth/* wildcard the namespace owner mounts on the raw Hono app, which
  forwards a real Fetch Request and yields better-auth's own clean 404.
- domains/auth.ts: a throw out of IAuthService.handleRequest is unattributable
  here, so its message is withheld unconditionally (objectstack-ai#5437/objectstack-ai#5464/objectstack-ai#5489 discipline)
  — 500 INTERNAL_ERROR with the original error on the server log.

Refs objectstack-ai#5085

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWUR56YsttL5sTF72Q75TQ

* test(runtime): pin both halves of the objectstack-ai#5085 /auth forwarding fix + changeset

- auth-forward-fault-sanitization.test.ts: the dispatcher plugin mounts NO route
  under ${prefix}/auth (the specific legacy mount AND the general invariant),
  and a throw out of IAuthService.handleRequest is a sanitised 500 whose body
  carries none of the thrown text, with the original error on the server log.
  Positive controls: a better-auth Response passes through untouched (same
  object), its own 401 body is not sanitised, and an empty auth slot still 501s.
- auth-unknown-subpath.hono.integration.test.ts: a real hono boot, with a fake
  auth service that is better-auth-SHAPED (reads request.headers.get + new
  URL(request.url) first thing, so an internal request object explodes here the
  way it did in production). POST /auth/login → 404, sign-in/email → 200 with
  its set-cookie.

Refs objectstack-ai#5085

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWUR56YsttL5sTF72Q75TQ

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants