Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/28970-cors-conditional-headers.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 10 additions & 6 deletions content/docs/protocol/kernel/http-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions packages/adapters/hono/src/hono.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion packages/plugins/plugin-hono-server/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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',
]);
Expand All @@ -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',
]);

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {};

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<string, unknown>();
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<Response>;
}

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<RawApp> {
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<Response> {
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']);
});
});
Loading