Skip to content

Commit f598aa8

Browse files
os-zhuangclaude
andauthored
fix(types): withhold the Postgres and bare-SQLite phrasings of a driver failure (#8132) (#8263)
* fix(types): teach looksLikeInternalErrorLeak the shipped dialects' phrasings (#8132) * test(rest): flip the #8130 residual pin to the withheld assertion (#8132) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3f7b4ff commit f598aa8

4 files changed

Lines changed: 186 additions & 28 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/types': patch
3+
---
4+
5+
Withhold the Postgres and bare-SQLite phrasings of a driver failure from HTTP error bodies
6+
7+
`looksLikeInternalErrorLeak` recognised SQLite's `SQLITE_ERROR: no such table: sys_metadata`
8+
but not the Postgres phrasing of the same condition, `relation "sys_metadata" does not exist`.
9+
The result was that one failure disclosed a physical table name or not depending on which
10+
engine was underneath, from every boundary that applies the predicate — `HttpDispatcher.error`,
11+
the declarative endpoint executor, the dispatcher plugin, the direct-mount package door and the
12+
Hono auth-config route.
13+
14+
The predicate now also recognises, for the engines this repo actually runs:
15+
16+
- Postgres `relation "…" does not exist` and `column "…" does not exist` (42P01/42703), which
17+
covers the `… of relation "…"` sub-object family as a superstring;
18+
- Postgres `permission denied for table|relation|sequence|database …` (42501);
19+
- SQLite/libsql `no such table:` / `no such column:` in their bare, un-prefixed form.
20+
21+
Each phrasing is anchored on the driver's own template — a quoted identifier, or the trailing
22+
colon — never on the bare tail, so ordinary business messages such as "user does not exist" are
23+
still returned to the caller unchanged. The predicate is applied only where the outcome is
24+
already a 5xx, and the full text still reaches the server log and the error reporter.

packages/rest/src/package-door-5xx-message-sanitization.test.ts

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -232,29 +232,31 @@ describe('[#8086] a real sys_metadata failure, walked in process through this do
232232
}, 60_000);
233233

