diff --git a/CHANGELOG.md b/CHANGELOG.md index d9f10b50d5..a140882c37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **CORS wildcard patterns in `@objectstack/hono` adapter (follow-up to PR #1177)** — `createHonoApp()` was the third CORS code path that still treated wildcard origins (e.g. `https://*.objectui.org`) as literal strings when passing them to Hono's `cors()` middleware. Because `apps/server` routes all non-OPTIONS requests through this adapter on Vercel, the browser would see a successful preflight (handled by the Vercel short-circuit) followed by a POST/GET response with no `Access-Control-Allow-Origin` header, blocking every real request. The adapter now imports `hasWildcardPattern` / `createOriginMatcher` from `@objectstack/plugin-hono-server` and uses the same matcher-function branch as `plugin-hono-server`, so all three Hono-based CORS paths share a single source of truth. (`packages/adapters/hono/src/index.ts`) - **CORS wildcard patterns on Vercel deployments** — `CORS_ORIGIN` values containing wildcard patterns (e.g. `https://*.objectui.org,https://*.objectstack.ai,http://localhost:*`) no longer cause browser CORS errors when `apps/server` is deployed to Vercel. The Vercel entrypoint's OPTIONS preflight short-circuit previously matched origins with a literal `Array.includes()`, treating `*` as a plain character and rejecting legitimate subdomains. It now shares the same pattern-matching logic as the Hono plugin's `cors()` middleware via new exports `createOriginMatcher` / `hasWildcardPattern` / `matchOriginPattern` / `normalizeOriginPatterns` from `@objectstack/plugin-hono-server`. (`apps/server/server/index.ts`, `packages/plugins/plugin-hono-server/src/pattern-matcher.ts`) ### Added diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index 5592acca2b..c5030d97e0 100644 --- a/packages/adapters/hono/package.json +++ b/packages/adapters/hono/package.json @@ -16,6 +16,9 @@ "test": "vitest run", "test:watch": "vitest" }, + "dependencies": { + "@objectstack/plugin-hono-server": "workspace:*" + }, "peerDependencies": { "@objectstack/runtime": "workspace:^", "hono": "^4.12.8" diff --git a/packages/adapters/hono/src/hono.test.ts b/packages/adapters/hono/src/hono.test.ts index 057f8a077c..c3626aec45 100644 --- a/packages/adapters/hono/src/hono.test.ts +++ b/packages/adapters/hono/src/hono.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; import { Hono } from 'hono'; // Mock dispatcher instance accessible across tests @@ -947,4 +947,82 @@ describe('createHonoApp', () => { expect(json.success).toBe(true); }); }); + + describe('CORS wildcard origin patterns', () => { + const ORIG_CORS_ORIGIN = process.env.CORS_ORIGIN; + const ORIG_CORS_CREDENTIALS = process.env.CORS_CREDENTIALS; + + beforeEach(() => { + delete process.env.CORS_ORIGIN; + delete process.env.CORS_CREDENTIALS; + }); + + afterAll(() => { + if (ORIG_CORS_ORIGIN === undefined) delete process.env.CORS_ORIGIN; + else process.env.CORS_ORIGIN = ORIG_CORS_ORIGIN; + if (ORIG_CORS_CREDENTIALS === undefined) delete process.env.CORS_CREDENTIALS; + else process.env.CORS_CREDENTIALS = ORIG_CORS_CREDENTIALS; + }); + + it('matches subdomain wildcard (https://*.example.com) for real subdomains', async () => { + process.env.CORS_ORIGIN = 'https://*.example.com'; + const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' }); + + const res = await app.request('/api/v1/meta', { + method: 'GET', + headers: { Origin: 'https://app.example.com' }, + }); + expect(res.headers.get('access-control-allow-origin')).toBe('https://app.example.com'); + }); + + it('matches port wildcard (http://localhost:*) for any localhost port', async () => { + process.env.CORS_ORIGIN = 'http://localhost:*'; + const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' }); + + const res = await app.request('/api/v1/meta', { + method: 'GET', + headers: { Origin: 'http://localhost:5173' }, + }); + expect(res.headers.get('access-control-allow-origin')).toBe('http://localhost:5173'); + }); + + it('matches the correct pattern from a comma-separated wildcard list', async () => { + process.env.CORS_ORIGIN = + 'https://*.objectui.org,https://*.objectstack.ai,http://localhost:*'; + const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' }); + + const res = await app.request('/api/v1/meta', { + method: 'GET', + headers: { Origin: 'https://studio.objectstack.ai' }, + }); + expect(res.headers.get('access-control-allow-origin')).toBe('https://studio.objectstack.ai'); + }); + + it('rejects origins that do not match any wildcard pattern', async () => { + process.env.CORS_ORIGIN = 'https://*.example.com'; + const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' }); + + const res = await app.request('/api/v1/meta', { + method: 'GET', + headers: { Origin: 'https://evil.com' }, + }); + // Hono's cors() returns no allow-origin header when the matcher rejects + expect(res.headers.get('access-control-allow-origin')).toBeNull(); + }); + + it('responds to preflight OPTIONS with matched wildcard origin', async () => { + process.env.CORS_ORIGIN = 'https://*.objectui.org'; + const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' }); + + const res = await app.request('/api/v1/meta', { + method: 'OPTIONS', + headers: { + Origin: 'https://app.objectui.org', + 'Access-Control-Request-Method': 'POST', + }, + }); + expect(res.status).toBe(204); + expect(res.headers.get('access-control-allow-origin')).toBe('https://app.objectui.org'); + }); + }); }); diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index 95c4ab4cb0..ea2ac4bb02 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -3,6 +3,7 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { type ObjectKernel, HttpDispatcher, HttpDispatcherResult } from '@objectstack/runtime'; +import { createOriginMatcher, hasWildcardPattern } from '@objectstack/plugin-hono-server'; export interface ObjectStackHonoCorsOptions { /** Enable or disable CORS. Defaults to true. */ @@ -96,11 +97,24 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { const maxAge = corsOpts.maxAge ?? (process.env.CORS_MAX_AGE ? parseInt(process.env.CORS_MAX_AGE, 10) : 86400); // When credentials is true, browsers reject wildcard '*' for Access-Control-Allow-Origin. - // Use a function to reflect the request's Origin header instead. + // For wildcard patterns (like "https://*.example.com" or "http://localhost:*") we must + // use a matcher function — Hono's cors() middleware does exact-string matching only and + // treats '*' in patterns as a literal character, so passing wildcard strings straight + // through would silently drop the Access-Control-Allow-Origin header on every real + // request (preflight can still succeed via apps/server's short-circuit, but the + // subsequent POST/GET would be blocked by the browser). + // + // This mirrors `plugin-hono-server`'s CORS wiring and uses the shared pattern matcher + // from `@objectstack/plugin-hono-server` so all Hono-based code paths stay in sync. let origin: string | string[] | ((origin: string) => string | undefined | null); - if (credentials && configuredOrigin === '*') { + if (configuredOrigin === '*' && credentials) { + // Credentials mode with '*' — reflect the request origin origin = (requestOrigin: string) => requestOrigin || '*'; + } else if (hasWildcardPattern(configuredOrigin)) { + // Wildcard patterns (e.g., "https://*.objectui.org", "http://localhost:*") + origin = createOriginMatcher(configuredOrigin); } else { + // Exact origin(s) — pass through as-is origin = configuredOrigin; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 357d006ea0..3bdd9ad593 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -481,6 +481,10 @@ importers: version: 4.1.4(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(happy-dom@20.9.0)(msw@2.13.3(@types/node@25.6.0)(typescript@6.0.2))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/adapters/hono: + dependencies: + '@objectstack/plugin-hono-server': + specifier: workspace:* + version: link:../../plugins/plugin-hono-server devDependencies: '@objectstack/runtime': specifier: workspace:*