Skip to content

Commit 541b96a

Browse files
os-zhuangclaude
andauthored
fix(security): enforce anonymous-deny posture uniformly across HTTP surfaces (#2567) (#2973)
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ShknNUYoSTspJLBiqorD8V * test(client): opt out of anonymous-deny in the hono client suite (#2567) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ShknNUYoSTspJLBiqorD8V --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8fc1208 commit 541b96a

10 files changed

Lines changed: 409 additions & 17 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/runtime': minor
3+
'@objectstack/plugin-hono-server': minor
4+
---
5+
6+
fix(security): enforce the anonymous-deny posture uniformly across HTTP surfaces (#2567)
7+
8+
The ADR-0056 D2 `requireAuth` flip made REST `/data/*` deny-anonymous by
9+
default, but three sibling surfaces reached ObjectQL without passing through the
10+
gate — so the platform's anonymous posture was **inconsistent by surface**: an
11+
anonymous caller denied on `/data` could read the same object data through a
12+
different door. This closes the remaining two gaps (the `/meta` gate had already
13+
landed) and pins every surface with a conformance row.
14+
15+
- **Dispatcher GraphQL** (`runtime/http-dispatcher.ts`, `dispatcher-plugin.ts`):
16+
`POST /graphql` reached `kernel.graphql`, whose security middleware falls
17+
**open** for an anonymous context. `handleGraphQL` now applies the same
18+
`requireAuth` gate as `/data` and `/meta`, resolving identity for the direct
19+
route that does not flow through `dispatch()`. The dispatcher's `requireAuth`
20+
default is aligned with the REST plugin's (`?? true`) so a bare host no longer
21+
denies anonymous `/data` while serving the same rows over `/graphql`; an
22+
explicit `requireAuth: false` opt-out is honoured and logs a boot warning.
23+
24+
- **Raw-hono standard `/data` routes** (`plugin-hono-server/hono-plugin.ts`):
25+
these delegate straight to ObjectQL and were only *shadowed* when the REST
26+
plugin registered the same paths first — so secure-by-default depended on
27+
plugin registration order. Each route now consults `requireAuth` (secure by
28+
default, mirroring `rest-server.ts`), making the deny decision a property of
29+
this entry point too. Order no longer affects the anonymous posture.
30+
31+
**Behaviour change:** on a `requireAuth` deployment (the secure default),
32+
anonymous `POST /graphql` and anonymous raw-hono `/data` now return 401.
33+
Deployments that intentionally serve these surfaces publicly set
34+
`requireAuth: false` (a boot warning is logged). Proven end-to-end on the
35+
platform default in `showcase-anonymous-deny-surfaces.dogfood.test.ts`, with
36+
handler-level regression coverage in `http-dispatcher.requireauth.test.ts` and
37+
`hono-anonymous-deny.test.ts`, and pinned by three new authz-conformance rows.

packages/client/src/client.hono.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,17 @@ describe('ObjectStackClient (with Hono Server)', () => {
1515
kernel.use(new ObjectQLPlugin());
1616

1717
// 2. Setup Hono Plugin
18-
const honoPlugin = new HonoServerPlugin({
18+
// This suite exercises the CLIENT's data operations over the raw-hono
19+
// standard endpoints; it wires no auth service. Opt out of the
20+
// secure-by-default anonymous-deny posture (#2567) so the anonymous
21+
// data CRUD under test stays reachable — the same explicit
22+
// `requireAuth: false` a deployment that intentionally serves data
23+
// publicly would set. The gate itself is proven in
24+
// plugin-hono-server/hono-anonymous-deny.test.ts.
25+
const honoPlugin = new HonoServerPlugin({
1926
port: 0,
20-
registerStandardEndpoints: true
27+
registerStandardEndpoints: true,
28+
restConfig: { api: { requireAuth: false } } as any,
2129
});
2230
kernel.use(honoPlugin);
2331

packages/dogfood/test/authz-conformance.matrix.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
5050
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).' },
5151
{ id: 'anonymous-deny', summary: 'secure-by-default anonymous posture (capability)', state: 'enforced',
5252
enforcement: 'rest/rest-server.ts enforceAuth (requireAuth)', proof: 'showcase-anonymous-deny.dogfood.test.ts' },
53+
// ── #2567 — the anonymous-deny posture is UNIFORM across HTTP surfaces, not
54+
// just REST `/data`. Each sibling surface that reaches ObjectQL now consults
55+
// the same `requireAuth` gate; these rows pin every entry point so a new
56+
// ungated surface (or a silent regression) fails CI, not review.
57+
{ id: 'anonymous-deny-meta', summary: 'anonymous-deny on the metadata endpoints (#2567 surface 1)', state: 'enforced',
58+
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',
59+
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts' },
60+
{ id: 'anonymous-deny-graphql', summary: 'anonymous-deny on the dispatcher GraphQL endpoint (#2567 surface 2)', state: 'enforced',
61+
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',
62+
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
63+
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.' },
64+
{ id: 'anonymous-deny-hono-data', summary: 'anonymous-deny on the raw-hono standard /data routes (#2567 surface 3)', state: 'enforced',
65+
enforcement: 'plugin-hono-server/hono-plugin.ts denyAnonymous gate on the standard /data routes (requireAuth ?? true, mirroring rest-server.ts)',
66+
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
67+
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.' },
5368
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',
5469
enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' },
5570

packages/dogfood/test/authz-conformance.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ describe('ADR-0056 D10 — authorization conformance matrix', () => {
1818
it('is a sound conformance ledger (ADR-0060 checkLedger)', () => {
1919
const problems = checkLedger(AUTHZ_CONFORMANCE, {
2020
proofRoot: HERE, // proofs are dogfood test files alongside this one
21-
highRisk: ['owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile'],
21+
highRisk: [
22+
'owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile',
23+
// #2567 — every anonymous-deny HTTP surface is high-risk: it guards the
24+
// same object data as REST `/data` through a sibling entry point.
25+
'anonymous-deny-meta', 'anonymous-deny-graphql', 'anonymous-deny-hono-data',
26+
],
2227
});
2328
expect(problems, problems.join('\n')).toEqual([]);
2429
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #2567 — anonymous posture must be UNIFORM across HTTP surfaces, not just the
4+
// REST `/data` routes proven by showcase-anonymous-deny.dogfood.test.ts. Before
5+
// this fix, on a `requireAuth` deployment `/data/*` denied anonymous callers
6+
// while three sibling surfaces reached ObjectQL without the gate:
7+
// - the metadata endpoints (`/meta`)
8+
// - the dispatcher GraphQL endpoint (`/graphql`)
9+
// - the raw-hono standard `/data` routes (order-dependent shadowing)
10+
//
11+
// This proof boots the real showcase HTTP stack ON THE PLATFORM DEFAULT (the
12+
// verify harness passes no `requireAuth` override, so the flipped secure default
13+
// is what a fresh production deployment gets) and asserts every surface denies
14+
// an anonymous caller with 401 while an authenticated member is unaffected.
15+
16+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
17+
import showcaseStack from '@objectstack/example-showcase';
18+
import { bootStack, type VerifyStack } from '@objectstack/verify';
19+
20+
const OBJ = '/data/showcase_private_note';
21+
22+
describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => {
23+
let stack: VerifyStack;
24+
let memberToken: string;
25+
26+
beforeAll(async () => {
27+
stack = await bootStack(showcaseStack); // platform default (deny anonymous)
28+
await stack.signIn();
29+
memberToken = await stack.signUp('surfaces-member@verify.test');
30+
}, 60_000);
31+
32+
afterAll(async () => { await stack?.stop(); });
33+
34+
// ── /meta ──────────────────────────────────────────────────────────────
35+
it('anonymous GET /meta is denied (401)', async () => {
36+
const r = await stack.api('/meta', { method: 'GET' });
37+
expect(r.status, 'anonymous metadata read must be 401').toBe(401);
38+
});
39+
40+
it('an authenticated member is NOT denied on /meta (deny targets anonymity)', async () => {
41+
const r = await stack.apiAs(memberToken, 'GET', '/meta');
42+
expect(r.status, 'authenticated metadata read must clear the auth gate').not.toBe(401);
43+
});
44+
45+
// ── /graphql ─────────────────────────────────────────────────────────────
46+
it('anonymous POST /graphql is denied (401)', async () => {
47+
const r = await stack.api('/graphql', {
48+
method: 'POST',
49+
headers: { 'Content-Type': 'application/json' },
50+
body: JSON.stringify({ query: '{ __typename }' }),
51+
});
52+
expect(r.status, 'anonymous GraphQL query must be 401').toBe(401);
53+
});
54+
55+
it('an authenticated member clears the /graphql gate (not 401)', async () => {
56+
// Past the gate the query may 200 or 501 (depending on whether a GraphQL
57+
// service is wired) — the point is it is NOT the anonymous 401.
58+
const r = await stack.apiAs(memberToken, 'POST', '/graphql', { query: '{ __typename }' });
59+
expect(r.status, 'authenticated GraphQL must clear the auth gate').not.toBe(401);
60+
});
61+
62+
// ── /data (surface-level; raw-hono handler proven in plugin-hono-server) ──
63+
it('anonymous READ of the data surface is denied (401)', async () => {
64+
const r = await stack.api(OBJ, { method: 'GET' });
65+
expect(r.status, 'anonymous data read must be 401').toBe(401);
66+
});
67+
68+
it('an authenticated member is allowed on the data surface', async () => {
69+
const r = await stack.apiAs(memberToken, 'GET', OBJ);
70+
expect(r.status).toBe(200);
71+
});
72+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #2567 — the raw-hono standard `/data` endpoints must honour the same
4+
// secure-by-default (`requireAuth`) anonymous-deny posture as the REST `/data`
5+
// routes. Before the gate, these routes delegated straight to ObjectQL and were
6+
// only *shadowed* when the REST plugin registered the same paths first — so the
7+
// anonymous posture depended on plugin registration order. These tests drive the
8+
// real routes on a real Hono app (no REST plugin in the picture) to prove the
9+
// gate stands on its own.
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { HonoServerPlugin } from './hono-plugin';
13+
14+
/**
15+
* Boot a plugin and register ONLY the standard CRUD routes on its real Hono
16+
* app, then hand back the app so tests can drive HTTP requests directly. No
17+
* listening socket, no CORS/static — we exercise the data routes in isolation.
18+
*/
19+
function bootStandardEndpoints(opts: {
20+
restConfig?: { api?: { requireAuth?: boolean } };
21+
services: Record<string, unknown>;
22+
}) {
23+
const plugin = new HonoServerPlugin({ port: 0, restConfig: opts.restConfig as any });
24+
const ctx: any = {
25+
logger: { info() {}, debug() {}, warn() {}, error() {} },
26+
getKernel: () => ({ getService: (n: string) => opts.services[n] }),
27+
registerService: () => {},
28+
hook: () => {},
29+
getService: (n: string) => {
30+
const s = opts.services[n];
31+
if (s === undefined) throw new Error(`no service: ${n}`);
32+
return s;
33+
},
34+
};
35+
(plugin as any).registerDiscoveryAndCrudEndpoints(ctx);
36+
return (plugin as any).server.getRawApp();
37+
}
38+
39+
// ObjectQL stub — every read returns empty, every write echoes an id. Enough
40+
// for the routes to reach a 200 once the gate has been cleared.
41+
const objectql = {
42+
find: async () => [],
43+
insert: async () => ({ id: 'new-id' }),
44+
};
45+
46+
// Auth stub — resolves a session ONLY when the request carries `x-test-user`,
47+
// so the same boot serves both anonymous and authenticated requests.
48+
const auth = {
49+
api: {
50+
getSession: async ({ headers }: { headers: Headers }) => {
51+
const uid = headers?.get?.('x-test-user');
52+
return uid ? { user: { id: uid }, session: {} } : null;
53+
},
54+
},
55+
};
56+
57+
const REQ = 'http://localhost/api/v1/data/thing';
58+
59+
describe('raw-hono /data — anonymous-deny gate (#2567)', () => {
60+
it('secure-by-default (no restConfig): anonymous LIST is 401', async () => {
61+
const app = bootStandardEndpoints({ services: { objectql, auth } });
62+
const res = await app.request(REQ, { method: 'GET' });
63+
expect(res.status).toBe(401);
64+
});
65+
66+
it('secure-by-default: anonymous GET-by-id is 401', async () => {
67+
const app = bootStandardEndpoints({ services: { objectql, auth } });
68+
const res = await app.request(`${REQ}/abc`, { method: 'GET' });
69+
expect(res.status).toBe(401);
70+
});
71+
72+
it('secure-by-default: anonymous CREATE is 401', async () => {
73+
const app = bootStandardEndpoints({ services: { objectql, auth } });
74+
const res = await app.request(REQ, {
75+
method: 'POST',
76+
headers: { 'Content-Type': 'application/json' },
77+
body: JSON.stringify({ title: 'x' }),
78+
});
79+
expect(res.status).toBe(401);
80+
});
81+
82+
it('an AUTHENTICATED caller is served (deny targets anonymity, not the route)', async () => {
83+
const app = bootStandardEndpoints({ services: { objectql, auth } });
84+
const res = await app.request(REQ, { method: 'GET', headers: { 'x-test-user': 'u1' } });
85+
expect(res.status).toBe(200);
86+
});
87+
88+
it('explicit opt-out (requireAuth:false) keeps the surface anonymously reachable', async () => {
89+
const app = bootStandardEndpoints({
90+
restConfig: { api: { requireAuth: false } },
91+
services: { objectql, auth },
92+
});
93+
const res = await app.request(REQ, { method: 'GET' });
94+
expect(res.status).toBe(200);
95+
});
96+
});

packages/plugins/plugin-hono-server/src/hono-plugin.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,46 @@ export class HonoServerPlugin implements Plugin {
529529

530530
ctx.logger.info('Registered discovery endpoints', { prefix });
531531

532+
// ── Anonymous-deny gate (ADR-0056 D2, #2567) ──────────────────────────
533+
// These raw `/data/:object` routes delegate straight to ObjectQL. They
534+
// are only *shadowed* by the REST plugin's gated `/data` routes when
535+
// that plugin registers the same paths FIRST — so before this gate the
536+
// platform's anonymous posture depended on plugin registration order: a
537+
// load-order change silently reopened anonymous data access with no test
538+
// failing. Gating here makes the deny decision a property of THIS entry
539+
// point too, so security no longer depends on who registered first.
540+
//
541+
// Secure-by-default: `requireAuth` mirrors `rest-server.ts`'s `?? true`
542+
// (ADR-0056 D2). A deployment that intentionally serves data publicly
543+
// sets `restConfig.api.requireAuth = false` (a boot warning is logged, as
544+
// in the REST plugin). No-op in that case — the previously-public surface
545+
// is unchanged. An authenticated / system caller always passes.
546+
//
547+
// `requireAuth` is not in the typed `api` shape (rest-server.ts reads it
548+
// via the same `as any` cast), so widen locally.
549+
const requireAuth =
550+
(this.options.restConfig?.api as { requireAuth?: boolean } | undefined)?.requireAuth ?? true;
551+
if (!requireAuth) {
552+
ctx.logger.warn(
553+
'Hono standard /data endpoints: requireAuth is OFF — anonymous callers can read/write object data. ' +
554+
'This is a deliberate opt-out; set restConfig.requireAuth=true to deny anonymous access (ADR-0056 D2, #2567).',
555+
);
556+
}
557+
// Returns a 401 Response when the caller is anonymous under the deny
558+
// posture, else null (caller proceeds). `isSystem` is never set on
559+
// inbound HTTP (internal-only), so it cannot be forged to bypass this.
560+
const denyAnonymous = (c: any, execCtx: any): Response | null => {
561+
if (!requireAuth) return null;
562+
if (execCtx?.userId || execCtx?.isSystem) return null;
563+
return c.json(
564+
{
565+
error: 'unauthenticated',
566+
message: 'Authentication is required to access this endpoint.',
567+
},
568+
401,
569+
);
570+
};
571+
532572
// Basic CRUD data endpoints — delegate to ObjectQL service directly
533573
const getObjectQL = () => ctx.getService<IDataEngine>('objectql');
534574

@@ -675,6 +715,8 @@ export class HonoServerPlugin implements Plugin {
675715
const object = c.req.param('object');
676716
const data = await c.req.json().catch(() => ({}));
677717
const execCtx = await resolveCtx(c);
718+
const denied = denyAnonymous(c, execCtx);
719+
if (denied) return denied;
678720
try {
679721
const res = await ql.insert(object, data, { context: execCtx } as any);
680722
const record = { ...data, ...res };
@@ -694,6 +736,8 @@ export class HonoServerPlugin implements Plugin {
694736
const object = c.req.param('object');
695737
const id = c.req.param('id');
696738
const execCtx = await resolveCtx(c);
739+
const denied = denyAnonymous(c, execCtx);
740+
if (denied) return denied;
697741
try {
698742
let all = await ql.find(object, { context: execCtx } as any);
699743
if (!all) all = [];
@@ -713,6 +757,8 @@ export class HonoServerPlugin implements Plugin {
713757
if (!ql) return c.json({ error: 'Data service not available' }, 503);
714758
const object = c.req.param('object');
715759
const execCtx = await resolveCtx(c);
760+
const denied = denyAnonymous(c, execCtx);
761+
if (denied) return denied;
716762
try {
717763
let all = await ql.find(object, { context: execCtx } as any);
718764
if (!Array.isArray(all) && all && (all as any).value) all = (all as any).value;

0 commit comments

Comments
 (0)