Skip to content

Commit 3c548bc

Browse files
authored
Merge pull request #2841: enforce declared auth:true on /meta + /ai route families
Enforce the requireAuth gate on the /meta (rest-server) and /ai (dispatcher) route families, mirroring /data. Merged via merge-commit so the cloud-pinned commit cb662a5 stays reachable in main history.
2 parents e723ac5 + cb662a5 commit 3c548bc

6 files changed

Lines changed: 317 additions & 9 deletions

File tree

packages/cli/src/commands/serve.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1538,6 +1538,9 @@ export default class Serve extends Command {
15381538
createDispatcherPlugin({
15391539
scoping: { enableProjectScoping, projectResolution },
15401540
enforceProjectMembership,
1541+
// Keep the dispatcher's `auth: true` service routes (AI) in
1542+
// lockstep with the REST `/data` gate above — same `requireAuth`.
1543+
requireAuth,
15411544
observability,
15421545
}),
15431546
);
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Regression coverage for the metadata-route auth gate. The `/meta/*` routes
4+
// were registered WITHOUT the `enforceAuth` call the `/data` routes have, so on
5+
// a `requireAuth` deployment an anonymous caller could read object/field
6+
// schemas (system-object schemas on a tenant-less host — a public leak).
7+
// registerMetadataEndpoints now wraps every meta route so it inherits the same
8+
// gate; these tests lock that in.
9+
10+
import { describe, it, expect, vi } from 'vitest';
11+
import { RestServer } from './rest-server';
12+
13+
const makeServer = () => ({
14+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
15+
use: vi.fn(), listen: vi.fn(), close: vi.fn(),
16+
});
17+
18+
const makeRes = () => {
19+
const state: any = { status: 200, body: undefined };
20+
const res: any = {
21+
status: (c: number) => { state.status = c; return res; },
22+
json: (b: any) => { state.body = b; return res; },
23+
header: () => res,
24+
send: () => {},
25+
};
26+
return { res, state };
27+
};
28+
29+
// The list handler for `GET /meta/:type` — the exact route the leak probe hit.
30+
const metaListHandler = (rest: RestServer) => {
31+
const route = rest
32+
.getRoutes()
33+
.find((r) => r.method === 'GET' && /\/meta\/:type$/.test(r.path));
34+
if (!route) throw new Error('GET /meta/:type route not registered');
35+
return route.handler as (req: any, res: any) => Promise<void>;
36+
};
37+
38+
describe('RestServer metadata routes — requireAuth gate', () => {
39+
it('401s an anonymous caller when requireAuth is on', async () => {
40+
const protocol: any = { getMetaItems: vi.fn().mockResolvedValue({ type: 'object', items: [] }) };
41+
const rest = new RestServer(makeServer() as any, protocol, { api: { requireAuth: true } } as any);
42+
rest.registerRoutes();
43+
const handler = metaListHandler(rest);
44+
45+
const { res, state } = makeRes();
46+
await handler({ method: 'GET', params: { type: 'object' }, query: {}, headers: {} }, res);
47+
48+
expect(state.status).toBe(401);
49+
expect(state.body?.error).toBe('unauthenticated');
50+
// The gate short-circuits BEFORE the schema read — nothing leaked.
51+
expect(protocol.getMetaItems).not.toHaveBeenCalled();
52+
});
53+
54+
it('lets an authenticated caller read metadata (gate passes through)', async () => {
55+
const protocol: any = { getMetaItems: vi.fn().mockResolvedValue({ type: 'object', items: [{ name: 'sys_metadata' }] }) };
56+
const rest = new RestServer(makeServer() as any, protocol, { api: { requireAuth: true } } as any);
57+
// A resolved session — the same shape resolveExecCtx yields for a
58+
// signed-in request (per-env session via hostname / scoped id).
59+
(rest as any).resolveExecCtx = vi.fn().mockResolvedValue({ userId: 'u1' });
60+
rest.registerRoutes();
61+
const handler = metaListHandler(rest);
62+
63+
const { res, state } = makeRes();
64+
await handler({ method: 'GET', params: { type: 'object' }, query: {}, headers: {} }, res);
65+
66+
expect(state.status).not.toBe(401);
67+
expect(protocol.getMetaItems).toHaveBeenCalled();
68+
});
69+
70+
it('serves anonymously when requireAuth is off (unchanged public behaviour)', async () => {
71+
const protocol: any = { getMetaItems: vi.fn().mockResolvedValue({ type: 'object', items: [] }) };
72+
const rest = new RestServer(makeServer() as any, protocol, { api: { requireAuth: false } } as any);
73+
rest.registerRoutes();
74+
const handler = metaListHandler(rest);
75+
76+
const { res, state } = makeRes();
77+
await handler({ method: 'GET', params: { type: 'object' }, query: {}, headers: {} }, res);
78+
79+
expect(state.status).not.toBe(401);
80+
expect(protocol.getMetaItems).toHaveBeenCalled();
81+
});
82+
});

