Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
);
Expand Down
82 changes: 82 additions & 0 deletions packages/rest/src/rest-meta-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
};

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();
});
});
47 changes: 46 additions & 1 deletion packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
52 changes: 48 additions & 4 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<any>;
}

Expand All @@ -112,6 +130,7 @@ function mountRouteOnServer(
routePath: string,
securityHeaders?: Record<string, string>,
resolveUser?: (headers: Record<string, any>) => Promise<any | undefined>,
requireAuth = false,
): boolean {
const handler = async (req: any, res: any) => {
try {
Expand All @@ -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({
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down
87 changes: 87 additions & 0 deletions packages/runtime/src/http-dispatcher.requireauth.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) =>
({
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);
});
});
Loading