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
21 changes: 21 additions & 0 deletions .changeset/settings-routes-verified-authz.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/service-settings': minor
'@objectstack/spec': minor
'@objectstack/plugin-security': patch
---

**Security fix (Critical): the settings HTTP routes no longer trust spoofable identity headers, and writes are now capability-gated.**

Previously `GET/PUT/POST /api/settings/*` derived the caller's identity from `x-user-id` / `x-tenant-id` / `x-permissions` request headers (the route default), and `setMany` performed **no permission check** — so on a standard `os serve --server` deployment (settings + HTTP server composed by default, routes registered on the raw app with no auth middleware) an **unauthenticated** remote client could write tenant- or platform-scoped settings (including the auth security-policy, localization, and company manifests) and enumerate every namespace.

Fixes:

- **Verified identity.** `SettingsServicePlugin` now derives the caller's identity and capabilities from the platform's verified resolution (`resolveAuthzContext` — session cookie / API key / OAuth), never from request headers. The route default is now SECURE: it trusts no identity header and yields an anonymous, denied context.
- **Capability gates.** Manifest `readPermission` / `writePermission` are enforced for HTTP callers: reads of a protected namespace, writes, and actions require the declared capability (writes default to at least the read capability, never ungated). Enforced via a new `enforced` flag set only at the HTTP boundary — **in-process/boot callers (`kernel.getService('settings')`, seed) are unchanged** and keep full trusted access.
- Unauthenticated HTTP callers can no longer enumerate protected manifests or write; a `403 SETTINGS_FORBIDDEN` is returned when the capability is missing.

**`setup.write` capability now real.** Enforcing the manifests' declared `writePermission` surfaced a modeling gap: `setup.write` (the write counterpart to `setup.access`, used by the branding / company / localization / feature-flag manifests) was referenced but never declared or granted — so under enforcement *nobody*, not even an admin, could write those namespaces. It is now a declared platform capability (`PLATFORM_CAPABILITIES`) held by `admin_full_access` and `organization_admin`, alongside `setup.access`.

**Behaviour change:** a deployment that relied on the old header-trusted default must present a real verified session/API-key/OAuth credential (which the console already does). A custom integration may still inject its own `contextFromRequest`.