packages/rest/src/rest-server.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1935,9 +1935,54 @@ export class RestServer {
19351935
}
19361936

19371937
/**
1938-
* Register metadata endpoints
1938+
* Register the metadata routes behind the SAME `requireAuth` gate the
1939+
* `/data` routes use.
1940+
*
1941+
* `registerMetadataEndpoints` builds ~17 `/meta/*` routes but — unlike the
1942+
* `/data` handlers — never calls {@link enforceAuth}: its handlers assumed
1943+
* the `requireAuth` gate rejected anonymous callers "upstream", yet nothing
1944+
* upstream covers `/meta`, so on a `requireAuth` deployment an anonymous
1945+
* caller could read object / field schemas. On a tenant-less runtime host
1946+
* those are SYSTEM-object schemas and the host is publicly reachable — a
1947+
* real leak.
1948+
*
1949+
* Rather than add the gate to every handler (and have the next new route
1950+
* forget it — the exact failure mode that caused this), wrap the route
1951+
* registrar for the duration of registration so every meta route, present
1952+
* and future, inherits it. The check is a no-op when `requireAuth` is off
1953+
* (demo / single-tenant), so the previously-public metadata surface there
1954+
* is unchanged; an authenticated user passes exactly as on `/data`.
19391955
*/
19401956
private registerMetadataEndpoints(basePath: string): void {
1957+
const realRouteManager = this.routeManager;
1958+
const guardedRouteManager = {
1959+
register: (entry: { handler: unknown; [k: string]: unknown }) => {
1960+
const inner = entry.handler;
1961+
if (typeof inner !== 'function') return realRouteManager.register(entry as any);
1962+
return realRouteManager.register({
1963+
...entry,
1964+
handler: async (req: any, res: any) => {
1965+
// `req.params.environmentId` is present only on the
1966+
// scoped `/environments/:id/meta/...` variant — mirrors
1967+
// the `isScoped ? req.params.environmentId : undefined`
1968+
// each `/data` handler derives.
1969+
const environmentId = req?.params?.environmentId;
1970+
const context = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
1971+
if (this.enforceAuth(req, res, context)) return;
1972+
return (inner as (rq: any, rs: any) => unknown)(req, res);
1973+
},
1974+
} as any);
1975+
},
1976+
} as unknown as RouteManager;
1977+
this.routeManager = guardedRouteManager;
1978+
try {
1979+
this.registerMetadataEndpointsInner(basePath);
1980+
} finally {
1981+
this.routeManager = realRouteManager;
1982+
}
1983+
}
1984+
1985+
private registerMetadataEndpointsInner(basePath: string): void {
19411986
const { metadata } = this.config;
19421987
const metaPath = `${basePath}${metadata.prefix}`;
19431988
const isScoped = basePath.includes('/environments/:environmentId');

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ export interface DispatcherPluginConfig {
4848
*/
4949
enforceProjectMembership?: boolean;
5050

51+
/**
52+
* Reject anonymous requests to `auth: true` service routes (AI, etc.) with
53+
* HTTP 401, mirroring the REST API's `requireAuth` gate. Must match the
54+
* REST plugin's `api.requireAuth` so `/ai` and `/meta` stay in lockstep
55+
* with `/data` — otherwise the AI routes' declared `auth: true` contract is
56+
* never enforced and anonymous callers reach adapter/model status routes.
57+
*
58+
* Defaults to `false` (backward-compatible: previously nothing enforced
59+
* `RouteDefinition.auth` here). Hosts pass their `api.requireAuth` through —
60+
* the framework `serve` command and the cloud apps do so from the same
61+
* stack `api` config the REST plugin reads.
62+
*/
63+
requireAuth?: boolean;
64+
5165
/**
5266
* Security response headers. When provided, every response routed
5367
* through this plugin gets the headers merged in (route-specific
@@ -99,6 +113,10 @@ interface RouteDefinition {
99113
method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
100114
path: string;
101115
description: string;
116+
/** Whether this route requires authentication (default: true). */
117+
auth?: boolean;
118+
/** Required permissions for accessing this route. */
119+
permissions?: string[];
102120
handler: (req: any) => Promise<any>;
103121
}
104122

@@ -112,6 +130,7 @@ function mountRouteOnServer(
112130
routePath: string,
113131
securityHeaders?: Record<string, string>,
114132
resolveUser?: (headers: Record<string, any>) => Promise<any | undefined>,
133+
requireAuth = false,
115134
): boolean {
116135
const handler = async (req: any, res: any) => {
117136
try {
@@ -124,8 +143,26 @@ function mountRouteOnServer(
124143
try {
125144
user = await resolveUser(req.headers ?? {});
126145
} catch {
127-
/* fall through anonymous — route's `auth: true` guard runs separately */
146+
/* fall through anonymous — enforced just below */
147+
}
148+
}
149+
150+
// Enforce the route's declared `auth` contract. This used to be
151+
// assumed to run "separately"/upstream, but nothing did: an
152+
// anonymous caller reached `auth: true` handlers (e.g.
153+
// `GET /ai/status`) and got adapter/model config back. Gate here
154+
// when the deployment requires auth. Off (or `auth: false`) → the
155+
// handler runs as before.
156+
if (requireAuth && route.auth !== false && !user) {
157+
res.status(401);
158+
if (securityHeaders) {
159+
for (const [k, v] of Object.entries(securityHeaders)) res.header(k, v);
128160
}
161+
res.json({
162+
error: 'unauthenticated',
163+
message: 'Authentication is required to access this endpoint.',
164+
});
165+
return;
129166
}
130167

131168
const result = await route.handler({
@@ -377,8 +414,15 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
377414
// Tests / single-tenant deploys can opt out via the explicit flag.
378415
const enforceMembership =
379416
config.enforceProjectMembership ?? (config.scoping?.enableProjectScoping ?? false);
417+
// Secure-by-default alignment with the REST plugin's `requireAuth`.
418+
// The cloud apps pass the whole stack `api` block as `scoping`
419+
// (which carries `requireAuth`), so honour it there too; an explicit
420+
// top-level `requireAuth` wins. Off → unchanged (routes stay open).
421+
const requireAuth =
422+
config.requireAuth ?? (config.scoping as { requireAuth?: boolean } | undefined)?.requireAuth ?? false;
380423
const dispatcher = new HttpDispatcher(kernel, undefined, {
381424
enforceProjectMembership: enforceMembership,
425+
requireAuth,
382426
});
383427
const prefix = config.prefix || '/api/v1';
384428

@@ -1095,11 +1139,11 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
10951139

10961140
let count = 0;
10971141
if (enableProjectScoping && projectResolution === 'required') {
1098-
if (mountRouteOnServer(route, server, toScopedPath(routePath), securityHeaders, resolveRequestUser)) count++;
1142+
if (mountRouteOnServer(route, server, toScopedPath(routePath), securityHeaders, resolveRequestUser, requireAuth)) count++;
10991143
} else {
1100-
if (mountRouteOnServer(route, server, routePath, securityHeaders, resolveRequestUser)) count++;
1144+
if (mountRouteOnServer(route, server, routePath, securityHeaders, resolveRequestUser, requireAuth)) count++;
11011145
if (enableProjectScoping) {
1102-
if (mountRouteOnServer(route, server, toScopedPath(routePath), securityHeaders, resolveRequestUser)) count++;
1146+
if (mountRouteOnServer(route, server, toScopedPath(routePath), securityHeaders, resolveRequestUser, requireAuth)) count++;
11031147
}
11041148
}
11051149
return count;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Regression coverage for the `requireAuth` gate on the dispatcher's service
4+
// route families (AI + metadata catch-all). These routes declare `auth: true`
5+
// but nothing enforced it before — an anonymous caller reached e.g.
6+
// `GET /api/v1/ai/status` (and the metadata reader) on a tenant-less host and
7+
// got adapter/model/schema data back. The gate mirrors the REST `enforceAuth`
8+
// seam: on a `requireAuth` deployment, anonymous callers get 401 while an
9+
// authenticated (or internal system) context passes.
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { HttpDispatcher } from './http-dispatcher.js';
13+
14+
const aiRoute = {
15+
method: 'GET',
16+
path: '/api/v1/ai/status',
17+
auth: true,
18+
handler: async () => ({ status: 200, body: { adapter: 'test' } }),
19+
};
20+
21+
const makeKernel = (extra: Record<string, unknown> = {}) =>
22+
({
23+
context: {
24+
getService: (name: string) => (name === 'ai' ? { adapterName: 'test' } : null),
25+
},
26+
__aiRoutes: [aiRoute],
27+
...extra,
28+
}) as any;
29+
30+
const anon = { request: {}, executionContext: undefined } as any;
31+
const authed = { request: {}, executionContext: { userId: 'u1' } } as any;
32+
const system = { request: {}, executionContext: { isSystem: true } } as any;
33+
34+
describe('HttpDispatcher requireAuth gate — AI routes (handleAI)', () => {
35+
it('401s an anonymous caller when requireAuth is on', async () => {
36+
const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true });
37+
const r = await d.handleAI('/ai/status', 'GET', undefined, undefined, anon);
38+
expect(r.response?.status).toBe(401);
39+
expect(r.response?.body?.error?.details?.code ?? r.response?.body?.error?.code).toBeDefined();
40+
});
41+
42+
it('lets an authenticated caller through', async () => {
43+
const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true });
44+
const r = await d.handleAI('/ai/status', 'GET', undefined, undefined, authed);
45+
expect(r.response?.status).toBe(200);
46+
expect(r.response?.body?.adapter).toBe('test');
47+
});
48+
49+
it('lets an internal system context through', async () => {
50+
const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true });
51+
const r = await d.handleAI('/ai/status', 'GET', undefined, undefined, system);
52+
expect(r.response?.status).toBe(200);
53+
});
54+
55+
it('serves anonymously when requireAuth is off (unchanged legacy behaviour)', async () => {
56+
const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: false });
57+
const r = await d.handleAI('/ai/status', 'GET', undefined, undefined, anon);
58+
expect(r.response?.status).toBe(200);
59+
});
60+
61+
it('does not gate a route that opts out with auth:false', async () => {
62+
const openRoute = { ...aiRoute, path: '/api/v1/ai/public', auth: false };
63+
const d = new HttpDispatcher(makeKernel({ __aiRoutes: [openRoute] }), undefined, { requireAuth: true });
64+
const r = await d.handleAI('/ai/public', 'GET', undefined, undefined, anon);
65+
expect(r.response?.status).toBe(200);
66+
});
67+
});
68+
69+
describe('HttpDispatcher requireAuth gate — metadata catch-all (handleMetadata)', () => {
70+
it('401s an anonymous caller when requireAuth is on', async () => {
71+
const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true });
72+
const r = await d.handleMetadata('/object', anon, 'GET');
73+
expect(r.response?.status).toBe(401);
74+
});
75+
76+
it('does not 401 an authenticated caller (proceeds past the gate)', async () => {
77+
const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true });
78+
const r = await d.handleMetadata('/object', authed, 'GET');
79+
expect(r.response?.status).not.toBe(401);
80+
});
81+
82+
it('does not 401 when requireAuth is off', async () => {
83+
const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: false });
84+
const r = await d.handleMetadata('/object', anon, 'GET');
85+
expect(r.response?.status).not.toBe(401);
86+
});
87+
});

0 commit comments

Comments
 (0)