diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index d877be687c..f509619f79 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1489,6 +1489,9 @@ export default class Serve extends Command { createDispatcherPlugin({ scoping: { enableProjectScoping, projectResolution }, enforceProjectMembership, + // Keep the dispatcher's `auth: true` service routes (AI) in + // lockstep with the REST `/data` gate above — same `requireAuth`. + requireAuth, observability, }), ); diff --git a/packages/rest/src/rest-meta-auth.test.ts b/packages/rest/src/rest-meta-auth.test.ts new file mode 100644 index 0000000000..cb8ea1c925 --- /dev/null +++ b/packages/rest/src/rest-meta-auth.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// Regression coverage for the metadata-route auth gate. The `/meta/*` routes +// were registered WITHOUT the `enforceAuth` call the `/data` routes have, so on +// a `requireAuth` deployment an anonymous caller could read object/field +// schemas (system-object schemas on a tenant-less host — a public leak). +// registerMetadataEndpoints now wraps every meta route so it inherits the same +// gate; these tests lock that in. + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +const makeServer = () => ({ + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn(), close: vi.fn(), +}); + +const makeRes = () => { + const state: any = { status: 200, body: undefined }; + const res: any = { + status: (c: number) => { state.status = c; return res; }, + json: (b: any) => { state.body = b; return res; }, + header: () => res, + send: () => {}, + }; + return { res, state }; +}; + +// The list handler for `GET /meta/:type` — the exact route the leak probe hit. +const metaListHandler = (rest: RestServer) => { + const route = rest + .getRoutes() + .find((r) => r.method === 'GET' && /\/meta\/:type$/.test(r.path)); + if (!route) throw new Error('GET /meta/:type route not registered'); + return route.handler as (req: any, res: any) => Promise; +}; + +describe('RestServer metadata routes — requireAuth gate', () => { + it('401s an anonymous caller when requireAuth is on', async () => { + const protocol: any = { getMetaItems: vi.fn().mockResolvedValue({ type: 'object', items: [] }) }; + const rest = new RestServer(makeServer() as any, protocol, { api: { requireAuth: true } } as any); + rest.registerRoutes(); + const handler = metaListHandler(rest); + + const { res, state } = makeRes(); + await handler({ method: 'GET', params: { type: 'object' }, query: {}, headers: {} }, res); + + expect(state.status).toBe(401); + expect(state.body?.error).toBe('unauthenticated'); + // The gate short-circuits BEFORE the schema read — nothing leaked. + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + }); + + it('lets an authenticated caller read metadata (gate passes through)', async () => { + const protocol: any = { getMetaItems: vi.fn().mockResolvedValue({ type: 'object', items: [{ name: 'sys_metadata' }] }) }; + const rest = new RestServer(makeServer() as any, protocol, { api: { requireAuth: true } } as any); + // A resolved session — the same shape resolveExecCtx yields for a + // signed-in request (per-env session via hostname / scoped id). + (rest as any).resolveExecCtx = vi.fn().mockResolvedValue({ userId: 'u1' }); + rest.registerRoutes(); + const handler = metaListHandler(rest); + + const { res, state } = makeRes(); + await handler({ method: 'GET', params: { type: 'object' }, query: {}, headers: {} }, res); + + expect(state.status).not.toBe(401); + expect(protocol.getMetaItems).toHaveBeenCalled(); + }); + + it('serves anonymously when requireAuth is off (unchanged public behaviour)', async () => { + const protocol: any = { getMetaItems: vi.fn().mockResolvedValue({ type: 'object', items: [] }) }; + const rest = new RestServer(makeServer() as any, protocol, { api: { requireAuth: false } } as any); + rest.registerRoutes(); + const handler = metaListHandler(rest); + + const { res, state } = makeRes(); + await handler({ method: 'GET', params: { type: 'object' }, query: {}, headers: {} }, res); + + expect(state.status).not.toBe(401); + expect(protocol.getMetaItems).toHaveBeenCalled(); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 51481b2fa7..73a08c6ce0 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1935,9 +1935,54 @@ export class RestServer { } /** - * Register metadata endpoints + * Register the metadata routes behind the SAME `requireAuth` gate the + * `/data` routes use. + * + * `registerMetadataEndpoints` builds ~17 `/meta/*` routes but — unlike the + * `/data` handlers — never calls {@link enforceAuth}: its handlers assumed + * the `requireAuth` gate rejected anonymous callers "upstream", yet nothing + * upstream covers `/meta`, so on a `requireAuth` deployment an anonymous + * caller could read object / field schemas. On a tenant-less runtime host + * those are SYSTEM-object schemas and the host is publicly reachable — a + * real leak. + * + * Rather than add the gate to every handler (and have the next new route + * forget it — the exact failure mode that caused this), wrap the route + * registrar for the duration of registration so every meta route, present + * and future, inherits it. The check is a no-op when `requireAuth` is off + * (demo / single-tenant), so the previously-public metadata surface there + * is unchanged; an authenticated user passes exactly as on `/data`. */ private registerMetadataEndpoints(basePath: string): void { + const realRouteManager = this.routeManager; + const guardedRouteManager = { + register: (entry: { handler: unknown; [k: string]: unknown }) => { + const inner = entry.handler; + if (typeof inner !== 'function') return realRouteManager.register(entry as any); + return realRouteManager.register({ + ...entry, + handler: async (req: any, res: any) => { + // `req.params.environmentId` is present only on the + // scoped `/environments/:id/meta/...` variant — mirrors + // the `isScoped ? req.params.environmentId : undefined` + // each `/data` handler derives. + const environmentId = req?.params?.environmentId; + const context = await this.resolveExecCtx(environmentId, req).catch(() => undefined); + if (this.enforceAuth(req, res, context)) return; + return (inner as (rq: any, rs: any) => unknown)(req, res); + }, + } as any); + }, + } as unknown as RouteManager; + this.routeManager = guardedRouteManager; + try { + this.registerMetadataEndpointsInner(basePath); + } finally { + this.routeManager = realRouteManager; + } + } + + private registerMetadataEndpointsInner(basePath: string): void { const { metadata } = this.config; const metaPath = `${basePath}${metadata.prefix}`; const isScoped = basePath.includes('/environments/:environmentId'); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 566b1e0139..0757b18ba5 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -48,6 +48,20 @@ export interface DispatcherPluginConfig { */ enforceProjectMembership?: boolean; + /** + * Reject anonymous requests to `auth: true` service routes (AI, etc.) with + * HTTP 401, mirroring the REST API's `requireAuth` gate. Must match the + * REST plugin's `api.requireAuth` so `/ai` and `/meta` stay in lockstep + * with `/data` — otherwise the AI routes' declared `auth: true` contract is + * never enforced and anonymous callers reach adapter/model status routes. + * + * Defaults to `false` (backward-compatible: previously nothing enforced + * `RouteDefinition.auth` here). Hosts pass their `api.requireAuth` through — + * the framework `serve` command and the cloud apps do so from the same + * stack `api` config the REST plugin reads. + */ + requireAuth?: boolean; + /** * Security response headers. When provided, every response routed * through this plugin gets the headers merged in (route-specific @@ -99,6 +113,10 @@ interface RouteDefinition { method: 'GET' | 'POST' | 'PATCH' | 'DELETE'; path: string; description: string; + /** Whether this route requires authentication (default: true). */ + auth?: boolean; + /** Required permissions for accessing this route. */ + permissions?: string[]; handler: (req: any) => Promise; } @@ -112,6 +130,7 @@ function mountRouteOnServer( routePath: string, securityHeaders?: Record, resolveUser?: (headers: Record) => Promise, + requireAuth = false, ): boolean { const handler = async (req: any, res: any) => { try { @@ -124,8 +143,26 @@ function mountRouteOnServer( try { user = await resolveUser(req.headers ?? {}); } catch { - /* fall through anonymous — route's `auth: true` guard runs separately */ + /* fall through anonymous — enforced just below */ + } + } + + // Enforce the route's declared `auth` contract. This used to be + // assumed to run "separately"/upstream, but nothing did: an + // anonymous caller reached `auth: true` handlers (e.g. + // `GET /ai/status`) and got adapter/model config back. Gate here + // when the deployment requires auth. Off (or `auth: false`) → the + // handler runs as before. + if (requireAuth && route.auth !== false && !user) { + res.status(401); + if (securityHeaders) { + for (const [k, v] of Object.entries(securityHeaders)) res.header(k, v); } + res.json({ + error: 'unauthenticated', + message: 'Authentication is required to access this endpoint.', + }); + return; } const result = await route.handler({ @@ -377,8 +414,15 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // Tests / single-tenant deploys can opt out via the explicit flag. const enforceMembership = config.enforceProjectMembership ?? (config.scoping?.enableProjectScoping ?? false); + // Secure-by-default alignment with the REST plugin's `requireAuth`. + // The cloud apps pass the whole stack `api` block as `scoping` + // (which carries `requireAuth`), so honour it there too; an explicit + // top-level `requireAuth` wins. Off → unchanged (routes stay open). + const requireAuth = + config.requireAuth ?? (config.scoping as { requireAuth?: boolean } | undefined)?.requireAuth ?? false; const dispatcher = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: enforceMembership, + requireAuth, }); const prefix = config.prefix || '/api/v1'; @@ -1095,11 +1139,11 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu let count = 0; if (enableProjectScoping && projectResolution === 'required') { - if (mountRouteOnServer(route, server, toScopedPath(routePath), securityHeaders, resolveRequestUser)) count++; + if (mountRouteOnServer(route, server, toScopedPath(routePath), securityHeaders, resolveRequestUser, requireAuth)) count++; } else { - if (mountRouteOnServer(route, server, routePath, securityHeaders, resolveRequestUser)) count++; + if (mountRouteOnServer(route, server, routePath, securityHeaders, resolveRequestUser, requireAuth)) count++; if (enableProjectScoping) { - if (mountRouteOnServer(route, server, toScopedPath(routePath), securityHeaders, resolveRequestUser)) count++; + if (mountRouteOnServer(route, server, toScopedPath(routePath), securityHeaders, resolveRequestUser, requireAuth)) count++; } } return count; diff --git a/packages/runtime/src/http-dispatcher.requireauth.test.ts b/packages/runtime/src/http-dispatcher.requireauth.test.ts new file mode 100644 index 0000000000..94cdb0748e --- /dev/null +++ b/packages/runtime/src/http-dispatcher.requireauth.test.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// Regression coverage for the `requireAuth` gate on the dispatcher's service +// route families (AI + metadata catch-all). These routes declare `auth: true` +// but nothing enforced it before — an anonymous caller reached e.g. +// `GET /api/v1/ai/status` (and the metadata reader) on a tenant-less host and +// got adapter/model/schema data back. The gate mirrors the REST `enforceAuth` +// seam: on a `requireAuth` deployment, anonymous callers get 401 while an +// authenticated (or internal system) context passes. + +import { describe, it, expect } from 'vitest'; +import { HttpDispatcher } from './http-dispatcher.js'; + +const aiRoute = { + method: 'GET', + path: '/api/v1/ai/status', + auth: true, + handler: async () => ({ status: 200, body: { adapter: 'test' } }), +}; + +const makeKernel = (extra: Record = {}) => + ({ + context: { + getService: (name: string) => (name === 'ai' ? { adapterName: 'test' } : null), + }, + __aiRoutes: [aiRoute], + ...extra, + }) as any; + +const anon = { request: {}, executionContext: undefined } as any; +const authed = { request: {}, executionContext: { userId: 'u1' } } as any; +const system = { request: {}, executionContext: { isSystem: true } } as any; + +describe('HttpDispatcher requireAuth gate — AI routes (handleAI)', () => { + it('401s an anonymous caller when requireAuth is on', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + const r = await d.handleAI('/ai/status', 'GET', undefined, undefined, anon); + expect(r.response?.status).toBe(401); + expect(r.response?.body?.error?.details?.code ?? r.response?.body?.error?.code).toBeDefined(); + }); + + it('lets an authenticated caller through', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + const r = await d.handleAI('/ai/status', 'GET', undefined, undefined, authed); + expect(r.response?.status).toBe(200); + expect(r.response?.body?.adapter).toBe('test'); + }); + + it('lets an internal system context through', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + const r = await d.handleAI('/ai/status', 'GET', undefined, undefined, system); + expect(r.response?.status).toBe(200); + }); + + it('serves anonymously when requireAuth is off (unchanged legacy behaviour)', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: false }); + const r = await d.handleAI('/ai/status', 'GET', undefined, undefined, anon); + expect(r.response?.status).toBe(200); + }); + + it('does not gate a route that opts out with auth:false', async () => { + const openRoute = { ...aiRoute, path: '/api/v1/ai/public', auth: false }; + const d = new HttpDispatcher(makeKernel({ __aiRoutes: [openRoute] }), undefined, { requireAuth: true }); + const r = await d.handleAI('/ai/public', 'GET', undefined, undefined, anon); + expect(r.response?.status).toBe(200); + }); +}); + +describe('HttpDispatcher requireAuth gate — metadata catch-all (handleMetadata)', () => { + it('401s an anonymous caller when requireAuth is on', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + const r = await d.handleMetadata('/object', anon, 'GET'); + expect(r.response?.status).toBe(401); + }); + + it('does not 401 an authenticated caller (proceeds past the gate)', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + const r = await d.handleMetadata('/object', authed, 'GET'); + expect(r.response?.status).not.toBe(401); + }); + + it('does not 401 when requireAuth is off', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: false }); + const r = await d.handleMetadata('/object', anon, 'GET'); + expect(r.response?.status).not.toBe(401); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index a871942cf4..e142776e0a 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -126,6 +126,14 @@ export interface HttpDispatcherOptions { * called on every scoped request so idle projects are evicted after TTL. */ scopeManager?: EnvironmentScopeManager; + /** + * Reject anonymous requests to `auth: true` service routes (AI) and to the + * metadata catch-all with HTTP 401, mirroring the REST API's `requireAuth` + * gate. Matches {@link DispatcherPluginConfig.requireAuth}; the dispatcher + * plugin threads the host's `api.requireAuth` here. Defaults to `false` + * (backward-compatible — nothing enforced `RouteDefinition.auth` before). + */ + requireAuth?: boolean; } /** @@ -154,6 +162,12 @@ export class HttpDispatcher { * `DispatcherConfig.enforceProjectMembership`). */ private enforceMembership: boolean; + /** + * When `true`, `auth: true` AI routes and the metadata catch-all reject + * anonymous callers with 401 (mirrors the REST `requireAuth` gate). Set + * from {@link HttpDispatcherOptions.requireAuth}. Defaults to `false`. + */ + private requireAuth: boolean; /** * In-memory cache of positive membership checks, keyed by * `${environmentId}:${userId}`. Entries expire 60 seconds after insertion @@ -180,6 +194,7 @@ export class HttpDispatcher { try { return (kernel as any).getService?.(name); } catch { return undefined; } }; this.enforceMembership = options?.enforceProjectMembership ?? true; + this.requireAuth = options?.requireAuth ?? false; // ADR-0006 kernel-resolution seam — the host's resolver owns env // resolution + kernel selection. Optional service so single-environment // hosts that register none are unchanged. @@ -1565,8 +1580,22 @@ export class HttpDispatcher { * Fallback for backward compat: /metadata (all objects), /metadata/:objectName (get object) */ async handleMetadata(path: string, _context: HttpProtocolContext, method?: string, body?: any, query?: any): Promise { + // Defense-in-depth: the metadata catch-all must honour the same + // `requireAuth` gate as the REST `/meta` routes (which serve `/meta` on + // the cloud runtime). Object/field schemas — SYSTEM-object schemas on a + // tenant-less host — must not be readable by anonymous callers when the + // deployment requires auth. No-op when `requireAuth` is off. + if (this.requireAuth) { + const ec: any = _context.executionContext; + if (!ec?.userId && !ec?.isSystem) { + return { + handled: true, + response: this.error('Authentication is required to access this endpoint.', 401, { code: 'unauthenticated' }), + }; + } + } const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); - + // GET /metadata/types if (parts[0] === 'types') { // PRIORITY 1: Try protocol service — it returns BOTH legacy @@ -3589,7 +3618,7 @@ export class HttpDispatcher { // Try to get route definitions from the AI service's cached routes const routes = (this.kernel as any).__aiRoutes as Array<{ - method: string; path: string; handler: (req: any) => Promise; + method: string; path: string; handler: (req: any) => Promise; auth?: boolean; }> | undefined; if (!routes) { @@ -3607,12 +3636,30 @@ export class HttpDispatcher { const params = matchRoute(route.path, fullPath); if (params === null) continue; + // Enforce the route's declared `auth` contract. Nothing upstream + // does: `enforceAuthGate` only covers ADR-0069 password/MFA gates + // and `enforceProjectMembership` bails when the request is + // anonymous or unscoped — so without this an anonymous caller + // reached `auth: true` handlers (e.g. GET /ai/status) and got the + // adapter/model config back. Gate when the deployment requires + // auth; an authenticated user (or an internal system context) + // passes, matching the REST `enforceAuth` seam. Off → unchanged. + if (this.requireAuth && route.auth !== false) { + const gec: any = context.executionContext; + if (!gec?.userId && !gec?.isSystem) { + return { + handled: true, + response: this.error('Authentication is required to access this endpoint.', 401, { code: 'unauthenticated' }), + }; + } + } + // Resolve `req.user` from the already-resolved ExecutionContext so // AI route handlers can attribute the call to the authenticated // actor (drives auto-titled conversations, permission-aware // tools, HITL conversation linkage, …). Falls back to undefined - // for anonymous requests — the route's own `auth: true` guard - // is enforced by upstream middleware. + // for anonymous requests (only reachable when the deployment does + // NOT require auth — the gate above rejects them otherwise). const ec: any = context.executionContext; // `ai_seat` is synthesized into ec.permissions by resolveExecutionContext // (the single, scope-correct source — security/resolve-execution-context.ts),