Found by an adversarial security review of the request→ExecutionContext trust boundary.
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export const defaultPermissionSets: PermissionSet[] = [
'manage_metadata',
'manage_platform_settings',
'setup.access',
'setup.write',
'studio.access',
],
}),
Expand Down Expand Up @@ -154,7 +155,7 @@ export const defaultPermissionSets: PermissionSet[] = [
sys_user_permission_set: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false },
sys_user_position: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false },
},
systemPermissions: ['manage_org_users', 'setup.access'],
systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'],
rowLevelSecurity: [
{
name: 'tenant_isolation',
Expand Down
91 changes: 85 additions & 6 deletions packages/services/service-settings/src/settings-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,17 @@ function makeReqRes(opts: { params?: Record<string, string>; body?: any; headers
return { req, res, state };
}

// [Finding-1] An authorized admin context (verified, holds the branding
// manifest's setup.access/setup.write capabilities). The production plugin
// derives this from the verified session/API-key; here we inject it directly.
const adminProvider = () => ({ enforced: true, permissions: ['setup.access', 'setup.write'] });

describe('settings-routes', () => {
it('GET /api/settings → manifests', async () => {
const http = new MockHttp();
const svc = new SettingsService();
svc.registerManifest(brandingSettingsManifest);
registerSettingsRoutes(http, svc);
registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider });

const h = http.routes.get('GET /api/settings')!;
const { req, res, state } = makeReqRes();
Expand All @@ -58,7 +63,7 @@ describe('settings-routes', () => {
const http = new MockHttp();
const svc = new SettingsService({ env: {} });
svc.registerManifest(brandingSettingsManifest);
registerSettingsRoutes(http, svc);
registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider });

const h = http.routes.get('GET /api/settings/:namespace')!;
const { req, res, state } = makeReqRes({ params: { namespace: 'branding' } });
Expand All @@ -71,7 +76,7 @@ describe('settings-routes', () => {
const http = new MockHttp();
const svc = new SettingsService({ env: { OS_BRANDING_WORKSPACE_NAME: 'X' } });
svc.registerManifest(brandingSettingsManifest);
registerSettingsRoutes(http, svc);
registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider });

const h = http.routes.get('PUT /api/settings/:namespace')!;
const { req, res, state } = makeReqRes({ params: { namespace: 'branding' }, body: { workspace_name: 'Y' } });
Expand All @@ -94,7 +99,7 @@ describe('settings-routes', () => {
const http = new MockHttp();
const svc = new SettingsService({ env: {} });
svc.registerManifest(brandingSettingsManifest);
registerSettingsRoutes(http, svc);
registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider });

const h = http.routes.get('PUT /api/settings/:namespace')!;
const { req, res, state } = makeReqRes({ params: { namespace: 'branding' }, body: { values: { workspace_name: 'My Co' } } });
Expand All @@ -108,7 +113,7 @@ describe('settings-routes', () => {
const http = new MockHttp();
const svc = new SettingsService({ env: {} });
svc.registerManifest(brandingSettingsManifest);
registerSettingsRoutes(http, svc);
registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider });

const h = http.routes.get('PUT /api/settings/:namespace')!;
// Exactly what GET returns: { values: { key: { value, source, ... } } }
Expand All @@ -126,12 +131,86 @@ describe('settings-routes', () => {
const svc = new SettingsService({ env: {} });
svc.registerManifest(brandingSettingsManifest);
svc.registerAction('branding', 'ping', () => ({ ok: true, message: 'pong' }));
registerSettingsRoutes(http, svc);
registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider });

const h = http.routes.get('POST /api/settings/:namespace/:actionId')!;
const { req, res, state } = makeReqRes({ params: { namespace: 'branding', actionId: 'ping' }, body: null });
await h(req, res);
expect(state.status).toBe(200);
expect(state.body.ok).toBe(true);
});

// ── [Finding-1] the DEFAULT (no verified provider) is SECURE ──────────────
// Header-trusted identity is gone; an unauthenticated request can neither
// enumerate protected namespaces nor write them.
it('anonymous GET /api/settings hides manifests that require a read capability', async () => {
const http = new MockHttp();
const svc = new SettingsService();
svc.registerManifest(brandingSettingsManifest); // requires setup.access
registerSettingsRoutes(http, svc); // secure default → anonymous + enforced

const h = http.routes.get('GET /api/settings')!;
const { req, res, state } = makeReqRes();
await h(req, res);
expect(state.body.manifests.length).toBe(0);
});

it('anonymous GET /api/settings/:ns is DENIED (403), not served', async () => {
const http = new MockHttp();
const svc = new SettingsService({ env: {} });
svc.registerManifest(brandingSettingsManifest);
registerSettingsRoutes(http, svc);

const h = http.routes.get('GET /api/settings/:namespace')!;
const { req, res, state } = makeReqRes({ params: { namespace: 'branding' } });
await h(req, res);
expect(state.status).toBe(403);
expect(state.body.error.code).toBe('SETTINGS_FORBIDDEN');
});

it('anonymous PUT /api/settings/:ns is DENIED (403) — the unauthenticated write hole is closed', async () => {
const http = new MockHttp();
const svc = new SettingsService({ env: {} });
svc.registerManifest(brandingSettingsManifest);
registerSettingsRoutes(http, svc);

const h = http.routes.get('PUT /api/settings/:namespace')!;
const { req, res, state } = makeReqRes({ params: { namespace: 'branding' }, body: { workspace_name: 'pwn' } });
await h(req, res);
expect(state.status).toBe(403);
expect(state.body.error.code).toBe('SETTINGS_FORBIDDEN');
});

it('a spoofed x-user-id / x-permissions header grants NOTHING (default ignores identity headers)', async () => {
const http = new MockHttp();
const svc = new SettingsService({ env: {} });
svc.registerManifest(brandingSettingsManifest);
registerSettingsRoutes(http, svc);

const h = http.routes.get('PUT /api/settings/:namespace')!;
const { req, res, state } = makeReqRes({
params: { namespace: 'branding' },
body: { workspace_name: 'pwn' },
headers: { 'x-user-id': 'attacker', 'x-permissions': 'setup.write,setup.access' },
});
await h(req, res);
expect(state.status).toBe(403);
});

it('a caller holding only read (setup.access) may read but NOT write', async () => {
const http = new MockHttp();
const svc = new SettingsService({ env: {} });
svc.registerManifest(brandingSettingsManifest);
registerSettingsRoutes(http, svc, { contextFromRequest: () => ({ enforced: true, permissions: ['setup.access'] }) });

const read = http.routes.get('GET /api/settings/:namespace')!;
const r1 = makeReqRes({ params: { namespace: 'branding' } });
await read(r1.req, r1.res);
expect(r1.state.body.manifest.namespace).toBe('branding');

const write = http.routes.get('PUT /api/settings/:namespace')!;
const r2 = makeReqRes({ params: { namespace: 'branding' }, body: { workspace_name: 'X' } });
await write(r2.req, r2.res);
expect(r2.state.status).toBe(403); // has setup.access, lacks setup.write
});
});
59 changes: 33 additions & 26 deletions packages/services/service-settings/src/settings-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
import { SettingsService } from './settings-service.js';
import {
SettingsForbiddenError,
SettingsLockedError,
SettingsValidationError,
UnknownKeyError,
Expand All @@ -27,27 +28,23 @@ export interface SettingsRoutesOptions {
/** Base path. Default `/api/settings`. */
basePath?: string;
/**
* Extract caller identity from the request. The default reads
* `x-user-id` / `x-tenant-id` headers and parses
* `x-permissions` as a comma-separated list — fine for dev and
* straightforward to override in production wiring.
* Derive the VERIFIED caller identity from the request. Production wiring
* (`SettingsServicePlugin`) passes a resolver backed by the platform's
* verified session / API-key / OAuth resolution (`resolveAuthzContext`), so
* `permissions` reflect real capabilities and are never spoofable.
*
* [Finding-1] The default is SECURE: it trusts NO identity header and yields
* an anonymous, `enforced` context (deny protected reads + all writes). The
* old default trusted `x-user-id` / `x-permissions` headers, which let an
* unauthenticated client forge any identity and write platform settings.
*/
contextFromRequest?: (req: IHttpRequest) => SettingsContext;
contextFromRequest?: (req: IHttpRequest) => SettingsContext | Promise<SettingsContext>;
}

const defaultContext = (req: IHttpRequest): SettingsContext => {
const header = (name: string): string | undefined => {
const v = req.headers?.[name];
return Array.isArray(v) ? v[0] : v;
};
const perms = header('x-permissions');
return {
userId: header('x-user-id'),
tenantId: header('x-tenant-id'),
permissions: perms ? perms.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
requestId: header('x-request-id'),
};
};
// [Finding-1] Secure default: anonymous + enforced. No identity is read from
// request headers — a deployment that wants authenticated settings access must
// wire a verified `contextFromRequest` (the plugin does).
const defaultContext = (_req: IHttpRequest): SettingsContext => ({ enforced: true });

function sendError(res: IHttpResponse, status: number, code: string, message: string, extra?: Record<string, unknown>) {
res.status(status).json({ error: { code, message, ...extra } });
Expand All @@ -63,22 +60,28 @@ export function registerSettingsRoutes(

http.get(base, (async (req, res) => {
try {
const ctx = ctxOf(req);
const ctx = await ctxOf(req);
const manifests = service.listManifests(ctx);
await res.json({ manifests });
} catch (err: any) {
sendError(res, 500, 'INTERNAL', err?.message ?? 'Failed to list manifests');
if (err instanceof SettingsForbiddenError) {
sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { namespace: err.namespace });
} else {
sendError(res, 500, 'INTERNAL', err?.message ?? 'Failed to list manifests');
}
}
}) satisfies RouteHandler);

http.get(`${base}/:namespace`, (async (req, res) => {
const ns = req.params.namespace;
try {
const ctx = ctxOf(req);
const ctx = await ctxOf(req);
const payload = await service.getNamespace(ns, ctx);
await res.json(payload);
} catch (err: any) {
if (err instanceof UnknownNamespaceError) {
if (err instanceof SettingsForbiddenError) {
sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { namespace: err.namespace });
} else if (err instanceof UnknownNamespaceError) {
sendError(res, 404, 'UNKNOWN_NAMESPACE', err.message);
} else {
sendError(res, 500, 'INTERNAL', err?.message ?? 'Failed to read namespace');
Expand Down Expand Up @@ -110,11 +113,13 @@ export function registerSettingsRoutes(
);
}
try {
const ctx = ctxOf(req);
const ctx = await ctxOf(req);
const result = await service.setMany(ns, body, ctx);
await res.json({ values: result });
} catch (err: any) {
if (err instanceof SettingsLockedError) {
if (err instanceof SettingsForbiddenError) {
sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { namespace: err.namespace });
} else if (err instanceof SettingsLockedError) {
sendError(res, 409, 'SETTINGS_LOCKED', err.message, {
namespace: err.namespace,
key: err.key,
Expand All @@ -138,12 +143,14 @@ export function registerSettingsRoutes(
http.post(`${base}/:namespace/:actionId`, (async (req, res) => {
const { namespace, actionId } = req.params;
try {
const ctx = ctxOf(req);
const ctx = await ctxOf(req);
const result = await service.runAction(namespace, actionId, req.body, ctx);
const status = result.ok ? 200 : 400;
await res.status(status).json(result);
} catch (err: any) {
if (err instanceof UnknownNamespaceError) {
if (err instanceof SettingsForbiddenError) {
sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { namespace: err.namespace });
} else if (err instanceof UnknownNamespaceError) {
sendError(res, 404, 'UNKNOWN_NAMESPACE', err.message);
} else {
sendError(res, 500, 'INTERNAL', err?.message ?? 'Action failed');
Expand Down
48 changes: 46 additions & 2 deletions packages/services/service-settings/src/settings-service-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Plugin, PluginContext } from '@objectstack/core';
import type { IHttpServer, IDataEngine } from '@objectstack/spec/contracts';
import { resolveAuthzContext } from '@objectstack/core';
import type { IHttpServer, IDataEngine, IHttpRequest } from '@objectstack/spec/contracts';
import type { SettingsContext } from './settings-service.types.js';
import type { SettingsManifest } from '@objectstack/spec/system';
import { SettingsService } from './settings-service.js';
import type { ICryptoProvider } from '@objectstack/spec/contracts';
Expand Down Expand Up @@ -200,7 +202,49 @@ export class SettingsServicePlugin implements Plugin {
);
return;
}
registerSettingsRoutes(http, this.service!, { basePath: this.opts.basePath });
// [Finding-1] Derive the caller's identity + capabilities from the
// platform's VERIFIED resolution (session cookie / API key / OAuth), NOT
// from spoofable `x-user-id` / `x-permissions` headers. `resolveAuthzContext`
// returns real, server-verified capabilities; `enforced: true` makes the
// service apply the manifest read/write permission gates. An unresolvable
// request fails CLOSED (anonymous + enforced → denied).
const verifiedContextFromRequest = async (req: IHttpRequest): Promise<SettingsContext> => {
try {
const ql: any = ctx.getService('objectql');
const headers = new Headers();
for (const [k, v] of Object.entries(req.headers ?? {})) {
if (v == null) continue;
headers.set(String(k), Array.isArray(v) ? v.join(',') : String(v));
}
const getSession = async (h: any) => {
try {
const authService: any = ctx.getService('auth');
let api: any = authService?.api;
if (!api && typeof authService?.getApi === 'function') api = await authService.getApi();
return await api?.getSession?.({ headers: h });
} catch {
return undefined;
}
};
const authz = await resolveAuthzContext({ ql, headers, getSession });
return {
userId: authz.userId,
tenantId: authz.tenantId,
// Manifest read/write permissions are system CAPABILITIES
// (setup.access / setup.write / manage_platform_settings) — union
// both aggregates so either kind of declared gate resolves.
permissions: [...(authz.systemPermissions ?? []), ...(authz.permissions ?? [])],
enforced: true,
};
} catch {
return { enforced: true }; // fail closed
}
};

registerSettingsRoutes(http, this.service!, {
basePath: this.opts.basePath,
contextFromRequest: verifiedContextFromRequest,
});
ctx.logger?.info?.(
'SettingsServicePlugin: REST routes registered at ' + (this.opts.basePath ?? '/api/settings'),
);
Expand Down
Loading