management: create_purchase echoes raw drizzle SQL to the MCP client #77
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found by
AuthorErrwhile fixing #70, by going looking for the class after failing to reproduce the instance. Audited and deliberately not patched there, because it is not on the Hono error path #70 owns.The defect
apps/management/lib/mcp/tools/purchases.ts:159echoes a raw drizzle error — including theFailed query:text and the SQL — straight back to the MCP client.That is the actual instance of the class #70 was filed about. #70's stated symptom, "no response body leaks driver internals", turned out not to reproduce on the Hono REST path: Hono's default error response emits a fixed string, and no Postgres text reached a caller at
d0b5825. The leak is here instead, on the MCP surface, one layer over.Why it matters more than the REST case would have
An MCP client is an LLM agent. So a raw
Failed query: update "inventory" set "buy_price" = $1, "stock" = stock + $2does not land in a log a human reads later — it lands in a model's context window, where it is:The REST equivalent would have been bad. This version is worse because of who reads it.
Related, and worth reading together
Three defects in this codebase are the same shape at different layers, and each was filed only after someone measured rather than assumed:
getSessionthrowing on a stalejwksrow was indistinguishable from a broken server.apps/store'sfetchCatalogrefused to hear anything specific: a barecatchrewrote every throw, including a Next static-generation bailout, into one friendly message. That masking mis-stated the same error three times across three artifacts before review caught it.One surface says nothing useful, one says too much, one refuses to listen. The general rule the three of them point at: decide deliberately what each boundary tells its caller, and make the untrusted-consumer case the one you design for.
Scope
"path": ["items", 0, "buyPrice"],Too big: expected number to be <=2147483647).content. This codebase has a repeated pattern of one instance being fixed and its twin surviving, and #68's own write-path count was wrong four times for exactly this reason. List every site found, even those left alone, with the reason.Explicitly not this issue
onErrorhandler. It is the right fix for the bare-500 half and it does not cover the MCP tool surface.Verification
create_purchaseover a real MCP client and assert the response body contains no SQL, noFailed query:, no column or table names, and no connection string fragments — scan for a fixed list of leak fragments rather than eyeballing it.md5(string_agg(...))before and after, plus counts onpurchase_items,inventory_lots,stock_movementsandpurchases. A purchase write touches four tables, so "returned an error" and "left nothing behind" are different claims.Acceptance
No MCP tool response body contains driver text, SQL, or schema identifiers; the full error is still logged server-side; PR #68's field-named rejections are unchanged; and every MCP handler that stringifies a caught error is listed here with its disposition.
Second surface: the uploads route echoes R2 configuration and the endpoint host
Added at
Reviewer76r2's condition during review of PR #76 — it agreed the leak should be tracked here rather than narrowed in that PR, provided this issue actually records the case by name. As originally filed it named onlylib/mcp/tools/purchases.ts:159, so the comment inuploads.tspointed at an issue that did not contain the defect it referenced.Measured on a real request:
Two sites, one shape:
app/api/[[...route]]/uploads.ts:57echoes whateveruploadFilethrew straight into the response body:const message = err instanceof Error ? err.message : "Upload gagal".lib/storage/r2.ts:56throws"Missing R2 configuration environment variables"— infrastructure state, not an operator instruction — andclient.send()in the same function surfaces AWS SDK errors whose messages name the<CLOUDFLARE_ACCOUNT_ID>.r2.cloudflarestorage.comendpoint. So a misconfiguration or an SDK fault discloses the account identifier to any caller of the upload route.This predates #70 and is not caused by the
onErrorhandler. PR #76 corrected only the false comment that claimed the branch was operator-facing and "not driver text"; the behaviour is unchanged and is tracked here.Why it belongs with
uploadFile, not with the routeThe route cannot tell which of
uploadFile's throws are safe to echo — that is knowledge the function has and the caller does not.r2.tsthrows at three sites (:56,:77,:86) plus whatever the SDK raises, and only some of those are things an operator can act on ("file too large" yes; "missing environment variables" no). Fixing this at the route means re-deriving that classification at every call site, which is the same transport-versus-service mistake #71 was filed for.Added scope
uploadFile's failures at the function: operator-actionable versus infrastructure. Return or throw a typed distinction the caller can branch on, rather than a bare string.Added acceptance
No upload response body contains R2 configuration state, the
r2.cloudflarestorage.comendpoint, the account id, or a bucket name; operator-actionable failures still name what the operator must change; and the classification lives inuploadFilerather than being re-derived per route.