Skip to content

Commit 4737f9c

Browse files
Merge pull request #1178 from objectstack-ai/copilot/update-fix-task-issue
Fix CORS_ORIGIN wildcard patterns in @objectstack/hono adapter
2 parents 9b07089 + 46adf77 commit 4737f9c

5 files changed

Lines changed: 103 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Fixed
11+
- **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`)
1112
- **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`)
1213

1314
### Added

packages/adapters/hono/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
"test": "vitest run",
1717
"test:watch": "vitest"
1818
},
19+
"dependencies": {
20+
"@objectstack/plugin-hono-server": "workspace:*"
21+
},
1922
"peerDependencies": {
2023
"@objectstack/runtime": "workspace:^",
2124
"hono": "^4.12.8"

packages/adapters/hono/src/hono.test.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
44
import { Hono } from 'hono';
55

66
// Mock dispatcher instance accessible across tests
@@ -947,4 +947,82 @@ describe('createHonoApp', () => {
947947
expect(json.success).toBe(true);
948948
});
949949
});
950+
951+
describe('CORS wildcard origin patterns', () => {
952+
const ORIG_CORS_ORIGIN = process.env.CORS_ORIGIN;
953+
const ORIG_CORS_CREDENTIALS = process.env.CORS_CREDENTIALS;
954+
955+
beforeEach(() => {
956+
delete process.env.CORS_ORIGIN;
957+
delete process.env.CORS_CREDENTIALS;
958+
});
959+
960+
afterAll(() => {
961+
if (ORIG_CORS_ORIGIN === undefined) delete process.env.CORS_ORIGIN;
962+
else process.env.CORS_ORIGIN = ORIG_CORS_ORIGIN;
963+
if (ORIG_CORS_CREDENTIALS === undefined) delete process.env.CORS_CREDENTIALS;
964+
else process.env.CORS_CREDENTIALS = ORIG_CORS_CREDENTIALS;
965+
});
966+
967+
it('matches subdomain wildcard (https://*.example.com) for real subdomains', async () => {
968+
process.env.CORS_ORIGIN = 'https://*.example.com';
969+
const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
970+
971+
const res = await app.request('/api/v1/meta', {
972+
method: 'GET',
973+
headers: { Origin: 'https://app.example.com' },
974+
});
975+
expect(res.headers.get('access-control-allow-origin')).toBe('https://app.example.com');
976+
});
977+
978+
it('matches port wildcard (http://localhost:*) for any localhost port', async () => {
979+
process.env.CORS_ORIGIN = 'http://localhost:*';
980+
const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
981+
982+
const res = await app.request('/api/v1/meta', {
983+
method: 'GET',
984+
headers: { Origin: 'http://localhost:5173' },
985+
});
986+
expect(res.headers.get('access-control-allow-origin')).toBe('http://localhost:5173');
987+
});
988+
989+
it('matches the correct pattern from a comma-separated wildcard list', async () => {
990+
process.env.CORS_ORIGIN =
991+
'https://*.objectui.org,https://*.objectstack.ai,http://localhost:*';
992+
const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
993+
994+
const res = await app.request('/api/v1/meta', {
995+
method: 'GET',
996+
headers: { Origin: 'https://studio.objectstack.ai' },
997+
});
998+
expect(res.headers.get('access-control-allow-origin')).toBe('https://studio.objectstack.ai');
999+
});
1000+
1001+
it('rejects origins that do not match any wildcard pattern', async () => {
1002+
process.env.CORS_ORIGIN = 'https://*.example.com';
1003+
const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
1004+
1005+
const res = await app.request('/api/v1/meta', {
1006+
method: 'GET',
1007+
headers: { Origin: 'https://evil.com' },
1008+
});
1009+
// Hono's cors() returns no allow-origin header when the matcher rejects
1010+
expect(res.headers.get('access-control-allow-origin')).toBeNull();
1011+
});
1012+
1013+
it('responds to preflight OPTIONS with matched wildcard origin', async () => {
1014+
process.env.CORS_ORIGIN = 'https://*.objectui.org';
1015+
const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
1016+
1017+
const res = await app.request('/api/v1/meta', {
1018+
method: 'OPTIONS',
1019+
headers: {
1020+
Origin: 'https://app.objectui.org',
1021+
'Access-Control-Request-Method': 'POST',
1022+
},
1023+
});
1024+
expect(res.status).toBe(204);
1025+
expect(res.headers.get('access-control-allow-origin')).toBe('https://app.objectui.org');
1026+
});
1027+
});
9501028
});

packages/adapters/hono/src/index.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Hono } from 'hono';
44
import { cors } from 'hono/cors';
55
import { type ObjectKernel, HttpDispatcher, HttpDispatcherResult } from '@objectstack/runtime';
6+
import { createOriginMatcher, hasWildcardPattern } from '@objectstack/plugin-hono-server';
67

78
export interface ObjectStackHonoCorsOptions {
89
/** Enable or disable CORS. Defaults to true. */
@@ -96,11 +97,24 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
9697
const maxAge = corsOpts.maxAge ?? (process.env.CORS_MAX_AGE ? parseInt(process.env.CORS_MAX_AGE, 10) : 86400);
9798

9899
// When credentials is true, browsers reject wildcard '*' for Access-Control-Allow-Origin.
99-
// Use a function to reflect the request's Origin header instead.
100+
// For wildcard patterns (like "https://*.example.com" or "http://localhost:*") we must
101+
// use a matcher function — Hono's cors() middleware does exact-string matching only and
102+
// treats '*' in patterns as a literal character, so passing wildcard strings straight
103+
// through would silently drop the Access-Control-Allow-Origin header on every real
104+
// request (preflight can still succeed via apps/server's short-circuit, but the
105+
// subsequent POST/GET would be blocked by the browser).
106+
//
107+
// This mirrors `plugin-hono-server`'s CORS wiring and uses the shared pattern matcher
108+
// from `@objectstack/plugin-hono-server` so all Hono-based code paths stay in sync.
100109
let origin: string | string[] | ((origin: string) => string | undefined | null);
101-
if (credentials && configuredOrigin === '*') {
110+
if (configuredOrigin === '*' && credentials) {
111+
// Credentials mode with '*' — reflect the request origin
102112
origin = (requestOrigin: string) => requestOrigin || '*';
113+
} else if (hasWildcardPattern(configuredOrigin)) {
114+
// Wildcard patterns (e.g., "https://*.objectui.org", "http://localhost:*")
115+
origin = createOriginMatcher(configuredOrigin);
103116
} else {
117+
// Exact origin(s) — pass through as-is
104118
origin = configuredOrigin;
105119
}
106120

pnpm-lock.yaml

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)