234234
/**
235-
* ⚠️ The CEILING of option B, measured and pinned rather than papered over.
235+
* [#8132] Was the residual; now the second green.
236236
*
237-
* The ruled fix applies the SHARED predicate, which is a heuristic over the
238-
* message and recognises no Postgres "relation … does not exist" phrasing —
239-
* measured false, asserted below. So that dialect's line still travels
240-
* through this door after this change, exactly as it travels through the
241-
* dispatcher twin, which runs the same predicate (#3867). The two doors
242-
* therefore still AGREE, which is what this card was about; what remains is a
243-
* property of the heuristic, shared by every boundary that applies it.
237+
* This case was added by #8086 as a deliberately-red-in-future pin: the
238+
* shared predicate was a heuristic over the message and knew no Postgres
239+
* `relation … does not exist` phrasing, so that dialect's line still
240+
* travelled through this door while SQLite's was withheld — the same
241+
* condition, disclosed or not depending on which engine was underneath. It
242+
* asserted that gap positively so the day it closed would be visible.
244243
*
245-
* This is not an argument for widening the predicate here — that would be a
246-
* new rule at one door, re-creating the divergence this closes. It is the
247-
* argument for **option C**: `metadata-protocol` should not interpolate
248-
* driver text into client-facing messages at all, which is the only fix that
249-
* does not depend on recognising a dialect's phrasing. Filed separately.
244+
* #8132 closed it in the predicate, where it belonged — so the assertion is
245+
* INVERTED here rather than deleted, and the pair above/below now proves the
246+
* property that actually matters: this door answers the same withheld
247+
* envelope for BOTH dialects of one failure.
250248
*
251-
* This case goes RED the day the shared predicate learns this phrasing or C
252-
* lands — which is precisely when a reader should come back and re-read the
253-
* paragraph above, instead of consuming a green suite as proof that the door
254-
* is covered.
249+
* ⚠️ Still not the structural cure, and this comment is the reason the
250+
* pointer survives the flip. The predicate now recognises the two engines
251+
* this repo runs; it is a phrasing test, and a phrasing test can only ever
252+
* know the dialects someone has met. **Option C** — `metadata-protocol` not
253+
* interpolating driver text into client-facing messages at all — is the fix
254+
* whose correctness does not depend on that, and is tracked as #8136. The
255+
* anti-vacuity guard at the top of this describe block goes red when C
256+
* lands, which is the intended signal to revisit this whole section.
255257
*/
256-
it('the residual: Postgres phrasing trips no keyword, so it still travels (option C is the cure)', async () => {
257-
expect(looksLikeInternalErrorLeak(PG_NO_RELATION)).toBe(false);
258+
it('the Postgres phrasing of the same failure is withheld too, by the shared predicate', async () => {
259+
expect(looksLikeInternalErrorLeak(PG_NO_RELATION)).toBe(true);
258260

259261
const protocol = await bootRealProtocol(PG_NO_RELATION);
260262
const captured = await drive(
@@ -267,10 +269,13 @@ describe('[#8086] a real sys_metadata failure, walked in process through this do
267269
const error = expectDeclaredEnvelope(captured);
268270
expect(captured.status).toBe(500);
269271
expect(error.code).toBe('INTERNAL_ERROR');
270-
// Stated as the fact it is: withheld would be better, and the predicate
271-
// cannot tell. Asserted positively so the day it changes is visible.
272-
expect(error.message).toContain('does not exist');
273-
expect(error.message).not.toBe(INTERNAL_ERROR_MESSAGE);
272+
// The same positive shape the SQLite case asserts, which is the point of
273+
// the flip: one door, one envelope, regardless of the engine underneath.
274+
expect(error.message).toBe(INTERNAL_ERROR_MESSAGE);
275+
276+
const wire = JSON.stringify(captured.body);
277+
expect(wire).not.toContain('does not exist');
278+
expect(wire).not.toContain('sys_metadata');
274279
}, 60_000);
275280
});
276281

packages/types/src/error-leak.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,81 @@ describe('looksLikeInternalErrorLeak', () => {
8787
});
8888
});
8989

