Skip to content

Commit 840ee4b

Browse files
os-zhuangclaude
andauthored
fix(analytics,runtime,types): gate cube auto-inference on object existence; stop the dispatcher boundary returning raw SQL (#3867) (#3875)
Two independent defects on the `/analytics` surface, found while verifying #3770 against a real server. Before this change, on an authenticated CRM dev server: POST /api/v1/analytics/query {"cube":"sqlite_master","measures":["count"],"dimensions":["type"]} → 200 {"rows":[{"type":"index","count":262},{"type":"table","count":71}, {"type":"view","count":1}]} SQLite's internal schema table — never a registered object — read through the analytics endpoint. Any table the connection can see was readable. ① CUBE NAME REACHED THE DRIVER AS A TABLE NAME. `AnalyticsService.ensureCube` auto-infers a minimal Cube when none is registered, with `cube.sql = <queried name>`. That is the intended "metric over an object" path (an `object-metric` KPI widget queries `crm_account` with no authored Cube) but it accepted ANY string. Not covered by #3770: that gated the protocol's `analyticsQuery`, the degraded fallback; a deployment with @objectstack/service-analytics installed runs the real engine instead (`ctx.replaceService`). Inference is now gated on the same schema registry the data path consults, via a new optional `AnalyticsServiceConfig.isRegisteredObject` wired in `plugin.ts` from the `data` engine's `getObject`. A registered Cube runs untouched; an unregistered name that IS an object still auto-infers as before; neither → `CUBE_NOT_FOUND` / 404 raised before any SQL exists. `generateSql` is gated too. With no probe configured the gate stands down and warns once. ② THE DISPATCHER BOUNDARY RETURNED `err.message` VERBATIM. `errorResponseBase` is the single error exit for EVERY route the dispatcher plugin mounts — /analytics, /packages, /i18n, /storage, /automation, /auth, /notifications, /mcp. @objectstack/rest has guarded its data routes forever (`mapDataError`); this boundary guarded nothing. Unlike ①, this half is unconditional. The heuristic moved to @objectstack/types as `looksLikeInternalErrorLeak` and applies at both boundaries. `mapDataError` is unchanged. At the dispatcher it applies ONLY to 5xx — a 4xx message is a deliberate answer. Diagnostics are preserved via the existing `__obsRecordedError` side-channel. ③ ALSO FIXED: `errorResponseBase` read only `err.statusCode`, while domain errors carry `status` (as `errorFromThrown` already assumes). Every deliberate 4xx thrown through a dispatcher route rendered as a 500 — including #3770's `OBJECT_NOT_FOUND` on the analytics fallback path. Verified live, before → after: crm_account KPI 200 rows → 200 rows (unchanged) sqlite_master count 200 {334} → 404 CUBE_NOT_FOUND sqlite_master grouped 200 real rows → 404 CUBE_NOT_FOUND malformed payload raw SQL body → "Internal server error" /analytics/sql ungated → 404 CUBE_NOT_FOUND Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent fd7cfde commit 840ee4b

11 files changed

Lines changed: 629 additions & 15 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
"@objectstack/types": minor
3+
"@objectstack/service-analytics": minor
4+
"@objectstack/runtime": minor
5+
"@objectstack/rest": patch
6+
---
7+
8+
fix(analytics,runtime,types): gate cube auto-inference on object existence; stop the dispatcher boundary returning raw SQL (#3867)
9+
10+
Two independent defects on the `/analytics` surface, found while verifying #3770
11+
against a real server. On an authenticated CRM dev server, before this change:
12+
13+
```
14+
POST /api/v1/analytics/query {"cube":"sqlite_master","measures":["count"],"dimensions":["type"]}
15+
→ 200 {"rows":[{"type":"index","count":262},{"type":"table","count":71},{"type":"view","count":1}],
16+
"sql":"SELECT type AS \"type\", COUNT(*) AS \"count\" FROM \"sqlite_master\" GROUP BY type"}
17+
```
18+
19+
That is SQLite's internal schema table — never a registered object — read
20+
successfully through the analytics endpoint. Not merely "the name reaches the
21+
driver and errors": **any table the connection can see was readable.**
22+
23+
**① The cube name reached the driver as a table name.** `AnalyticsService.ensureCube`
24+
auto-infers a minimal Cube when none is registered, with `cube.sql = <the queried
25+
name>`. That is the intended "metric over an object" path — an `object-metric` KPI
26+
widget queries `crm_account` with no authored Cube — but it accepted *any* string,
27+
so the endpoint could aggregate over an arbitrary physical table. The
28+
analytics-side twin of the data-path gap #3770 closed, and it was not covered by
29+
that fix: #3770 gated the protocol's `analyticsQuery`, which is the *degraded
30+
fallback*; a deployment with `@objectstack/service-analytics` installed runs the
31+
real engine instead (`ctx.replaceService`).
32+
33+
Inference is now gated on the same schema registry the data path consults, via a
34+
new optional `AnalyticsServiceConfig.isRegisteredObject` that `plugin.ts` wires
35+
from the `data` engine's `getObject`. Three-way rule: a registered Cube runs
36+
untouched (its `sql` is whatever it declares); an unregistered name that IS an
37+
object still auto-infers exactly as before; neither → `CUBE_NOT_FOUND` / 404
38+
raised before any SQL exists, naming both ways to make the request valid. With no
39+
probe configured the gate stands down and warns once — the same tiering #3770
40+
took for a missing registry. `generateSql` (`/analytics/sql`) is gated too.
41+
42+
**② The dispatcher boundary returned `err.message` verbatim.** `errorResponseBase`
43+
is the single error exit for *every* route the dispatcher plugin mounts —
44+
`/analytics`, `/packages`, `/i18n`, `/storage`, `/automation`, `/auth`,
45+
`/notifications`, `/mcp`. `@objectstack/rest` has guarded its data routes against
46+
driver dumps forever (`mapDataError`); this boundary guarded nothing, so any
47+
driver error on any of those routes shipped its SQL to the client. Unlike ①, this
48+
half is unconditional — it does not depend on the cube being invalid.
49+
50+
The leak heuristic moved out of `rest-server.ts` into `@objectstack/types` as
51+
`looksLikeInternalErrorLeak` (both packages already depend on it) and is now
52+
applied at both boundaries — one predicate, one place to widen when a new
53+
dialect's phrasing shows up. `mapDataError`'s behaviour is unchanged. At the
54+
dispatcher it applies **only to 5xx**: a 4xx message is a deliberate
55+
business/validation answer and must reach the caller intact. Sanitising costs no
56+
diagnostics — the untouched error still reaches `errorReporter` through the
57+
existing `__obsRecordedError` side-channel.
58+
59+
**Also fixed in the same function:** `errorResponseBase` read only
60+
`err.statusCode`, while domain errors across this codebase carry `status` (and
61+
`HttpDispatcher.errorFromThrown` already reads `status` first). Every deliberate
62+
4xx thrown through a dispatcher route — including #3770's `OBJECT_NOT_FOUND` on
63+
the analytics fallback path — was rendered as a **500**. It now reads `status`
64+
then `statusCode`.
65+
66+
**Behaviour change.** `/analytics/query` and `/analytics/sql` return 404
67+
`CUBE_NOT_FOUND` for a cube that is neither registered nor a registered object;
68+
previously the name was passed to the driver. Dashboards and KPI widgets pointed
69+
at real objects or authored cubes are unaffected. A 5xx on a dispatcher route
70+
whose message looks like a driver dump now reads `Internal server error` — check
71+
server logs or your error reporter for the original.

packages/rest/src/rest-server.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
IHttpServer, resolveAuthzContext, resolveLocalizationContext, isAuthGateAllowlisted,
55
shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS,
66
} from '@objectstack/core';
7-
import { isMcpServerEnabled } from '@objectstack/types';
7+
import { isMcpServerEnabled, looksLikeInternalErrorLeak } from '@objectstack/types';
88
import { allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability';
99
import { RouteManager } from './route-manager.js';
1010
import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api';
@@ -401,17 +401,14 @@ export function mapDataError(error: any, object?: string): { status: number; bod
401401
// Default: do NOT leak raw SQL or driver internals. If the message
402402
// looks like a SQL/driver dump, replace it with a generic envelope
403403
// and rely on server logs for the full diagnostic.
404-
const looksLikeSqlLeak =
405-
lower.includes('sqlite_') ||
406-
lower.includes('sqlstate') ||
407-
lower.startsWith('insert into ') ||
408-
lower.startsWith('update ') ||
409-
lower.startsWith('select ') ||
410-
lower.startsWith('delete from ') ||
411-
lower.includes('constraint failed') ||
412-
lower.includes('unique constraint') ||
413-
lower.includes('foreign key');
414-
if (looksLikeSqlLeak) {
404+
//
405+
// [#3867] The heuristic itself now lives in `@objectstack/types`
406+
// (`looksLikeInternalErrorLeak`) so the OTHER HTTP boundary — the
407+
// dispatcher-plugin routes (`/analytics`, `/packages`, `/i18n`, …) — can
408+
// apply the same rule. Before #3867 that boundary applied none and
409+
// returned raw SQL to clients. Behaviour here is unchanged; only the
410+
// predicate's home moved.
411+
if (looksLikeInternalErrorLeak(raw)) {
415412
// Surface unique-constraint violations as a structured 409 so
416413
// the UI can map them to "this value already exists".
417414
if (lower.includes('unique constraint') || lower.includes('unique violation')) {
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #3867 — the dispatcher-plugin error exit.
5+
*
6+
* `errorResponseBase` is the single error exit for EVERY route this plugin
7+
* mounts (`/analytics`, `/packages`, `/i18n`, `/storage`, `/automation`,
8+
* `/auth`, `/notifications`, `/mcp`, …): each handler catches and calls it
9+
* rather than re-throwing. Two defects lived there, and neither is visible
10+
* until a real error actually reaches it:
11+
*
12+
* 1. It returned `err.message` verbatim. `@objectstack/rest` has guarded its
13+
* data routes against driver dumps forever (`mapDataError`); this boundary
14+
* guarded nothing, so `POST /analytics/query` on an unresolvable cube
15+
* answered with a real SQL statement in the body.
16+
* 2. It read only `err.statusCode`, while domain errors across this codebase
17+
* carry `status` (and `HttpDispatcher.errorFromThrown` already reads
18+
* `status` first). A deliberate 404 rendered as a 500.
19+
*
20+
* These drive the REAL route handler the plugin registers, through the real
21+
* dispatcher, so the assertions are about what an HTTP client receives.
22+
*/
23+
24+
import { describe, it, expect } from 'vitest';
25+
26+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
27+
28+
function makeFakeServer() {
29+
const handlers: Record<string, (req: any, res: any) => any> = {};
30+
const rec = (verb: string) => (path: string, handler: any) => {
31+
handlers[`${verb} ${path}`] = handler;
32+
};
33+
return {
34+
handlers,
35+
server: {
36+
get: rec('GET'),
37+
post: rec('POST'),
38+
put: rec('PUT'),
39+
delete: rec('DELETE'),
40+
patch: rec('PATCH'),
41+
},
42+
};
43+
}
44+
45+
/** A kernel whose `analytics` service throws whatever the test hands it. */
46+
function makeCtx(fakeServer: any, analyticsError: unknown) {
47+
const analytics = {
48+
query: async () => { throw analyticsError; },
49+
getMeta: async () => ({ cubes: [] }),
50+
generateSql: async () => ({ sql: null }),
51+
};
52+
const kernel = {
53+
getService: (name: string) => (name === 'analytics' ? analytics : undefined),
54+
getServiceAsync: async (name: string) => (name === 'analytics' ? analytics : undefined),
55+
};
56+
return {
57+
getKernel: () => kernel,
58+
getService: (name: string) => (name === 'http.server' ? fakeServer : undefined),
59+
environmentId: undefined,
60+
logger: { info() {}, warn() {}, error() {}, debug() {} },
61+
hook: () => {},
62+
on: () => {},
63+
} as any;
64+
}
65+
66+
function makeRes() {
67+
const res: any = {
68+
statusCode: undefined as number | undefined,
69+
body: undefined as any,
70+
status(c: number) { res.statusCode = c; return res; },
71+
header() { return res; },
72+
json(b: any) { res.body = b; return res; },
73+
};
74+
return res;
75+
}
76+
77+
/** Drive `POST /analytics/query` with an analytics service that throws `err`. */
78+
async function postAnalyticsQuery(err: unknown) {
79+
const { server, handlers } = makeFakeServer();
80+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
81+
await plugin.start?.(makeCtx(server, err));
82+
83+
const handler = handlers['POST /api/v1/analytics/query'];
84+
expect(handler, 'POST /api/v1/analytics/query must be mounted').toBeTypeOf('function');
85+
86+
const res = makeRes();
87+
await handler({ body: { cube: 'x', query: {} }, query: {} }, res);
88+
return res;
89+
}
90+
91+
describe('#3867 — dispatcher-plugin error envelope', () => {
92+
it('does not return raw SQL to the client (the message that motivated the issue)', async () => {
93+
const res = await postAnalyticsQuery(
94+
new Error('SELECT FROM "sqlite_sequence" - near "FROM": syntax error'),
95+
);
96+
97+
expect(res.statusCode).toBe(500);
98+
expect(res.body.success).toBe(false);
99+
expect(res.body.error.message).toBe('Internal server error');
100+
// The specifics a client must never see.
101+
expect(String(res.body.error.message)).not.toContain('SELECT');
102+
expect(String(res.body.error.message)).not.toContain('sqlite_sequence');
103+
});
104+
105+
it('still hands the UNSANITISED error to the observability side-channel', async () => {
106+
// Sanitising the response must not cost server-side diagnostics: the
107+
// error reporter reads `__obsRecordedError`, not the response body.
108+
const original = new Error('UNIQUE constraint failed: sys_user.email');
109+
const res = await postAnalyticsQuery(original);
110+
111+
expect(res.statusCode).toBe(500);
112+
expect(res.body.error.message).toBe('Internal server error');
113+
expect((res as any).__obsRecordedError).toBe(original);
114+
});
115+
116+
it('leaves an ordinary 5xx message alone — only leaks are replaced', async () => {
117+
const res = await postAnalyticsQuery(new Error('analytics engine unavailable'));
118+
119+
expect(res.statusCode).toBe(500);
120+
expect(res.body.error.message).toBe('analytics engine unavailable');
121+
});
122+
123+
it('honours `status` (not just `statusCode`) so a domain 404 is not a 500', async () => {
124+
// What the #3867 cube gate throws, and the shape every protocol-layer
125+
// domain error uses (`OBJECT_NOT_FOUND`, `RECORD_NOT_FOUND`, …).
126+
const err = Object.assign(new Error("Cube 'ghost' not found: no cube is registered"), {
127+
code: 'CUBE_NOT_FOUND',
128+
status: 404,
129+
});
130+
const res = await postAnalyticsQuery(err);
131+
132+
expect(res.statusCode).toBe(404);
133+
// A 4xx message is a deliberate answer — it must reach the caller intact.
134+
expect(res.body.error.message).toContain("Cube 'ghost' not found");
135+
});
136+
137+
it('still honours `statusCode` for callers that use it', async () => {
138+
const err = Object.assign(new Error('bad request'), { statusCode: 400 });
139+
const res = await postAnalyticsQuery(err);
140+
141+
expect(res.statusCode).toBe(400);
142+
expect(res.body.error.message).toBe('bad request');
143+
});
144+
145+
it('does not sanitise a 4xx even when its message resembles SQL', async () => {
146+
// Anti-regression for the tier: the guard is scoped to 5xx precisely so
147+
// a deliberate client-facing answer is never swallowed.
148+
const err = Object.assign(new Error('unique constraint on email — pick another'), {
149+
status: 409,
150+
});
151+
const res = await postAnalyticsQuery(err);
152+
153+
expect(res.statusCode).toBe(409);
154+
expect(res.body.error.message).toBe('unique constraint on email — pick another');
155+
});
156+
});

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
4+
import { looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE } from '@objectstack/types';
45
import { HttpDispatcher, HttpDispatcherResult } from './http-dispatcher.js';
56
import {
67
buildSecurityHeaders,
@@ -347,8 +348,38 @@ function sendResultBase(
347348
});
348349
}
349350

351+
/**
352+
* The single error exit for EVERY dispatcher-plugin route — `/analytics`,
353+
* `/packages`, `/i18n`, `/storage`, `/automation`, `/auth`, `/notifications`,
354+
* `/mcp`, … Each handler catches and calls here rather than re-throwing.
355+
*
356+
* [#3867] Two things were wrong with it, both invisible until a driver error
357+
* actually reached this path:
358+
*
359+
* 1. **It only honoured `statusCode`.** Domain errors across this codebase
360+
* carry their HTTP status as `status` (the protocol layer's
361+
* `OBJECT_NOT_FOUND`/`RECORD_NOT_FOUND`/`CLONE_DISABLED`, plugin-sharing's
362+
* `FORBIDDEN`, …); `HttpDispatcher.errorFromThrown` already reads `status`
363+
* first, `statusCode` second. Here a deliberate 404 was rendered as a
364+
* **500** — the wrong code, and it dragged the message through the
365+
* sanitiser below for no reason. Now aligned with `errorFromThrown`.
366+
*
367+
* 2. **It returned `err.message` verbatim.** `@objectstack/rest` has guarded
368+
* its data routes against driver dumps since forever (`mapDataError`), but
369+
* this boundary had no equivalent, so `POST /analytics/query` on an
370+
* unresolvable cube answered with a real SQL statement in the body. The
371+
* shared predicate now applies here too — but ONLY on a 5xx: a 4xx message
372+
* is a deliberate business/validation answer and must reach the caller
373+
* intact.
374+
*
375+
* Sanitising costs no diagnostics: the untouched error is still handed to
376+
* `errorReporter` through the `__obsRecordedError` side-channel below.
377+
*/
350378
function errorResponseBase(err: any, res: any, securityHeaders?: Record<string, string>): void {
351-
const code = err.statusCode || 500;
379+
const code =
380+
(typeof err?.status === 'number' ? err.status : undefined) ??
381+
(typeof err?.statusCode === 'number' ? err.statusCode : undefined) ??
382+
500;
352383
res.status(code);
353384
if (securityHeaders) {
354385
for (const [k, v] of Object.entries(securityHeaders)) {
@@ -366,9 +397,14 @@ function errorResponseBase(err: any, res: any, securityHeaders?: Record<string,
366397
// res is a frozen / proxy object — skip
367398
}
368399
}
400+
const raw = err?.message;
401+
const message =
402+
code >= 500 && looksLikeInternalErrorLeak(raw)
403+
? INTERNAL_ERROR_MESSAGE
404+
: raw || 'Internal Server Error';
369405
res.json({
370406
success: false,
371-
error: { message: err.message || 'Internal Server Error', code },
407+
error: { message, code },
372408
});
373409
}
374410

packages/services/service-analytics/src/__tests__/analytics-service.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,7 +725,15 @@ describe('AnalyticsService — auto-inferred cube log level', () => {
725725
it('logs at debug (not warn) for a scalar metric over an unregistered cube', async () => {
726726
const logger = { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(), child: vi.fn().mockReturnThis() } as any;
727727
await makeService(logger).query({ cube: 'showcase_task', measures: ['count'] });
728-
expect(logger.warn).not.toHaveBeenCalled();
728+
// [#3867] Asserted against THIS message rather than "warn was never called
729+
// at all". These services are built without `isRegisteredObject`, so the
730+
// cube-existence gate correctly stands down and warns once about being
731+
// inactive — a different message, and not what this pair is about. The
732+
// contract here is the LEVEL of the no-cube-registered log, which is
733+
// exactly how the sibling case below already asserts it.
734+
expect(logger.warn).not.toHaveBeenCalledWith(
735+
expect.stringContaining('No cube registered for "showcase_task"'),
736+
);
729737
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('No cube registered for "showcase_task"'));
730738
});
731739

0 commit comments

Comments
 (0)