From ae2b0b732871a1bcdc0d6d81e65209535cc46184 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:33:39 +0000 Subject: [PATCH] fix(plugin-hono-server): allow If-None-Match and expose ETag in the default CORS policy The /meta draft save reads If-None-Match: * and the metadata reads answer an ETag that cached reads send back. The default CORS lists allowed If-Match but not If-None-Match and exposed no ETag, so a cross-origin first-draft save died at the preflight and cross-origin JS read the ETag as null. Both default lists gain the one entry each; allowed origins are unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5nsjQhYdbu8MX5XyPn --- .changeset/28970-cors-conditional-headers.md | 17 ++ .../docs/protocol/kernel/http-protocol.mdx | 16 +- packages/adapters/hono/src/hono.test.ts | 39 ++++ .../plugins/plugin-hono-server/src/adapter.ts | 12 +- .../src/cors-conditional-headers.test.ts | 166 ++++++++++++++++++ 5 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 .changeset/28970-cors-conditional-headers.md create mode 100644 packages/plugins/plugin-hono-server/src/cors-conditional-headers.test.ts diff --git a/.changeset/28970-cors-conditional-headers.md b/.changeset/28970-cors-conditional-headers.md new file mode 100644 index 00000000000..0f13ee870df --- /dev/null +++ b/.changeset/28970-cors-conditional-headers.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-hono-server': patch +--- + +fix(plugin-hono-server): the default CORS policy lets `If-None-Match` through and exposes `ETag`, so a cross-origin browser client can make the conditional requests the metadata doors speak + +Clause-②: yes (widening) +Metadata-protocol: none + +The `/meta` draft save reads `If-Match` and `If-None-Match: *` (the first-write pin the console's `DraftConflictDialog` and Studio's first-draft saves send), and the metadata reads answer an `ETag` that `@objectstack/client`'s cached reads send back as `If-None-Match`. The default CORS policy allowed `If-Match` but not `If-None-Match`, and exposed no `ETag`. From another origin, the first-draft save and every cached read failed in the browser with a bare `Failed to fetch` (the preflight refused them, so nothing reached the server), and cross-origin JS read the `ETag` as `null`. + +- **`DEFAULT_CORS_ALLOW_HEADERS`** gains `If-None-Match`. +- **`DEFAULT_CORS_EXPOSE_HEADERS`** gains `ETag`. + +Both constants are applied by `@objectstack/plugin-hono-server` and by the `@objectstack/hono` adapter's `createHonoApp`. Allowed origins are unchanged: a request from an origin the policy does not admit still gets no `Access-Control-Allow-Origin`. + +A deployment that passes its own `allowHeaders` replaces the default, as before: add `If-None-Match` to that list to let a cross-origin client send it. `exposeHeaders` still merges with the default, so `ETag` is exposed either way. diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 696a7e6fa63..1fb50991161 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -1039,26 +1039,30 @@ ObjectStack sends CORS headers automatically: ```http Access-Control-Allow-Origin: https://app.acme.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS -Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-Tenant-ID, X-Environment-Id, If-Match, X-Share-Password, X-Share-Password-Encoding -Access-Control-Expose-Headers: set-auth-token, x-objectstack-dropped-fields +Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-Tenant-ID, X-Environment-Id, If-Match, If-None-Match, X-Share-Password, X-Share-Password-Encoding +Access-Control-Expose-Headers: set-auth-token, x-objectstack-dropped-fields, ETag Access-Control-Max-Age: 86400 ``` -Five of the allowed request headers are easy to overlook, and each one disables +Six of the allowed request headers are easy to overlook, and each one disables a feature if an intermediate proxy strips it: | Header | Why it is allowed | | --- | --- | | `X-Tenant-ID` / `X-Environment-Id` | Route the request to its environment on a multi-tenant host. | | `If-Match` | Carries the OCC token on record `PATCH`es. Without it, a cross-origin save fails in the browser with "Failed to fetch". | +| `If-None-Match` | Carries the `/meta` draft save's first-write pin (`If-None-Match: *`) and the `ETag` a cached metadata read sends back. Without it, the preflight refuses the request and the browser answers "Failed to fetch" before it reaches the server. | | `X-Share-Password` | Carries a share-link password to the public `/share-links/:token/resolve` and `/messages` routes. It is the preferred form because a header stays out of URLs; without it, a cross-origin client can only send the password as a query parameter. | | `X-Share-Password-Encoding` | Declares how `X-Share-Password` is encoded. Its one accepted value is `utf-8` (any case): the password header then carries the password's UTF-8 bytes percent-encoded, as `encodeURIComponent(password)` produces them. A browser cannot put a character above U+00FF in a header, and strips leading and trailing spaces, so this is how such a password is sent. Without this header, `X-Share-Password` is read raw, as it arrives. Any other value, or a password header that is not percent-encoded UTF-8 under `utf-8`, is refused with `400 VALIDATION_FAILED`. | -The two **exposed** response headers matter to browser clients specifically: +The three **exposed** response headers matter to browser clients specifically: `set-auth-token` delivers a rotated session token (without it a cross-origin -session silently breaks even though every request succeeds), and +session silently breaks even though every request succeeds), `x-objectstack-dropped-fields` warns that a write dropped undeclared keys — the -response body's `droppedFields` stays the primary channel for that. +response body's `droppedFields` stays the primary channel for that — and `ETag` +is the version a metadata read answers, which a client sends back as +`If-None-Match` or pins a save to as `If-Match` (without it cross-origin JS +reads it as `null`). These are the defaults exported as `DEFAULT_CORS_ALLOW_HEADERS` and `DEFAULT_CORS_EXPOSE_HEADERS` from `@objectstack/plugin-hono-server`. Supplying diff --git a/packages/adapters/hono/src/hono.test.ts b/packages/adapters/hono/src/hono.test.ts index 5f4c0204d19..560ffd380c0 100644 --- a/packages/adapters/hono/src/hono.test.ts +++ b/packages/adapters/hono/src/hono.test.ts @@ -1099,6 +1099,45 @@ describe('createHonoApp', () => { expect(exposed).toContain('x-objectstack-dropped-fields'); }); + // [#28970] The metadata reads answer an `ETag` that `@objectstack/client`'s + // cached reads send back; cross-origin JS reads it as `null` unless exposed. + it('always exposes ETag by default', async () => { + const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' }); + + const res = await app.request('/api/v1/meta', { + method: 'GET', + headers: { Origin: 'https://app.example.com' }, + }); + const exposed = (res.headers.get('access-control-expose-headers') || '') + .split(',').map(s => s.trim().toLowerCase()); + expect(exposed).toContain('etag'); + }); + + // [#28970] The `/meta` first-draft save sends `If-None-Match: *`; a preflight + // that does not allow it fails the save in the browser before it is sent. + it('allows If-None-Match on a preflight by default, beside If-Match', async () => { + const app = createHonoApp({ + kernel: mockKernel, + prefix: '/api/v1', + cors: { origin: 'https://app.example.com' }, + }); + + const res = await app.request('/api/v1/meta/page/x?mode=draft', { + method: 'OPTIONS', + headers: { + Origin: 'https://app.example.com', + 'Access-Control-Request-Method': 'PUT', + 'Access-Control-Request-Headers': 'content-type,if-match,if-none-match', + }, + }); + expect(res.status).toBe(204); + expect(res.headers.get('access-control-allow-origin')).toBe('https://app.example.com'); + const allowed = (res.headers.get('access-control-allow-headers') || '') + .split(',').map(s => s.trim().toLowerCase()); + expect(allowed).toContain('if-none-match'); + expect(allowed).toContain('if-match'); + }); + it('merges user-supplied exposeHeaders with set-auth-token (does not replace)', async () => { const app = createHonoApp({ kernel: mockKernel, diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index 576e123bef3..69a105b36c2 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -67,6 +67,11 @@ import { * `If-Match` carries the OCC token on record PATCHes (objectui's inline edit, * REST `update` with `ifMatch`) — without it in the preflight allow-list every * cross-origin save fails in the browser with "Failed to fetch" (objectui#2572). + * `If-None-Match` carries the `/meta` draft save's first-write pin (`*`, the + * console's `DraftConflictDialog` and Studio's first-draft saves) and the + * validator `@objectstack/client`'s cached metadata reads send back — without + * it here the preflight refuses both and the browser answers a bare "Failed to + * fetch" before the request reaches the door (#28970). * `X-Share-Password` carries a share-link password to the public * `/share-links/:token/resolve` and `/messages` routes — the preferred form, * since a header stays out of URLs (#21839); without it here a cross-origin @@ -83,6 +88,7 @@ export const DEFAULT_CORS_ALLOW_HEADERS: readonly string[] = Object.freeze([ 'X-Tenant-ID', 'X-Environment-Id', 'If-Match', + 'If-None-Match', 'X-Share-Password', 'X-Share-Password-Encoding', ]); @@ -94,11 +100,15 @@ export const DEFAULT_CORS_ALLOW_HEADERS: readonly string[] = Object.freeze([ * `set-auth-token` lets better-auth's `bearer()` plugin hand rotated session * tokens to cross-origin clients (see plugin-auth). `x-objectstack-dropped-fields` * (#3455) exposes the single-write drop warning (#3431); the body `droppedFields` - * channel remains the primary, cross-origin-safe surface. + * channel remains the primary, cross-origin-safe surface. `ETag` is the version + * the metadata reads answer, which a cached read sends back as `If-None-Match` + * and a save can pin as `If-Match` — without it here cross-origin JS reads it + * as `null` (#28970). */ export const DEFAULT_CORS_EXPOSE_HEADERS: readonly string[] = Object.freeze([ 'set-auth-token', 'x-objectstack-dropped-fields', + 'ETag', ]); /** diff --git a/packages/plugins/plugin-hono-server/src/cors-conditional-headers.test.ts b/packages/plugins/plugin-hono-server/src/cors-conditional-headers.test.ts new file mode 100644 index 00000000000..bc64b8c7d16 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/cors-conditional-headers.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The conditional-request headers the server's own doors speak must pass the + * default CORS policy — driven through the real adapter and the real + * `hono/cors` middleware (`app.request`), never a captured config. + * + * The doors: the `/meta` draft save reads `If-Match` and `If-None-Match: *` + * (`@objectstack/rest`'s `metaSaveRequestOptions`), and the metadata reads + * answer `ETag` and a bodiless `304` to an `If-None-Match` that names it + * (`@objectstack/client`'s cached reads send it back). A cross-origin browser + * client the policy admits must be able to send both conditional headers and + * read the `ETag`: a preflight that does not allow `If-None-Match` fails the + * request in the browser with a bare "Failed to fetch" before anything reaches + * the server, and an `ETag` the policy does not expose reads as `null` in JS. + * + * The defaults are {@link DEFAULT_CORS_ALLOW_HEADERS} and + * {@link DEFAULT_CORS_EXPOSE_HEADERS}; `@objectstack/hono`'s `createHonoApp` + * applies the same two constants and carries its own pin. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { PluginContext } from '@objectstack/core'; + +import type { HonoCorsOptions } from './adapter'; +import { HonoServerPlugin } from './hono-plugin'; + +const ALLOWED = 'https://app.example.com'; +const FOREIGN = 'https://other.example'; + +/** Every variable the CORS block reads, canonical and legacy spellings. */ +const CORS_ENV = [ + 'OS_CORS_ENABLED', 'CORS_ENABLED', + 'OS_CORS_ORIGIN', 'CORS_ORIGIN', + 'OS_CORS_CREDENTIALS', 'CORS_CREDENTIALS', + 'OS_CORS_MAX_AGE', 'CORS_MAX_AGE', + 'NODE_ENV', +] as const; + +let saved: Record = {}; + +beforeEach(() => { + saved = {}; + for (const name of CORS_ENV) { + saved[name] = process.env[name]; + delete process.env[name]; + } + process.env.NODE_ENV = 'production'; +}); + +afterEach(() => { + for (const name of CORS_ENV) { + if (saved[name] === undefined) delete process.env[name]; + else process.env[name] = saved[name]; + } +}); + +function fakeCtx(): PluginContext { + const services = new Map(); + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + registerService: (name: string, svc: unknown) => services.set(name, svc), + getService: (name: string) => services.get(name), + hook: () => {}, + getKernel: () => ({}), + } as unknown as PluginContext; +} + +interface ProbeContext { + header(name: string, value: string): void; + json(body: unknown): Response; +} + +interface RawApp { + get(path: string, handler: (c: ProbeContext) => Response): unknown; + put(path: string, handler: (c: ProbeContext) => Response): unknown; + request(path: string, init?: RequestInit): Promise; +} + +const META_ITEM = '/api/v1/meta/page/zz_cors_probe'; + +/** + * Run the real `init()` with one admitted origin, then mount a stand-in for + * the metadata item door behind its middleware: the read answers an `ETag`, + * the draft save answers `200`. + */ +async function boot(cors: HonoCorsOptions = {}): Promise { + const plugin = new HonoServerPlugin({ + port: 0, + serverTiming: false, + cors: { origins: [ALLOWED], ...cors }, + }); + await plugin.init(fakeCtx()); + const rawApp = (plugin as unknown as { server: { getRawApp(): RawApp } }).server.getRawApp(); + rawApp.get(META_ITEM, (c) => { + c.header('ETag', '"v1"'); + return c.json({ name: 'zz_cors_probe' }); + }); + rawApp.put(META_ITEM, (c) => c.json({ ok: true })); + return rawApp; +} + +/** The browser's preflight ahead of the first-draft save `PUT …?mode=draft` + `If-None-Match: *`. */ +function draftPreflight(app: RawApp, origin: string, requested: string): Promise { + return app.request(`${META_ITEM}?mode=draft`, { + method: 'OPTIONS', + headers: { + Origin: origin, + 'Access-Control-Request-Method': 'PUT', + 'Access-Control-Request-Headers': requested, + }, + }); +} + +/** A comma-separated header value as a lower-cased list. */ +function listOf(res: Response, name: string): string[] { + return (res.headers.get(name) ?? '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); +} + +describe('the default CORS policy admits the conditional headers the metadata doors speak', () => { + it('answers a preflight asking If-None-Match with it in Access-Control-Allow-Headers', async () => { + const app = await boot(); + const res = await draftPreflight(app, ALLOWED, 'content-type,if-none-match,authorization'); + expect(res.status).toBe(204); + expect(res.headers.get('access-control-allow-origin')).toBe(ALLOWED); + expect(listOf(res, 'access-control-allow-headers')).toContain('if-none-match'); + }); + + it('exposes ETag on a cross-origin metadata read, so cross-origin JS can read the version', async () => { + const app = await boot(); + const res = await app.request(META_ITEM, { headers: { Origin: ALLOWED } }); + expect(res.status).toBe(200); + expect(res.headers.get('access-control-allow-origin')).toBe(ALLOWED); + expect(res.headers.get('etag')).toBe('"v1"'); + expect(listOf(res, 'access-control-expose-headers')).toContain('etag'); + }); +}); + +describe('controls: what the two entries leave unchanged', () => { + it('still answers a disallowed origin with no Access-Control-Allow-Origin, on the preflight and on the read', async () => { + const app = await boot(); + const preflight = await draftPreflight(app, FOREIGN, 'content-type,if-none-match'); + expect(preflight.headers.get('access-control-allow-origin')).toBeNull(); + const read = await app.request(META_ITEM, { headers: { Origin: FOREIGN } }); + expect(read.headers.get('access-control-allow-origin')).toBeNull(); + }); + + it('keeps If-Match in the allow list and the existing exposed headers in the expose list', async () => { + const app = await boot(); + const preflight = await draftPreflight(app, ALLOWED, 'content-type,if-match'); + expect(listOf(preflight, 'access-control-allow-headers')).toContain('if-match'); + const read = await app.request(META_ITEM, { headers: { Origin: ALLOWED } }); + const exposed = listOf(read, 'access-control-expose-headers'); + expect(exposed).toContain('set-auth-token'); + expect(exposed).toContain('x-objectstack-dropped-fields'); + }); + + it('lets a custom allowHeaders still replace the default (If-None-Match then only if listed)', async () => { + const app = await boot({ allowHeaders: ['Content-Type', 'Authorization'] }); + const res = await draftPreflight(app, ALLOWED, 'content-type,if-none-match'); + expect(listOf(res, 'access-control-allow-headers')).toEqual(['content-type', 'authorization']); + }); +});