90+
/**
91+
* [#8132] The shipped dialects' phrasings, both directions.
92+
*
93+
* The gap this pins: the keyword set caught SQLite's `SQLITE_ERROR: no such
94+
* table: sys_metadata` (via the `sqlite_` limb) while the Postgres phrasing of
95+
* *the same condition* — `relation "sys_metadata" does not exist` — returned
96+
* FALSE and shipped a physical table name from every boundary that applies the
97+
* predicate.
98+
*
99+
* Scope is deliberately the dialects this repo actually RUNS (SQLite/libsql and
100+
* Postgres, via `driver-sql`), not a census of MySQL/MSSQL/Oracle spellings
101+
* nobody here has met — the unbounded-list trap the module note argues against.
102+
*
103+
* The negative half is the load-bearing half. A bare `includes('does not
104+
* exist')` would have matched "user does not exist" and started replacing
105+
* ordinary business answers with "Internal server error", so every phrasing is
106+
* anchored on the driver's own template (a QUOTED identifier, or the trailing
107+
* colon) and the near-miss cases below are what prove that anchor is real
108+
* rather than incidental.
109+
*/
110+
describe('looksLikeInternalErrorLeak — shipped-dialect phrasings (#8132)', () => {
111+
it.each([
112+
// Postgres 42P01. The exact string measured false on the shipping predicate.
113+
['postgres missing relation', 'relation "sys_metadata" does not exist'],
114+
[
115+
'postgres missing relation wrapped in a producer sentence',
116+
'Failed to delete customization overlay: relation "sys_metadata" does not exist',
117+
],
118+
// Postgres 42703, read path — no relation named, so the sub-object
119+
// helpers in `relation-sub-object.ts` deliberately do not see it.
120+
['postgres missing column (read path)', 'column "bogus" does not exist'],
121+
// Postgres 42703 write path / 42704: these carry a complete missing-TABLE
122+
// phrase as a substring. For a LEAK verdict that overlap is harmless —
123+
// both spellings are a leak — which is why this predicate needs none of
124+
// the ordering care `matchMissingColumnOfRelation` exists to provide.
125+
['postgres missing column of relation', 'column "label" of relation "sys_team" does not exist'],
126+
[
127+
'postgres missing constraint of relation',
128+
'constraint "uq_sys_team_name" of relation "sys_team" does not exist',
129+
],
130+
// Postgres 42501 — names a physical table the caller never asked about.
131+
['postgres permission denied for table', 'permission denied for table sys_user'],
132+
['postgres permission denied for relation', 'permission denied for relation sys_user'],
133+
// SQLite/libsql message-only errors: the same conditions with NO
134+
// `SQLITE_` prefix to trip the existing limb. Measured shapes in this
135+
// repo — `metadata/src/utils/schema-sync-errors.ts` documents both.
136+
['sqlite bare missing table', 'no such table: sys_metadata'],
137+
['sqlite bare missing table with a schema prefix', 'no such table: main.sys_metadata_history'],
138+
['sqlite bare missing column', 'no such column: bogus'],
139+
])('catches %s', (_label, message) => {
140+
expect(looksLikeInternalErrorLeak(message)).toBe(true);
141+
});
142+
143+
/**
144+
* ⛔ The false-positive guard. Every one of these contains the tail of a
145+
* phrasing above and is an ordinary message a caller is entitled to read.
146+
* If someone later relaxes an anchor to a bare `includes(...)`, these go red
147+
* — which is the whole point of writing them down.
148+
*/
149+
it.each([
150+
['a business message about a missing user', 'user does not exist'],
151+
['a business message about a missing record', 'record does not exist'],
152+
['a sentence a hook author wrote', 'The customer you selected does not exist'],
153+
// The quote anchor, stated as a test: unquoted prose that uses the same
154+
// NOUN is not a driver line. The looser `includes('relation') &&
155+
// includes('does not exist')` reading would match this one.
156+
['prose merely using the word relation', 'This relation does not exist in the diagram'],
157+
['prose merely using the word column', 'The column layout does not exist'],
158+
// No physical object kind, so not Postgres' ACL template.
159+
['an ordinary permission refusal', 'Permission denied for this operation'],
160+
])('leaves %s alone', (_label, message) => {
161+
expect(looksLikeInternalErrorLeak(message)).toBe(false);
162+
});
163+
});
164+
90165
/**
91166
* [#5811] The declaration half. `looksLikeInternalErrorLeak` asks whether a
92167
* message SOUNDS internal; this asks whether the producer SAID it was a server

packages/types/src/error-leak.ts

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,71 @@
3535
/** Generic replacement text for a message that trips {@link looksLikeInternalErrorLeak}. */
3636
export const INTERNAL_ERROR_MESSAGE = 'Internal server error';
3737

