From cdcc185a0a66eee26a76ce895424f320261601d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 15:00:40 +0000 Subject: [PATCH 1/2] fix(security): enforce anonymous-deny posture uniformly across HTTP surfaces (#2567) The ADR-0056 D2 requireAuth flip made REST /data/* deny-anonymous by default, but sibling HTTP surfaces reached ObjectQL without passing through the gate, so the anonymous posture was inconsistent by surface: a caller denied on /data could read the same object data through another door. This closes the two remaining gaps (the /meta gate had already landed) and pins every surface. Dispatcher GraphQL: - handleGraphQL now applies the same requireAuth gate as /data and /meta, resolving identity for the direct /graphql route that does not flow through dispatch() (kernel.graphql's security middleware falls open for anonymous). - The dispatcher's requireAuth default is aligned with the REST plugin's (?? true) so a bare host no longer denies anonymous /data while serving the same rows over /graphql; an explicit requireAuth:false opt-out warns at boot. Raw-hono standard /data routes: - Each route now consults requireAuth (secure by default, mirroring rest-server.ts). Previously these delegated straight to ObjectQL and were only shadowed when the REST plugin registered the same paths first, so the posture depended on plugin registration order. Order no longer affects security. Proofs & conformance: - showcase-anonymous-deny-surfaces.dogfood.test.ts proves anonymous /meta, /graphql and /data all 401 on the platform default; authenticated members pass. - Handler-level regression coverage in http-dispatcher.requireauth.test.ts and hono-anonymous-deny.test.ts. - Three new authz-conformance rows (meta / graphql / raw-hono) with enforcement sites and proof, marked high-risk, so a new ungated surface or a dropped proof fails CI. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ShknNUYoSTspJLBiqorD8V --- .../authz-2567-anonymous-posture-surfaces.md | 37 +++++++ .../dogfood/test/authz-conformance.matrix.ts | 15 +++ .../dogfood/test/authz-conformance.test.ts | 7 +- ...se-anonymous-deny-surfaces.dogfood.test.ts | 72 ++++++++++++++ .../src/hono-anonymous-deny.test.ts | 96 +++++++++++++++++++ .../plugin-hono-server/src/hono-plugin.ts | 46 +++++++++ packages/runtime/src/dispatcher-plugin.ts | 41 +++++--- .../src/http-dispatcher.requireauth.test.ts | 34 +++++++ packages/runtime/src/http-dispatcher.ts | 66 ++++++++++++- 9 files changed, 399 insertions(+), 15 deletions(-) create mode 100644 .changeset/authz-2567-anonymous-posture-surfaces.md create mode 100644 packages/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts create mode 100644 packages/plugins/plugin-hono-server/src/hono-anonymous-deny.test.ts diff --git a/.changeset/authz-2567-anonymous-posture-surfaces.md b/.changeset/authz-2567-anonymous-posture-surfaces.md new file mode 100644 index 0000000000..46650de61e --- /dev/null +++ b/.changeset/authz-2567-anonymous-posture-surfaces.md @@ -0,0 +1,37 @@ +--- +'@objectstack/runtime': minor +'@objectstack/plugin-hono-server': minor +--- + +fix(security): enforce the anonymous-deny posture uniformly across HTTP surfaces (#2567) + +The ADR-0056 D2 `requireAuth` flip made REST `/data/*` deny-anonymous by +default, but three sibling surfaces reached ObjectQL without passing through the +gate — so the platform's anonymous posture was **inconsistent by surface**: an +anonymous caller denied on `/data` could read the same object data through a +different door. This closes the remaining two gaps (the `/meta` gate had already +landed) and pins every surface with a conformance row. + +- **Dispatcher GraphQL** (`runtime/http-dispatcher.ts`, `dispatcher-plugin.ts`): + `POST /graphql` reached `kernel.graphql`, whose security middleware falls + **open** for an anonymous context. `handleGraphQL` now applies the same + `requireAuth` gate as `/data` and `/meta`, resolving identity for the direct + route that does not flow through `dispatch()`. The dispatcher's `requireAuth` + default is aligned with the REST plugin's (`?? true`) so a bare host no longer + denies anonymous `/data` while serving the same rows over `/graphql`; an + explicit `requireAuth: false` opt-out is honoured and logs a boot warning. + +- **Raw-hono standard `/data` routes** (`plugin-hono-server/hono-plugin.ts`): + these delegate straight to ObjectQL and were only *shadowed* when the REST + plugin registered the same paths first — so secure-by-default depended on + plugin registration order. Each route now consults `requireAuth` (secure by + default, mirroring `rest-server.ts`), making the deny decision a property of + this entry point too. Order no longer affects the anonymous posture. + +**Behaviour change:** on a `requireAuth` deployment (the secure default), +anonymous `POST /graphql` and anonymous raw-hono `/data` now return 401. +Deployments that intentionally serve these surfaces publicly set +`requireAuth: false` (a boot warning is logged). Proven end-to-end on the +platform default in `showcase-anonymous-deny-surfaces.dogfood.test.ts`, with +handler-level regression coverage in `http-dispatcher.requireauth.test.ts` and +`hono-anonymous-deny.test.ts`, and pinned by three new authz-conformance rows. diff --git a/packages/dogfood/test/authz-conformance.matrix.ts b/packages/dogfood/test/authz-conformance.matrix.ts index 476937a9e2..a191b03eb2 100644 --- a/packages/dogfood/test/authz-conformance.matrix.ts +++ b/packages/dogfood/test/authz-conformance.matrix.ts @@ -50,6 +50,21 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ note: 'An organization_admin holds the superuser bit via its `*` wildcard, so it used to also get the Layer 0 exemption and read/write EVERY tenant\'s rows on private tenant objects. The exemption now requires a platform-exclusive capability (manage_metadata/manage_platform_settings/studio.access/manage_users), which org_admin deliberately lacks — a SECURITY NARROWING: org admin is walled to its own org, a true platform admin still crosses, the better-auth carve-out is untouched. Unit-proven in plugin-security/authz-matrix-gate.test.ts ([Finding 2 / #2937] Layer 0 cross-tenant exemption requires the platform posture).' }, { id: 'anonymous-deny', summary: 'secure-by-default anonymous posture (capability)', state: 'enforced', enforcement: 'rest/rest-server.ts enforceAuth (requireAuth)', proof: 'showcase-anonymous-deny.dogfood.test.ts' }, + // ── #2567 — the anonymous-deny posture is UNIFORM across HTTP surfaces, not + // just REST `/data`. Each sibling surface that reaches ObjectQL now consults + // the same `requireAuth` gate; these rows pin every entry point so a new + // ungated surface (or a silent regression) fails CI, not review. + { id: 'anonymous-deny-meta', summary: 'anonymous-deny on the metadata endpoints (#2567 surface 1)', state: 'enforced', + enforcement: 'rest/rest-server.ts registerMetadataEndpoints guarded registrar (enforceAuth) — every /meta route inherits the gate; runtime/http-dispatcher.ts handleMetadata mirrors it for the dispatcher metadata catch-all', + proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts' }, + { id: 'anonymous-deny-graphql', summary: 'anonymous-deny on the dispatcher GraphQL endpoint (#2567 surface 2)', state: 'enforced', + enforcement: 'runtime/http-dispatcher.ts handleGraphQL (requireAuth gate, resolves identity for the direct /graphql route) + runtime/dispatcher-plugin.ts requireAuth default(true), mirroring rest-server.ts', + proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', + note: 'GraphQL reaches the same object data as /data through kernel.graphql, whose security middleware falls OPEN for an anonymous context. Unit-proven in runtime/http-dispatcher.requireauth.test.ts (GraphQL block); e2e on the platform default in the surfaces proof.' }, + { id: 'anonymous-deny-hono-data', summary: 'anonymous-deny on the raw-hono standard /data routes (#2567 surface 3)', state: 'enforced', + enforcement: 'plugin-hono-server/hono-plugin.ts denyAnonymous gate on the standard /data routes (requireAuth ?? true, mirroring rest-server.ts)', + proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', + note: 'These routes delegate straight to ObjectQL and were only shadowed when the REST plugin registered the same paths FIRST — so the posture depended on plugin registration order (a load-order change silently reopened it, no test failing). Gating each route makes the deny decision a property of this entry point too. Handler-level proof in plugin-hono-server/hono-anonymous-deny.test.ts.' }, { id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced', enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' }, diff --git a/packages/dogfood/test/authz-conformance.test.ts b/packages/dogfood/test/authz-conformance.test.ts index 2ad4bbcd3d..4b4a2c6e9a 100644 --- a/packages/dogfood/test/authz-conformance.test.ts +++ b/packages/dogfood/test/authz-conformance.test.ts @@ -18,7 +18,12 @@ describe('ADR-0056 D10 — authorization conformance matrix', () => { it('is a sound conformance ledger (ADR-0060 checkLedger)', () => { const problems = checkLedger(AUTHZ_CONFORMANCE, { proofRoot: HERE, // proofs are dogfood test files alongside this one - highRisk: ['owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile'], + highRisk: [ + 'owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile', + // #2567 — every anonymous-deny HTTP surface is high-risk: it guards the + // same object data as REST `/data` through a sibling entry point. + 'anonymous-deny-meta', 'anonymous-deny-graphql', 'anonymous-deny-hono-data', + ], }); expect(problems, problems.join('\n')).toEqual([]); }); diff --git a/packages/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts new file mode 100644 index 0000000000..35375cde64 --- /dev/null +++ b/packages/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #2567 — anonymous posture must be UNIFORM across HTTP surfaces, not just the +// REST `/data` routes proven by showcase-anonymous-deny.dogfood.test.ts. Before +// this fix, on a `requireAuth` deployment `/data/*` denied anonymous callers +// while three sibling surfaces reached ObjectQL without the gate: +// - the metadata endpoints (`/meta`) +// - the dispatcher GraphQL endpoint (`/graphql`) +// - the raw-hono standard `/data` routes (order-dependent shadowing) +// +// This proof boots the real showcase HTTP stack ON THE PLATFORM DEFAULT (the +// verify harness passes no `requireAuth` override, so the flipped secure default +// is what a fresh production deployment gets) and asserts every surface denies +// an anonymous caller with 401 while an authenticated member is unaffected. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const OBJ = '/data/showcase_private_note'; + +describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => { + let stack: VerifyStack; + let memberToken: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); // platform default (deny anonymous) + await stack.signIn(); + memberToken = await stack.signUp('surfaces-member@verify.test'); + }, 60_000); + + afterAll(async () => { await stack?.stop(); }); + + // ── /meta ────────────────────────────────────────────────────────────── + it('anonymous GET /meta is denied (401)', async () => { + const r = await stack.api('/meta', { method: 'GET' }); + expect(r.status, 'anonymous metadata read must be 401').toBe(401); + }); + + it('an authenticated member is NOT denied on /meta (deny targets anonymity)', async () => { + const r = await stack.apiAs(memberToken, 'GET', '/meta'); + expect(r.status, 'authenticated metadata read must clear the auth gate').not.toBe(401); + }); + + // ── /graphql ───────────────────────────────────────────────────────────── + it('anonymous POST /graphql is denied (401)', async () => { + const r = await stack.api('/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: '{ __typename }' }), + }); + expect(r.status, 'anonymous GraphQL query must be 401').toBe(401); + }); + + it('an authenticated member clears the /graphql gate (not 401)', async () => { + // Past the gate the query may 200 or 501 (depending on whether a GraphQL + // service is wired) — the point is it is NOT the anonymous 401. + const r = await stack.apiAs(memberToken, 'POST', '/graphql', { query: '{ __typename }' }); + expect(r.status, 'authenticated GraphQL must clear the auth gate').not.toBe(401); + }); + + // ── /data (surface-level; raw-hono handler proven in plugin-hono-server) ── + it('anonymous READ of the data surface is denied (401)', async () => { + const r = await stack.api(OBJ, { method: 'GET' }); + expect(r.status, 'anonymous data read must be 401').toBe(401); + }); + + it('an authenticated member is allowed on the data surface', async () => { + const r = await stack.apiAs(memberToken, 'GET', OBJ); + expect(r.status).toBe(200); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/hono-anonymous-deny.test.ts b/packages/plugins/plugin-hono-server/src/hono-anonymous-deny.test.ts new file mode 100644 index 0000000000..21392ca8e5 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/hono-anonymous-deny.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #2567 — the raw-hono standard `/data` endpoints must honour the same +// secure-by-default (`requireAuth`) anonymous-deny posture as the REST `/data` +// routes. Before the gate, these routes delegated straight to ObjectQL and were +// only *shadowed* when the REST plugin registered the same paths first — so the +// anonymous posture depended on plugin registration order. These tests drive the +// real routes on a real Hono app (no REST plugin in the picture) to prove the +// gate stands on its own. + +import { describe, it, expect } from 'vitest'; +import { HonoServerPlugin } from './hono-plugin'; + +/** + * Boot a plugin and register ONLY the standard CRUD routes on its real Hono + * app, then hand back the app so tests can drive HTTP requests directly. No + * listening socket, no CORS/static — we exercise the data routes in isolation. + */ +function bootStandardEndpoints(opts: { + restConfig?: { api?: { requireAuth?: boolean } }; + services: Record; +}) { + const plugin = new HonoServerPlugin({ port: 0, restConfig: opts.restConfig as any }); + const ctx: any = { + logger: { info() {}, debug() {}, warn() {}, error() {} }, + getKernel: () => ({ getService: (n: string) => opts.services[n] }), + registerService: () => {}, + hook: () => {}, + getService: (n: string) => { + const s = opts.services[n]; + if (s === undefined) throw new Error(`no service: ${n}`); + return s; + }, + }; + (plugin as any).registerDiscoveryAndCrudEndpoints(ctx); + return (plugin as any).server.getRawApp(); +} + +// ObjectQL stub — every read returns empty, every write echoes an id. Enough +// for the routes to reach a 200 once the gate has been cleared. +const objectql = { + find: async () => [], + insert: async () => ({ id: 'new-id' }), +}; + +// Auth stub — resolves a session ONLY when the request carries `x-test-user`, +// so the same boot serves both anonymous and authenticated requests. +const auth = { + api: { + getSession: async ({ headers }: { headers: Headers }) => { + const uid = headers?.get?.('x-test-user'); + return uid ? { user: { id: uid }, session: {} } : null; + }, + }, +}; + +const REQ = 'http://localhost/api/v1/data/thing'; + +describe('raw-hono /data — anonymous-deny gate (#2567)', () => { + it('secure-by-default (no restConfig): anonymous LIST is 401', async () => { + const app = bootStandardEndpoints({ services: { objectql, auth } }); + const res = await app.request(REQ, { method: 'GET' }); + expect(res.status).toBe(401); + }); + + it('secure-by-default: anonymous GET-by-id is 401', async () => { + const app = bootStandardEndpoints({ services: { objectql, auth } }); + const res = await app.request(`${REQ}/abc`, { method: 'GET' }); + expect(res.status).toBe(401); + }); + + it('secure-by-default: anonymous CREATE is 401', async () => { + const app = bootStandardEndpoints({ services: { objectql, auth } }); + const res = await app.request(REQ, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'x' }), + }); + expect(res.status).toBe(401); + }); + + it('an AUTHENTICATED caller is served (deny targets anonymity, not the route)', async () => { + const app = bootStandardEndpoints({ services: { objectql, auth } }); + const res = await app.request(REQ, { method: 'GET', headers: { 'x-test-user': 'u1' } }); + expect(res.status).toBe(200); + }); + + it('explicit opt-out (requireAuth:false) keeps the surface anonymously reachable', async () => { + const app = bootStandardEndpoints({ + restConfig: { api: { requireAuth: false } }, + services: { objectql, auth }, + }); + const res = await app.request(REQ, { method: 'GET' }); + expect(res.status).toBe(200); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index d5aec23cbe..e1e1736091 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -529,6 +529,46 @@ export class HonoServerPlugin implements Plugin { ctx.logger.info('Registered discovery endpoints', { prefix }); + // ── Anonymous-deny gate (ADR-0056 D2, #2567) ────────────────────────── + // These raw `/data/:object` routes delegate straight to ObjectQL. They + // are only *shadowed* by the REST plugin's gated `/data` routes when + // that plugin registers the same paths FIRST — so before this gate the + // platform's anonymous posture depended on plugin registration order: a + // load-order change silently reopened anonymous data access with no test + // failing. Gating here makes the deny decision a property of THIS entry + // point too, so security no longer depends on who registered first. + // + // Secure-by-default: `requireAuth` mirrors `rest-server.ts`'s `?? true` + // (ADR-0056 D2). A deployment that intentionally serves data publicly + // sets `restConfig.api.requireAuth = false` (a boot warning is logged, as + // in the REST plugin). No-op in that case — the previously-public surface + // is unchanged. An authenticated / system caller always passes. + // + // `requireAuth` is not in the typed `api` shape (rest-server.ts reads it + // via the same `as any` cast), so widen locally. + const requireAuth = + (this.options.restConfig?.api as { requireAuth?: boolean } | undefined)?.requireAuth ?? true; + if (!requireAuth) { + ctx.logger.warn( + 'Hono standard /data endpoints: requireAuth is OFF — anonymous callers can read/write object data. ' + + 'This is a deliberate opt-out; set restConfig.requireAuth=true to deny anonymous access (ADR-0056 D2, #2567).', + ); + } + // Returns a 401 Response when the caller is anonymous under the deny + // posture, else null (caller proceeds). `isSystem` is never set on + // inbound HTTP (internal-only), so it cannot be forged to bypass this. + const denyAnonymous = (c: any, execCtx: any): Response | null => { + if (!requireAuth) return null; + if (execCtx?.userId || execCtx?.isSystem) return null; + return c.json( + { + error: 'unauthenticated', + message: 'Authentication is required to access this endpoint.', + }, + 401, + ); + }; + // Basic CRUD data endpoints — delegate to ObjectQL service directly const getObjectQL = () => ctx.getService('objectql'); @@ -675,6 +715,8 @@ export class HonoServerPlugin implements Plugin { const object = c.req.param('object'); const data = await c.req.json().catch(() => ({})); const execCtx = await resolveCtx(c); + const denied = denyAnonymous(c, execCtx); + if (denied) return denied; try { const res = await ql.insert(object, data, { context: execCtx } as any); const record = { ...data, ...res }; @@ -694,6 +736,8 @@ export class HonoServerPlugin implements Plugin { const object = c.req.param('object'); const id = c.req.param('id'); const execCtx = await resolveCtx(c); + const denied = denyAnonymous(c, execCtx); + if (denied) return denied; try { let all = await ql.find(object, { context: execCtx } as any); if (!all) all = []; @@ -713,6 +757,8 @@ export class HonoServerPlugin implements Plugin { if (!ql) return c.json({ error: 'Data service not available' }, 503); const object = c.req.param('object'); const execCtx = await resolveCtx(c); + const denied = denyAnonymous(c, execCtx); + if (denied) return denied; try { let all = await ql.find(object, { context: execCtx } as any); if (!Array.isArray(all) && all && (all as any).value) all = (all as any).value; diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 0757b18ba5..64bfcffedf 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -49,16 +49,19 @@ 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. + * Reject anonymous requests to `auth: true` service routes (AI, etc.), the + * `/graphql` endpoint and the `/meta` catch-all with HTTP 401, mirroring the + * REST API's `requireAuth` gate. Must match the REST plugin's + * `api.requireAuth` so `/ai`, `/graphql` 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 or read + * object data over GraphQL that `/data/*` 401s (#2567). * - * 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. + * Defaults to `true` — secure-by-default, matching the REST plugin's + * `api.requireAuth` default (ADR-0056 D2). 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); a deployment that serves + * these surfaces publicly sets `requireAuth: false` explicitly. */ requireAuth?: boolean; @@ -417,9 +420,25 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // 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). + // top-level `requireAuth` wins. + // + // Defaults to `true` — matching `rest-server.ts`'s `?? true` + // (ADR-0056 D2). The dispatcher gates the same object data as REST + // through sibling surfaces (`/graphql`, `/ai`, the `/meta` + // catch-all, service routes); defaulting it OFF while REST defaults + // ON is exactly the by-surface inconsistency #2567 closes — a bare + // host would deny anonymous `/data` yet serve the same rows over + // `/graphql`. A deployment that intentionally serves these surfaces + // publicly opts out with an explicit `requireAuth: false` (a + // boot warning is logged, mirroring the REST plugin). const requireAuth = - config.requireAuth ?? (config.scoping as { requireAuth?: boolean } | undefined)?.requireAuth ?? false; + config.requireAuth ?? (config.scoping as { requireAuth?: boolean } | undefined)?.requireAuth ?? true; + if (!requireAuth) { + ctx.logger?.warn?.( + '[dispatcher] requireAuth is OFF — /graphql, /ai and the /meta catch-all serve anonymous callers. ' + + 'This is a deliberate opt-out; set api.requireAuth=true to deny anonymous access (ADR-0056 D2, #2567).', + ); + } const dispatcher = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: enforceMembership, requireAuth, diff --git a/packages/runtime/src/http-dispatcher.requireauth.test.ts b/packages/runtime/src/http-dispatcher.requireauth.test.ts index 94cdb0748e..b8e29ab291 100644 --- a/packages/runtime/src/http-dispatcher.requireauth.test.ts +++ b/packages/runtime/src/http-dispatcher.requireauth.test.ts @@ -66,6 +66,40 @@ describe('HttpDispatcher requireAuth gate — AI routes (handleAI)', () => { }); }); +describe('HttpDispatcher requireAuth gate — GraphQL (handleGraphQL)', () => { + // A valid-enough body so the missing-query 400 branch isn't hit first. + const gqlBody = { query: '{ __typename }' }; + + it('401s an anonymous caller when requireAuth is on (#2567)', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + await expect(d.handleGraphQL(gqlBody, anon)).rejects.toMatchObject({ statusCode: 401 }); + }); + + it('lets an authenticated caller PAST the gate', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + // The mock kernel has no graphql service, so a caller that clears the + // gate hits 501 — which PROVES the gate passed (an anonymous caller + // throws 401 before this point). + await expect(d.handleGraphQL(gqlBody, authed)).rejects.toMatchObject({ statusCode: 501 }); + }); + + it('lets an internal system context past the gate', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + await expect(d.handleGraphQL(gqlBody, system)).rejects.toMatchObject({ statusCode: 501 }); + }); + + it('serves anonymously when requireAuth is off (unchanged legacy behaviour)', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: false }); + // Gate is a no-op → reaches the (absent) graphql service → 501, NOT 401. + await expect(d.handleGraphQL(gqlBody, anon)).rejects.toMatchObject({ statusCode: 501 }); + }); + + it('400s a missing query before the auth gate is consulted', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + await expect(d.handleGraphQL({} as any, anon)).rejects.toMatchObject({ statusCode: 400 }); + }); +}); + 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 }); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 61ed2137b6..557afea37a 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1489,16 +1489,76 @@ export class HttpDispatcher { if (!body || !body.query) { throw { statusCode: 400, message: 'Missing query in request body' }; } - + + // Anonymous-deny gate — the same `requireAuth` posture the REST `/data` + // and `/meta` surfaces enforce. GraphQL reaches ObjectQL through + // `kernel.graphql`, whose security middleware falls OPEN for an + // anonymous context (no userId/roles → next()), so without this gate an + // anonymous query could read exactly the object data the sibling + // `/data/*` 401 denies (#2567). Mirrors {@link handleMetadata}: no-op + // when `requireAuth` is off (demo / single-tenant), an authenticated or + // system caller passes exactly as on `/data`. + // + // The dispatcher-plugin's direct `/graphql` route calls us WITHOUT + // resolving identity first (unlike `dispatch()`, which populates + // `context.executionContext`), so resolve it here when absent. + if (this.requireAuth) { + let ec: any = context.executionContext; + if (!ec) { + ec = await this.resolveRequestExecutionContext(context); + if (ec) context.executionContext = ec; + } + if (!ec?.userId && !ec?.isSystem) { + throw { + statusCode: 401, + message: 'Authentication is required to access this endpoint.', + code: 'unauthenticated', + }; + } + } + if (typeof this.kernel.graphql !== 'function') { throw { statusCode: 501, message: 'GraphQL service not available' }; } - return this.kernel.graphql(body.query, body.variables, { - request: context.request + return this.kernel.graphql(body.query, body.variables, { + request: context.request }); } + /** + * Resolve the RBAC/RLS execution context for a request that did NOT flow + * through {@link dispatch} (which resolves and caches it on + * `context.executionContext`). The dispatcher-plugin's direct `/graphql` + * route is the current caller. Mirrors the identity resolution `dispatch()` + * performs so an anonymous-deny gate can tell an authenticated caller from + * an anonymous one. Best-effort: returns `undefined` on failure (treated as + * anonymous, i.e. denied under `requireAuth`). + */ + private async resolveRequestExecutionContext( + context: HttpProtocolContext, + ): Promise { + try { + return await resolveExecutionContext({ + getService: (n: string) => this.resolveService(n, context.environmentId), + getQl: async () => { + const k: any = this.kernel; + if (k && typeof k.getServiceAsync === 'function') { + const ql = await k.getServiceAsync('objectql').catch(() => undefined); + if (ql && (ql.registry || typeof ql.find === 'function')) return ql; + } + return this.getObjectQLService(context.environmentId); + }, + request: context.request, + // OAuth access tokens are honoured only on the MCP surface + // (#2698); GraphQL enforces no per-scope tool gating. + acceptOAuthAccessToken: false, + }); + } catch { + return undefined; + } + } + /** * Handles Auth requests * path: sub-path after /auth/ From 441409988bdc854a65312b2389c35daa96c636e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 15:09:57 +0000 Subject: [PATCH 2/2] test(client): opt out of anonymous-deny in the hono client suite (#2567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client.hono.test.ts boots the raw-hono standard endpoints with no auth service and drives anonymous data CRUD to exercise the CLIENT — it is not testing the auth posture. Under the secure-by-default gate those requests now 401, so the suite sets the documented `requireAuth: false` opt-out (the same a deployment that intentionally serves data publicly would use). The gate itself stays proven in plugin-hono-server/hono-anonymous-deny.test.ts. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ShknNUYoSTspJLBiqorD8V --- packages/client/src/client.hono.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/client/src/client.hono.test.ts b/packages/client/src/client.hono.test.ts index a98b43b8be..ddaab3f0a5 100644 --- a/packages/client/src/client.hono.test.ts +++ b/packages/client/src/client.hono.test.ts @@ -15,9 +15,17 @@ describe('ObjectStackClient (with Hono Server)', () => { kernel.use(new ObjectQLPlugin()); // 2. Setup Hono Plugin - const honoPlugin = new HonoServerPlugin({ + // This suite exercises the CLIENT's data operations over the raw-hono + // standard endpoints; it wires no auth service. Opt out of the + // secure-by-default anonymous-deny posture (#2567) so the anonymous + // data CRUD under test stays reachable — the same explicit + // `requireAuth: false` a deployment that intentionally serves data + // publicly would set. The gate itself is proven in + // plugin-hono-server/hono-anonymous-deny.test.ts. + const honoPlugin = new HonoServerPlugin({ port: 0, - registerStandardEndpoints: true + registerStandardEndpoints: true, + restConfig: { api: { requireAuth: false } } as any, }); kernel.use(honoPlugin);