diff --git a/.changeset/perf-timing-per-request-gating.md b/.changeset/perf-timing-per-request-gating.md new file mode 100644 index 0000000000..946787a947 --- /dev/null +++ b/.changeset/perf-timing-per-request-gating.md @@ -0,0 +1,29 @@ +--- +"@objectstack/observability": minor +"@objectstack/plugin-hono-server": minor +"@objectstack/runtime": minor +--- + +feat(observability): admin-gated per-request `Server-Timing` via `X-OS-Debug-Timing` (#2408) + +Perf-tuning mode was previously global-only (`serverTiming` option / +`OS_SERVER_TIMING`), which discloses internal phase durations — a mild +backend-fingerprinting surface — to every caller. This adds the per-request +gating path from the design so an operator can pull a single request's +`Server-Timing` breakdown on a live environment without turning the header on +for everyone. + +- **observability**: a request-scoped disclosure gate (`runWithPerfDisclosure`, + `allowPerfDisclosure`, `isPerfDisclosureAllowed`, `PerfDisclosureGate`) kept + separate from the pure `PerfTiming` collector and pinned to its own + `Symbol.for` store so the middleware and dispatcher share it across module + copies. +- **plugin-hono-server**: the Server-Timing middleware is registered by default + (unless `serverTiming: false`). It runs the collector when timing is global + **or** the request sends `X-OS-Debug-Timing: 1`, and emits the header only + when the gate is open. `OS_PERF_TIMING=1` now also enables global mode. +- **runtime**: after resolving the execution context, the dispatcher opens the + gate for admin/service/system principals, so ordinary callers never receive + the header even if they send the debug header. + +Existing global-mode behavior is unchanged. diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index 0756e56d1a..9a8f2b106e 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -238,27 +238,45 @@ usual culprit behind an inexplicably slow list), 18ms in **3** business hooks, and 7ms serializing the response. `db` and `hooks` are aggregates — one member carrying the summed duration and the event count, not one member per query. -This is **off by default**: the header discloses internal phase durations, -which is helpful for profiling but also lets a caller fingerprint the backend. -Treat it as a perf-tuning toggle you flip in staging (or briefly in production -behind an allowlist), not a default-on header. +The header discloses internal phase durations, which is helpful for profiling +but also lets a caller fingerprint the backend, so disclosure is **gated**. +There are two ways to turn it on: -Enable it on the Hono server plugin: +**Global** — every response carries the header. Flip this in staging, or +briefly on a production environment under active investigation: ```ts new HonoServerPlugin({ serverTiming: true }); ``` …or, for the default `os serve` server (which constructs the plugin for you), -via the environment: +via the environment (`OS_PERF_TIMING=1` and the older `OS_SERVER_TIMING=true` +are equivalent): ```bash -OS_SERVER_TIMING=true os serve +OS_PERF_TIMING=1 os serve ``` -When enabled, every response carries `total` (the whole request, measured by -an outer middleware) plus the sub-phases the request path records out of the -box: +**Per-request** — available by default (unless hard-disabled below), with **no +redeploy**: the caller sends the request header + +``` +X-OS-Debug-Timing: 1 +``` + +and the `Server-Timing` header comes back **only** when the request resolves an +**admin/service identity** (a platform/tenant admin, a service token, or an +internal system call). An ordinary user who sends the header gets nothing back — +they can never pull timings, so this is safe to leave available on a live +environment. This is the path to reach for when diagnosing "why is *this* request +slow?" against a running environment. + +To hard-disable both paths (no middleware registered at all), set +`serverTiming: false` explicitly. + +When timing is emitted, every response carries `total` (the whole request, +measured by an outer middleware) plus the sub-phases the request path records +out of the box: | Member | Recorded by | Meaning | |:---|:---|:---| @@ -316,5 +334,7 @@ countServerTiming('db', queryMs, 'queries'); // → db;dur=;desc=" queri - [ ] Log records include `requestId` field; cross-checked one against the response `X-Request-Id` header. - [ ] Alerts wired: error rate, p95 latency per route. -- [ ] (Optional) `Server-Timing` verified in DevTools when `serverTiming` / - `OS_SERVER_TIMING=true` is enabled, and confirmed **absent** by default. +- [ ] (Optional) `Server-Timing` verified in DevTools with global mode + (`serverTiming: true` / `OS_PERF_TIMING=1`) on, confirmed **absent** for a + normal request, and confirmed the per-request `X-OS-Debug-Timing: 1` header + returns timing **only** to an admin/service caller. diff --git a/packages/observability/src/index.ts b/packages/observability/src/index.ts index 8fbc4c589c..14afff89fb 100644 --- a/packages/observability/src/index.ts +++ b/packages/observability/src/index.ts @@ -52,5 +52,9 @@ export { startServerTiming, measureServerTiming, countServerTiming, + runWithPerfDisclosure, + allowPerfDisclosure, + isPerfDisclosureAllowed, type ServerTimingMark, + type PerfDisclosureGate, } from './perf-timing.js'; diff --git a/packages/observability/src/perf-timing.test.ts b/packages/observability/src/perf-timing.test.ts index f5c1ba66d3..e49b8fd03c 100644 --- a/packages/observability/src/perf-timing.test.ts +++ b/packages/observability/src/perf-timing.test.ts @@ -10,6 +10,10 @@ import { startServerTiming, measureServerTiming, countServerTiming, + runWithPerfDisclosure, + allowPerfDisclosure, + isPerfDisclosureAllowed, + type PerfDisclosureGate, } from './perf-timing.js'; describe('formatServerTiming', () => { @@ -208,3 +212,61 @@ describe('ambient collector', () => { expect(t.marks().map((m) => m.name)).toContain('after-await'); }); }); + +describe('disclosure gate', () => { + it('isPerfDisclosureAllowed() is false with no active gate', () => { + expect(isPerfDisclosureAllowed()).toBe(false); + }); + + it('allowPerfDisclosure() is a no-op with no active gate (does not throw)', () => { + allowPerfDisclosure(); + expect(isPerfDisclosureAllowed()).toBe(false); + }); + + it('reflects the seeded state inside runWithPerfDisclosure', () => { + const closed: PerfDisclosureGate = { allowed: false }; + runWithPerfDisclosure(closed, () => { + expect(isPerfDisclosureAllowed()).toBe(false); + }); + const open: PerfDisclosureGate = { allowed: true }; + runWithPerfDisclosure(open, () => { + expect(isPerfDisclosureAllowed()).toBe(true); + }); + }); + + it('allowPerfDisclosure() opens a closed gate the caller still holds', async () => { + const gate: PerfDisclosureGate = { allowed: false }; + await runWithPerfDisclosure(gate, async () => { + await new Promise((r) => setTimeout(r, 1)); + allowPerfDisclosure(); // after an await — same ALS scope + }); + // The caller reads its own reference once the scope settles. + expect(gate.allowed).toBe(true); + }); + + it('leaves the gate closed when disclosure is never granted', async () => { + const gate: PerfDisclosureGate = { allowed: false }; + await runWithPerfDisclosure(gate, async () => { + await new Promise((r) => setTimeout(r, 1)); + }); + expect(gate.allowed).toBe(false); + }); + + it('pins the gate store to a global-registry symbol (shared across module copies)', () => { + const key = Symbol.for('@objectstack/observability:perf-disclosure-gate'); + expect((globalThis as Record)[key]).toBeDefined(); + }); + + it('is independent of the timing collector scope', () => { + // A collector can be active without a gate, and vice versa. + const t = new PerfTiming(); + runWithPerfTiming(t, () => { + expect(isPerfDisclosureAllowed()).toBe(false); + }); + const gate: PerfDisclosureGate = { allowed: true }; + runWithPerfDisclosure(gate, () => { + expect(currentPerfTiming()).toBeUndefined(); + expect(isPerfDisclosureAllowed()).toBe(true); + }); + }); +}); diff --git a/packages/observability/src/perf-timing.ts b/packages/observability/src/perf-timing.ts index 58bc5de44b..6637f15eac 100644 --- a/packages/observability/src/perf-timing.ts +++ b/packages/observability/src/perf-timing.ts @@ -283,3 +283,64 @@ export async function measureServerTiming( export function countServerTiming(name: string, dur: number, unit?: string): void { store.getStore()?.count(name, dur, unit); } + +// --- Disclosure gate (WHO may see the timing) ------------------------- + +/** + * Per-request disclosure gate — the policy counterpart to the collector. + * + * The {@link PerfTiming} collector only MEASURES; whether the measured + * `Server-Timing` header is returned to the client is a separate decision. When + * perf-tuning is turned on GLOBALLY (env flag / plugin option) the operator has + * opted the whole environment in, so the gate opens up front and every response + * carries the header. When it is turned on PER-REQUEST — the caller sends an + * `X-OS-Debug-Timing` header — the header must stay withheld until the request + * proves an admin/service identity: phase durations are a mild + * backend-fingerprinting surface, so an ordinary user must never be able to pull + * them just by sending a header. The request path flips the gate open with + * {@link allowPerfDisclosure} once it has resolved a privileged principal. + * + * Keeping this out of {@link PerfTiming} preserves the collector's invariant + * ("it only measures, it never decides whether to emit"). + */ +export interface PerfDisclosureGate { + /** Whether the collected timing may be disclosed to the client. */ + allowed: boolean; +} + +/** + * The disclosure gate lives in its OWN global-registry-pinned + * `AsyncLocalStorage`, for the same cross-module-copy reason as the collector + * store above: the middleware seeds the gate and the dispatcher (a different + * package, possibly a different module copy) flips it open — both must see the + * one store. + */ +const GATE_KEY = Symbol.for('@objectstack/observability:perf-disclosure-gate'); +const globalGate = globalThis as unknown as Record | undefined>; +const gateStore: AsyncLocalStorage = + globalGate[GATE_KEY] ?? (globalGate[GATE_KEY] = new AsyncLocalStorage()); + +/** + * Run `fn` with `gate` as the ambient disclosure gate for the async call chain. + * The caller keeps its reference to `gate` and reads `gate.allowed` after `fn` + * settles to decide whether to emit the header. + */ +export function runWithPerfDisclosure(gate: PerfDisclosureGate, fn: () => T): T { + return gateStore.run(gate, fn); +} + +/** + * Open the ambient disclosure gate — the request has proven it may see its own + * `Server-Timing` header (admin/service identity). A no-op when no gate is + * active (perf-tuning off, or already-global mode with no gate to flip), so the + * call site stays branch-free. + */ +export function allowPerfDisclosure(): void { + const g = gateStore.getStore(); + if (g) g.allowed = true; +} + +/** Whether the ambient disclosure gate is open. `false` when none is active. */ +export function isPerfDisclosureAllowed(): boolean { + return gateStore.getStore()?.allowed ?? false; +} diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts index f9f52efbc8..3086646038 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts @@ -210,36 +210,40 @@ describe('HonoServerPlugin', () => { }); it('should disable CORS when cors option is false', async () => { + corsConfigCapture.last = undefined; + const plugin = new HonoServerPlugin({ cors: false }); await plugin.init(context as PluginContext); - const serverInstance = (HonoHttpServer as any).mock.instances[0]; - const rawApp = serverInstance.getRawApp(); - - // CORS middleware should NOT be registered - expect(rawApp.use).not.toHaveBeenCalled(); + // CORS middleware must NOT be configured. (Assert on the CORS config, + // not the raw `use` count: the perf-timing middleware registers its + // own `use('*')` by default to catch the `X-OS-Debug-Timing` header.) + expect(corsConfigCapture.last).toBeUndefined(); }); it('should disable CORS when CORS_ENABLED env is false', async () => { const originalEnv = process.env.OS_CORS_ENABLED; process.env.OS_CORS_ENABLED = 'false'; + corsConfigCapture.last = undefined; - const plugin = new HonoServerPlugin(); - await plugin.init(context as PluginContext); - - const serverInstance = (HonoHttpServer as any).mock.instances[0]; - const rawApp = serverInstance.getRawApp(); - - expect(rawApp.use).not.toHaveBeenCalled(); - - // Restore environment - if (originalEnv !== undefined) { - process.env.OS_CORS_ENABLED = originalEnv; - } else { - delete process.env.OS_CORS_ENABLED; + try { + const plugin = new HonoServerPlugin(); + await plugin.init(context as PluginContext); + + // CORS not configured — see the note above re: the perf-timing + // middleware's own `use('*')`. + expect(corsConfigCapture.last).toBeUndefined(); + } finally { + // Restore environment even if the assertion fails, so a leaked + // `OS_CORS_ENABLED=false` can't disable CORS in later tests. + if (originalEnv !== undefined) { + process.env.OS_CORS_ENABLED = originalEnv; + } else { + delete process.env.OS_CORS_ENABLED; + } } }); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 15826df4f4..07074f015c 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -14,7 +14,12 @@ import * as fs from 'fs'; import * as path from 'path'; import { createOriginMatcher, hasWildcardPattern, isLocalhostOrigin } from './pattern-matcher'; import { readEnvWithDeprecation } from '@objectstack/types'; -import { PerfTiming, runWithPerfTiming } from '@objectstack/observability'; +import { + PerfTiming, + runWithPerfTiming, + runWithPerfDisclosure, + type PerfDisclosureGate, +} from '@objectstack/observability'; export interface StaticMount { root: string; @@ -63,13 +68,23 @@ export interface HonoPluginOptions { cors?: HonoCorsOptions | false; /** - * Enable per-request performance timing via the `Server-Timing` response - * header ("perf-tuning mode"). OFF by default — the header discloses - * internal phase durations (total / body-parse / handler), which is handy - * for profiling but is also a backend-fingerprinting surface, so it is - * opt-in. Can also be enabled with the `OS_SERVER_TIMING=true` environment - * variable. - * @default false + * Per-request performance timing via the `Server-Timing` response header + * ("perf-tuning mode"). The header discloses internal phase durations + * (total / auth / db / hooks / serialize), which is handy for profiling but + * is also a mild backend-fingerprinting surface, so disclosure is gated: + * + * - **GLOBAL** — `serverTiming: true`, or `OS_SERVER_TIMING=true` / + * `OS_PERF_TIMING=1`: every response carries the header (an environment + * under active investigation). + * - **PER-REQUEST** — always available unless hard-disabled: a caller sends + * `X-OS-Debug-Timing: 1` and the header is returned ONLY after the request + * resolves an admin/service identity (the dispatcher opens the disclosure + * gate). Ordinary users can never pull timings just by sending the header. + * - `serverTiming: false` hard-disables BOTH paths (no middleware). + * + * `undefined` (the default) leaves global mode off but keeps the + * admin-gated per-request path available. + * @default undefined */ serverTiming?: boolean; } @@ -123,6 +138,16 @@ export function foldWildcardSuperUser(objects: Record): void { } } +/** + * Whether a request opted into per-request perf timing via `X-OS-Debug-Timing`. + * Accepts the common truthy spellings; anything else (including absent) is off. + */ +export function isDebugTimingRequested(value: string | undefined | null): boolean { + if (!value) return false; + const v = value.trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'yes' || v === 'on'; +} + /** Minimal schema shape the managed-write clamp needs. */ export interface ManagedSchemaLike { managedBy?: string; @@ -217,20 +242,45 @@ export class HonoServerPlugin implements Plugin { ctx.logger.debug('HTTP server service registered', { serviceName: 'http.server' }); // ─── Server-Timing (perf-tuning mode) ───────────────────────────────── - // Opt-in per-request performance timing exposed via the `Server-Timing` + // Per-request performance timing exposed via the `Server-Timing` // response header. Registered FIRST (before CORS) so the `total` mark // brackets the whole request and the ambient timing collector is // established — via AsyncLocalStorage — for every downstream layer - // (CORS, route handler, body parse) to record sub-phases into. - const serverTimingEnabled = - this.options.serverTiming ?? (process.env.OS_SERVER_TIMING === 'true'); - if (serverTimingEnabled) { + // (CORS, route handler, body parse, SQL driver, hooks) to record + // sub-phases into. + // + // Two ways to turn it on (see the `serverTiming` option JSDoc): + // • GLOBAL — `serverTiming: true` / `OS_SERVER_TIMING=true` / + // `OS_PERF_TIMING=1`: the header is returned to EVERY + // caller. The disclosure gate opens up front. + // • PER-REQUEST — the caller sends `X-OS-Debug-Timing: 1`; the header + // is returned ONLY after the dispatcher resolves an + // admin/service identity and opens the gate, so an + // ordinary user can never fingerprint the backend by + // sending the header alone. + // `serverTiming: false` hard-disables both paths (no middleware). + if (this.options.serverTiming !== false) { + const globalTiming = + this.options.serverTiming === true || + process.env.OS_SERVER_TIMING === 'true' || + process.env.OS_PERF_TIMING === '1' || + process.env.OS_PERF_TIMING === 'true'; const rawApp = this.server.getRawApp(); rawApp.use('*', async (c, next) => { + const perRequest = isDebugTimingRequested(c.req.header('X-OS-Debug-Timing')); + // Nothing asked for timing on this request — a single header + // read, then straight through. Zero collector overhead. + if (!globalTiming && !perRequest) return next(); + const timing = new PerfTiming(); + // Global mode opens the gate for everyone; the per-request path + // starts closed and is opened only if an admin/service identity + // is proven during dispatch (`allowPerfDisclosure`). + const gate: PerfDisclosureGate = { allowed: globalTiming }; const endTotal = timing.start('total', 'Total server time'); - await runWithPerfTiming(timing, () => next()); + await runWithPerfTiming(timing, () => runWithPerfDisclosure(gate, () => next())); endTotal(); + if (!gate.allowed) return; // per-request, unverified caller — withhold const header = timing.toHeader(); // `append` (not `set`) so we coexist with any upstream proxy // that already added a Server-Timing entry. diff --git a/packages/plugins/plugin-hono-server/src/server-timing.test.ts b/packages/plugins/plugin-hono-server/src/server-timing.test.ts index 2369087022..89abfa3971 100644 --- a/packages/plugins/plugin-hono-server/src/server-timing.test.ts +++ b/packages/plugins/plugin-hono-server/src/server-timing.test.ts @@ -1,8 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { HonoServerPlugin } from './hono-plugin'; -import { countServerTiming } from '@objectstack/observability'; +import { HonoServerPlugin, isDebugTimingRequested } from './hono-plugin'; +import { countServerTiming, allowPerfDisclosure } from '@objectstack/observability'; import type { PluginContext } from '@objectstack/core'; /** @@ -25,16 +25,28 @@ async function setup(opts: { serverTiming?: boolean } = {}) { await (plugin as any).init(fakeCtx()); const server = (plugin as any).server; server.get('/ping', (_req: any, res: any) => res.json({ ok: true })); + // Simulates the request path proving an admin/service identity mid-dispatch: + // the dispatcher calls `allowPerfDisclosure()` after resolving the principal. + server.get('/admin', (_req: any, res: any) => { + allowPerfDisclosure(); + return res.json({ ok: true }); + }); const app = server.getRawApp(); return { plugin, server, app }; } describe('Server-Timing (perf-tuning) middleware', () => { - const prev = process.env.OS_SERVER_TIMING; - beforeEach(() => { delete process.env.OS_SERVER_TIMING; }); + const prevServer = process.env.OS_SERVER_TIMING; + const prevPerf = process.env.OS_PERF_TIMING; + beforeEach(() => { + delete process.env.OS_SERVER_TIMING; + delete process.env.OS_PERF_TIMING; + }); afterEach(() => { - if (prev === undefined) delete process.env.OS_SERVER_TIMING; - else process.env.OS_SERVER_TIMING = prev; + if (prevServer === undefined) delete process.env.OS_SERVER_TIMING; + else process.env.OS_SERVER_TIMING = prevServer; + if (prevPerf === undefined) delete process.env.OS_PERF_TIMING; + else process.env.OS_PERF_TIMING = prevPerf; }); it('is OFF by default — no Server-Timing header', async () => { @@ -86,4 +98,65 @@ describe('Server-Timing (perf-tuning) middleware', () => { const res = await app.request('/ping'); expect(res.headers.get('Server-Timing')).toBeNull(); }); + + it('is enabled globally via OS_PERF_TIMING=1 (issue #2408 env alias)', async () => { + process.env.OS_PERF_TIMING = '1'; + const { app } = await setup(); + const res = await app.request('/ping'); + expect(res.headers.get('Server-Timing')).toMatch(/total;dur=/); + }); + + describe('per-request gating via X-OS-Debug-Timing', () => { + it('withholds the header for the debug header ALONE (unverified caller)', async () => { + // Global mode off; the caller asks for timing but never proves an + // admin/service identity → no disclosure. + const { app } = await setup(); + const res = await app.request('/ping', { headers: { 'X-OS-Debug-Timing': '1' } }); + expect(res.status).toBe(200); + expect(res.headers.get('Server-Timing')).toBeNull(); + }); + + it('emits the header once an admin/service identity is proven', async () => { + const { app } = await setup(); + const res = await app.request('/admin', { headers: { 'X-OS-Debug-Timing': '1' } }); + expect(res.status).toBe(200); + const header = res.headers.get('Server-Timing'); + expect(header).toBeTruthy(); + expect(header).toMatch(/(^|, )total;dur=[\d.]+/); + expect(header).toContain('serialize;dur='); + }); + + it('does NOT emit for an admin when no debug header is sent (opt-in only)', async () => { + // Global mode off + no debug header → the collector never opens, even + // though the handler would grant disclosure. + const { app } = await setup(); + const res = await app.request('/admin'); + expect(res.headers.get('Server-Timing')).toBeNull(); + }); + + it('stays hard-disabled under serverTiming: false even for an admin', async () => { + const { app } = await setup({ serverTiming: false }); + const res = await app.request('/admin', { headers: { 'X-OS-Debug-Timing': '1' } }); + expect(res.headers.get('Server-Timing')).toBeNull(); + }); + + it('global mode discloses to everyone regardless of the debug header', async () => { + const { app } = await setup({ serverTiming: true }); + const res = await app.request('/ping'); // no debug header, non-admin + expect(res.headers.get('Server-Timing')).toMatch(/total;dur=/); + }); + }); +}); + +describe('isDebugTimingRequested', () => { + it('accepts common truthy spellings', () => { + for (const v of ['1', 'true', 'TRUE', 'yes', 'on', ' On ']) { + expect(isDebugTimingRequested(v)).toBe(true); + } + }); + it('rejects falsy / absent / other values', () => { + for (const v of [undefined, null, '', '0', 'false', 'no', 'off', 'maybe']) { + expect(isDebugTimingRequested(v)).toBe(false); + } + }); }); diff --git a/packages/runtime/src/http-dispatcher.perf-gating.test.ts b/packages/runtime/src/http-dispatcher.perf-gating.test.ts new file mode 100644 index 0000000000..a5bc82f49a --- /dev/null +++ b/packages/runtime/src/http-dispatcher.perf-gating.test.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { isPerfDisclosurePrincipal } from './http-dispatcher.js'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; + +/** + * Unit coverage for the per-request `Server-Timing` disclosure predicate + * (#2408). Only admin/service/system principals may pull the timing header when + * it was opened per-request via `X-OS-Debug-Timing`; every ordinary caller must + * read as not-allowed so sending the header leaks nothing. + */ +const ctx = (over: Partial): ExecutionContext => + ({ isSystem: false, positions: [], permissions: [], ...over }) as ExecutionContext; + +describe('isPerfDisclosurePrincipal', () => { + it('denies an undefined / anonymous context', () => { + expect(isPerfDisclosurePrincipal(undefined)).toBe(false); + expect(isPerfDisclosurePrincipal(ctx({}))).toBe(false); + }); + + it('denies an ordinary human / guest / agent principal', () => { + expect(isPerfDisclosurePrincipal(ctx({ principalKind: 'human' }))).toBe(false); + expect(isPerfDisclosurePrincipal(ctx({ principalKind: 'guest' }))).toBe(false); + expect(isPerfDisclosurePrincipal(ctx({ principalKind: 'agent' }))).toBe(false); + // A member-rung human with permissions but no admin posture stays denied. + expect( + isPerfDisclosurePrincipal(ctx({ principalKind: 'human', posture: 'MEMBER' })), + ).toBe(false); + }); + + it('allows system / service principals', () => { + expect(isPerfDisclosurePrincipal(ctx({ isSystem: true }))).toBe(true); + expect(isPerfDisclosurePrincipal(ctx({ principalKind: 'service' }))).toBe(true); + expect(isPerfDisclosurePrincipal(ctx({ principalKind: 'system' }))).toBe(true); + }); + + it('allows the derived admin posture rungs', () => { + expect(isPerfDisclosurePrincipal(ctx({ posture: 'PLATFORM_ADMIN' }))).toBe(true); + expect(isPerfDisclosurePrincipal(ctx({ posture: 'TENANT_ADMIN' }))).toBe(true); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 98417454f1..fc7360e186 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -5,7 +5,7 @@ import { shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, } from '@objectstack/core'; import { isMcpServerEnabled } from '@objectstack/types'; -import { measureServerTiming } from '@objectstack/observability'; +import { measureServerTiming, allowPerfDisclosure } from '@objectstack/observability'; import { CoreServiceName } from '@objectstack/spec/system'; import { readServiceSelfInfo } from '@objectstack/spec/api'; import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; @@ -41,6 +41,27 @@ function isSystemObjectName(name: string): boolean { return /^sys_/i.test(name); } +/** + * Whether a resolved principal may see a PER-REQUEST `Server-Timing` header + * (#2408 perf-tuning gating). The header exposes internal phase durations — a + * mild backend-fingerprinting surface — so when timing is opened per-request via + * `X-OS-Debug-Timing` it is disclosed only to an admin/service identity: + * + * - `isSystem` — internal/engine self-calls, + * - `principalKind` `service` / `system` — service tokens & the system seed, + * - `posture` `PLATFORM_ADMIN` / `TENANT_ADMIN` — the derived admin rungs. + * + * Ordinary human/guest/agent callers get `false`, so sending the debug header + * yields no header for them. Global (env/option) perf mode bypasses this — it + * opened the disclosure gate up front for the whole environment. + */ +export function isPerfDisclosurePrincipal(ec: ExecutionContext | undefined): boolean { + if (!ec) return false; + if (ec.isSystem === true) return true; + if (ec.principalKind === 'service' || ec.principalKind === 'system') return true; + return ec.posture === 'PLATFORM_ADMIN' || ec.posture === 'TENANT_ADMIN'; +} + export interface HttpProtocolContext { request: any; response?: any; @@ -230,10 +251,17 @@ export class HttpDispatcher { * overhead (session lookup, org-scope resolution). A no-op wrapper when * perf-tuning is off, so it costs nothing on the normal path. */ - private timedResolveExecutionContext( + private async timedResolveExecutionContext( opts: Parameters[0], ): Promise { - return measureServerTiming('auth', () => resolveExecutionContext(opts), 'Identity/session'); + const ec = await measureServerTiming('auth', () => resolveExecutionContext(opts), 'Identity/session'); + // Perf-tuning disclosure gate (#2408): when timing was opened + // per-request via `X-OS-Debug-Timing`, the `Server-Timing` header stays + // withheld until the request proves an admin/service identity — never + // leak phase timings to an ordinary caller. A no-op when perf-tuning is + // off or already global (no gate, or gate already open). + if (isPerfDisclosurePrincipal(ec)) allowPerfDisclosure(); + return ec; } private success(data: any, meta?: any) {