38+
/**
39+
* [#8132] The phrasings of the dialects this repo actually RUNS, each anchored
40+
* on the driver's own errmsg template rather than on its tail.
41+
*
42+
* The gap that forced these: the keyword set below caught SQLite's
43+
* `SQLITE_ERROR: no such table: sys_metadata` through the `sqlite_` limb, while
44+
* the Postgres phrasing of *the same condition* —
45+
* `relation "sys_metadata" does not exist` — matched nothing and shipped a
46+
* physical table name to the client from every boundary that applies the
47+
* predicate.
48+
*
49+
* **Why anchored, and never on the bare tail.** `does not exist` is ordinary
50+
* business English: "user does not exist", "record does not exist". Matching
51+
* that substring would replace legitimate answers with `Internal server error`,
52+
* so each pattern requires what the DRIVER always emits and prose usually does
53+
* not — a quoted identifier, or the trailing colon of SQLite's template. The
54+
* negative cases in `error-leak.test.ts` pin that distinction.
55+
*
56+
* **Why the list stops here.** The module note above argues against growing a
57+
* driver taxonomy, and it is right that the list is unbounded *across dialects*
58+
* — MySQL/MSSQL/Oracle each phrase all of this differently and nobody here runs
59+
* them. These are not a census: they are the two engines `driver-sql`,
60+
* `driver-turso` and `driver-sqlite-wasm` actually reach. A dialect this repo
61+
* does not run gets no entry, and {@link declaresServerFault} remains the
62+
* answer that does not depend on phrasing at all.
63+
*
64+
* ⚠️ Related but NOT reusable: `relation-sub-object.ts` owns the same Postgres
65+
* sentence for two other questions (which column? / is this a sub-object?), and
66+
* its note warns that its two widths must never be collapsed. Neither answers
67+
* "is this a leak", and its central problem does not arise here: a message like
68+
* `column "label" of relation "sys_team" does not exist` contains a complete
69+
* missing-TABLE phrase as a substring, which is a hazard when you are deciding
70+
* WHICH object is missing and a non-issue when the verdict is "leak" either way.
71+
* That is why this asks its own question with its own patterns.
72+
*/
73+
const DIALECT_LEAK_PHRASINGS: readonly RegExp[] = [
74+
// Postgres 42P01 / 42703 (and, as a superstring, the `… of relation "…"`
75+
// sub-object family: 42704 and friends). The quotes are required because
76+
// Postgres always emits them here.
77+
/\b(?:relation|column)\s+["'`][^"'`]+["'`]\s+does not exist/i,
78+
// Postgres 42501. Restricted to physical object kinds: `schema`, `view`,
79+
// `function` and `column` are all ObjectStack AUTHORING vocabulary, so a
80+
// product message could legitimately use them and a miss is the cheap
81+
// direction (the outcome is already a 5xx).
82+
/\bpermission denied for (?:table|relation|sequence|database)\b/i,
83+
// SQLite/libsql, message-only form. The `sqlite_` limb below catches these
84+
// only when the driver prefixed its code; `better-sqlite3` and libsql both
85+
// raise them bare, which is the shape measured across this repo.
86+
/\bno such (?:table|column):/i,
87+
];
88+
3889
/**
3990
* Whether `message` looks like a raw SQL statement or driver/engine dump that
4091
* must not be returned to an API client.
4192
*
4293
* Matches: dialect error codes (`SQLSTATE`, `sqlite_*`), bare statements
4394
* (a message that *starts* as `SELECT`/`INSERT INTO`/`UPDATE`/`DELETE FROM` —
44-
* drivers prefix the offending SQL to their message), and constraint-violation
45-
* dumps, which name physical tables and columns.
95+
* drivers prefix the offending SQL to their message), constraint-violation
96+
* dumps, which name physical tables and columns, and the
97+
* {@link DIALECT_LEAK_PHRASINGS} of the engines this repo ships.
4698
*
4799
* Does NOT match ordinary business or validation messages, which is why the
48-
* statement forms are anchored with `startsWith`: a legitimate message may
49-
* *mention* "update" without being one.
100+
* statement forms are anchored with `startsWith` and the dialect phrasings on
101+
* the driver's template: a legitimate message may *mention* "update", or say
102+
* "does not exist" about a business record, without being either.
50103
*/
51104
export function looksLikeInternalErrorLeak(message: string | undefined | null): boolean {
52105
if (!message) return false;
@@ -60,7 +113,8 @@ export function looksLikeInternalErrorLeak(message: string | undefined | null):
60113
lower.startsWith('delete from ') ||
61114
lower.includes('constraint failed') ||
62115
lower.includes('unique constraint') ||
63-
lower.includes('foreign key')
116+
lower.includes('foreign key') ||
117+
DIALECT_LEAK_PHRASINGS.some((pattern) => pattern.test(lower))
64118
);
65119
}
66120

0 commit comments

Comments
 (0)