Skip to content

Commit f05f1f7

Browse files
fix(server): support CORS_ORIGIN wildcard patterns in Vercel preflight short-circuit
Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/4eda94c5-dea3-49b4-9f32-f9d97a0d7045 Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com>
1 parent db088d2 commit f05f1f7

File tree

6 files changed

+150
-120
lines changed

6 files changed

+150
-120
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- **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`)
12+
1013
### Added
1114
- **Claude Code integration (`CLAUDE.md`)** — Added root `CLAUDE.md` file so that [Claude Code](https://docs.anthropic.com/en/docs/claude-code) automatically loads the project's system prompt when launched in the repository. Content is synced with `.github/copilot-instructions.md` and includes build/test quick-reference commands, all prime directives, monorepo structure, protocol domains, coding patterns, and domain-specific prompt references. This complements the existing GitHub Copilot instructions and `skills/` directory.
1215
- **AI Skills documentation pages** — Added two new documentation pages covering the Skills System:

apps/server/server/index.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import { ObjectKernel } from '@objectstack/runtime';
1111
import { createHonoApp } from '@objectstack/hono';
12+
import { createOriginMatcher, hasWildcardPattern } from '@objectstack/plugin-hono-server';
1213
import { getRequestListener } from '@hono/node-server';
1314
import type { Hono } from 'hono';
1415
import stackConfig from '../objectstack.config';
@@ -102,8 +103,20 @@ function corsMaxAge(): number {
102103
*
103104
* - If `CORS_ORIGIN` is unset, reflects the request `Origin` (or `*` when
104105
* credentials are disabled and no `Origin` is sent).
105-
* - If `CORS_ORIGIN` is a comma-separated list, matches against it.
106+
* - If `CORS_ORIGIN` contains wildcard patterns (e.g. `https://*.example.com`
107+
* or `http://localhost:*`), matches them against the request origin using
108+
* the same rules as the Hono plugin's CORS middleware — see
109+
* `@objectstack/plugin-hono-server`'s `createOriginMatcher`.
110+
* - If `CORS_ORIGIN` is a comma-separated list of exact origins, matches
111+
* against it directly.
106112
* - Returns `null` if the origin is disallowed.
113+
*
114+
* Keeping the wildcard semantics identical to the Hono plugin is critical:
115+
* the Vercel handler short-circuits OPTIONS preflight responses here
116+
* *before* the Hono app runs. If this function rejected a wildcard origin
117+
* that the plugin itself would have accepted, the browser would see a
118+
* missing `Access-Control-Allow-Origin` header and block every subsequent
119+
* request.
107120
*/
108121
function resolveAllowOrigin(requestOrigin: string | null): string | null {
109122
const credentials = corsCredentials();
@@ -121,6 +134,14 @@ function resolveAllowOrigin(requestOrigin: string | null): string | null {
121134
return '*';
122135
}
123136

137+
// Wildcard patterns (e.g. "https://*.objectui.org", "http://localhost:*")
138+
// must be matched using the shared pattern matcher — plain Array.includes()
139+
// treats '*' as a literal character and produces spurious CORS errors.
140+
if (hasWildcardPattern(envOrigin)) {
141+
if (!requestOrigin) return null;
142+
return createOriginMatcher(envOrigin)(requestOrigin);
143+
}
144+
124145
const allowed = envOrigin.includes(',')
125146
? envOrigin.split(',').map((s: string) => s.trim()).filter(Boolean)
126147
: [envOrigin];

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

Lines changed: 2 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { cors } from 'hono/cors';
99
import { serveStatic } from '@hono/node-server/serve-static';
1010
import * as fs from 'fs';
1111
import * as path from 'path';
12+
import { createOriginMatcher, hasWildcardPattern } from './pattern-matcher';
1213

1314
export interface StaticMount {
1415
root: string;
@@ -66,62 +67,6 @@ export interface HonoPluginOptions {
6667
* - `@objectstack/rest` → CRUD, metadata, discovery, UI, batch
6768
* - `createDispatcherPlugin()` → auth, graphql, analytics, packages, etc.
6869
*/
69-
/**
70-
* Check if an origin matches a pattern with wildcards.
71-
* Supports patterns like:
72-
* - "https://*.example.com" - matches any subdomain
73-
* - "http://localhost:*" - matches any port
74-
* - "https://*.objectui.org,https://*.objectstack.ai" - comma-separated patterns
75-
*
76-
* @param origin The origin to check (e.g., "https://app.example.com")
77-
* @param pattern The pattern to match against (supports * wildcard)
78-
* @returns true if origin matches the pattern
79-
*/
80-
function matchOriginPattern(origin: string, pattern: string): boolean {
81-
if (pattern === '*') return true;
82-
if (pattern === origin) return true;
83-
84-
// Convert wildcard pattern to regex
85-
// Escape special regex characters except *
86-
const regexPattern = pattern
87-
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
88-
.replace(/\*/g, '.*'); // Convert * to .*
89-
90-
const regex = new RegExp(`^${regexPattern}$`);
91-
return regex.test(origin);
92-
}
93-
94-
/**
95-
* Create a CORS origin matcher function that supports wildcard patterns.
96-
*
97-
* @param patterns Single pattern, array of patterns, or comma-separated patterns
98-
* @returns Function that returns the origin if it matches, or null/undefined
99-
*/
100-
function createOriginMatcher(
101-
patterns: string | string[]
102-
): (origin: string) => string | undefined | null {
103-
// Normalize to array
104-
let patternList: string[];
105-
if (typeof patterns === 'string') {
106-
// Handle comma-separated patterns
107-
patternList = patterns.includes(',')
108-
? patterns.split(',').map(s => s.trim()).filter(Boolean)
109-
: [patterns];
110-
} else {
111-
patternList = patterns;
112-
}
113-
114-
// Return matcher function
115-
return (requestOrigin: string) => {
116-
for (const pattern of patternList) {
117-
if (matchOriginPattern(requestOrigin, pattern)) {
118-
return requestOrigin;
119-
}
120-
}
121-
return null;
122-
};
123-
}
124-
12570
export class HonoServerPlugin implements Plugin {
12671
name = 'com.objectstack.server.hono';
12772
type = 'server';
@@ -187,19 +132,13 @@ export class HonoServerPlugin implements Plugin {
187132
// Determine origin handler based on configuration
188133
let origin: string | string[] | ((origin: string) => string | undefined | null);
189134

190-
// Check if patterns contain wildcards (*, subdomain patterns, port patterns)
191-
const hasWildcard = (patterns: string | string[]): boolean => {
192-
const list = Array.isArray(patterns) ? patterns : [patterns];
193-
return list.some(p => p.includes('*'));
194-
};
195-
196135
// When credentials is true, browsers reject wildcard '*' for Access-Control-Allow-Origin.
197136
// For wildcard patterns (like "https://*.example.com"), always use a matcher function.
198137
// For exact origins, we can pass them directly as string/array.
199138
if (configuredOrigin === '*' && credentials) {
200139
// Credentials mode with '*' - reflect the request origin
201140
origin = (requestOrigin: string) => requestOrigin || '*';
202-
} else if (hasWildcard(configuredOrigin)) {
141+
} else if (hasWildcardPattern(configuredOrigin)) {
203142
// Wildcard patterns (including better-auth style patterns like "https://*.objectui.org")
204143
// Use pattern matcher to support subdomain and port wildcards
205144
origin = createOriginMatcher(configuredOrigin);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22

33
export * from './hono-plugin';
44
export * from './adapter';
5+
export * from './pattern-matcher';
56

packages/plugins/plugin-hono-server/src/pattern-matcher.test.ts

Lines changed: 34 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
3-
/**
4-
* Check if an origin matches a pattern with wildcards.
5-
* Supports patterns like:
6-
* - "https://*.example.com" - matches any subdomain
7-
* - "http://localhost:*" - matches any port
8-
* - "https://*.objectui.org,https://*.objectstack.ai" - comma-separated patterns
9-
*
10-
* @param origin The origin to check (e.g., "https://app.example.com")
11-
* @param pattern The pattern to match against (supports * wildcard)
12-
* @returns true if origin matches the pattern
13-
*/
14-
function matchOriginPattern(origin: string, pattern: string): boolean {
15-
if (pattern === '*') return true;
16-
if (pattern === origin) return true;
17-
18-
// Convert wildcard pattern to regex
19-
// Escape special regex characters except *
20-
const regexPattern = pattern
21-
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
22-
.replace(/\*/g, '.*'); // Convert * to .*
23-
24-
const regex = new RegExp(`^${regexPattern}$`);
25-
return regex.test(origin);
26-
}
27-
28-
/**
29-
* Create a CORS origin matcher function that supports wildcard patterns.
30-
*
31-
* @param patterns Single pattern, array of patterns, or comma-separated patterns
32-
* @returns Function that returns the origin if it matches, or null/undefined
33-
*/
34-
function createOriginMatcher(
35-
patterns: string | string[]
36-
): (origin: string) => string | undefined | null {
37-
// Normalize to array
38-
let patternList: string[];
39-
if (typeof patterns === 'string') {
40-
// Handle comma-separated patterns
41-
patternList = patterns.includes(',')
42-
? patterns.split(',').map(s => s.trim()).filter(Boolean)
43-
: [patterns];
44-
} else {
45-
patternList = patterns;
46-
}
47-
48-
// Return matcher function
49-
return (requestOrigin: string) => {
50-
for (const pattern of patternList) {
51-
if (matchOriginPattern(requestOrigin, pattern)) {
52-
return requestOrigin;
53-
}
54-
}
55-
return null;
56-
};
57-
}
2+
import { matchOriginPattern, createOriginMatcher, hasWildcardPattern, normalizeOriginPatterns } from './pattern-matcher';
583

594
describe('matchOriginPattern', () => {
605
describe('exact matching', () => {
@@ -177,4 +122,37 @@ describe('createOriginMatcher', () => {
177122
expect(matcher('http://127.0.0.1:3000')).toBe(null);
178123
});
179124
});
125+
126+
describe('empty origin handling', () => {
127+
it('should return null for empty request origin', () => {
128+
const matcher = createOriginMatcher('https://*.example.com');
129+
expect(matcher('')).toBe(null);
130+
});
131+
});
132+
});
133+
134+
describe('hasWildcardPattern', () => {
135+
it('detects wildcards in a single string', () => {
136+
expect(hasWildcardPattern('https://*.example.com')).toBe(true);
137+
expect(hasWildcardPattern('https://example.com')).toBe(false);
138+
});
139+
140+
it('detects wildcards in an array', () => {
141+
expect(hasWildcardPattern(['https://a.com', 'https://*.b.com'])).toBe(true);
142+
expect(hasWildcardPattern(['https://a.com', 'https://b.com'])).toBe(false);
143+
});
144+
});
145+
146+
describe('normalizeOriginPatterns', () => {
147+
it('splits comma-separated strings and trims whitespace', () => {
148+
expect(normalizeOriginPatterns('a, b , c')).toEqual(['a', 'b', 'c']);
149+
});
150+
151+
it('passes arrays through after trimming', () => {
152+
expect(normalizeOriginPatterns([' a ', 'b'])).toEqual(['a', 'b']);
153+
});
154+
155+
it('drops empty entries', () => {
156+
expect(normalizeOriginPatterns('a,,b')).toEqual(['a', 'b']);
157+
});
180158
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* CORS origin pattern matching utilities.
5+
*
6+
* Supports the same wildcard syntax as better-auth's `trustedOrigins`:
7+
* - `*` → matches any origin
8+
* - `https://*.example.com` → matches any subdomain
9+
* - `http://localhost:*` → matches any port
10+
* - Comma-separated list of the above
11+
*
12+
* These helpers are shared between the Hono plugin's CORS middleware and
13+
* consumers that need to apply CORS headers outside the Hono request
14+
* pipeline (e.g., the Vercel serverless entrypoint's preflight
15+
* short-circuit in `apps/server`). Keeping a single implementation
16+
* ensures both paths stay consistent — divergence caused bug where
17+
* wildcard `CORS_ORIGIN` values worked locally but produced browser
18+
* CORS errors on Vercel.
19+
*/
20+
21+
/**
22+
* Check if an origin matches a pattern with wildcards.
23+
*
24+
* @param origin The origin to check (e.g., `https://app.example.com`)
25+
* @param pattern The pattern to match against (supports `*` wildcard)
26+
* @returns true if origin matches the pattern
27+
*/
28+
export function matchOriginPattern(origin: string, pattern: string): boolean {
29+
if (pattern === '*') return true;
30+
if (pattern === origin) return true;
31+
32+
// Convert wildcard pattern to regex:
33+
// 1. Escape regex special chars EXCEPT `*`
34+
// 2. Replace `*` with `.*`
35+
const regexPattern = pattern
36+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
37+
.replace(/\*/g, '.*');
38+
39+
const regex = new RegExp(`^${regexPattern}$`);
40+
return regex.test(origin);
41+
}
42+
43+
/**
44+
* Normalize a single string / comma-separated string / array into a
45+
* trimmed array of non-empty patterns.
46+
*/
47+
export function normalizeOriginPatterns(patterns: string | string[]): string[] {
48+
if (Array.isArray(patterns)) {
49+
return patterns.map(p => p.trim()).filter(Boolean);
50+
}
51+
return patterns.includes(',')
52+
? patterns.split(',').map(s => s.trim()).filter(Boolean)
53+
: [patterns.trim()].filter(Boolean);
54+
}
55+
56+
/**
57+
* Create a CORS origin matcher function that supports wildcard patterns.
58+
*
59+
* The returned function follows Hono's `cors({ origin })` contract:
60+
* given the request's `Origin` header, it returns the origin to echo
61+
* back in `Access-Control-Allow-Origin`, or `null` if the origin is not
62+
* allowed.
63+
*
64+
* @param patterns Single pattern, array of patterns, or comma-separated patterns
65+
*/
66+
export function createOriginMatcher(
67+
patterns: string | string[]
68+
): (origin: string) => string | null {
69+
const patternList = normalizeOriginPatterns(patterns);
70+
71+
return (requestOrigin: string) => {
72+
if (!requestOrigin) return null;
73+
for (const pattern of patternList) {
74+
if (matchOriginPattern(requestOrigin, pattern)) {
75+
return requestOrigin;
76+
}
77+
}
78+
return null;
79+
};
80+
}
81+
82+
/**
83+
* True if any pattern in the given list contains a `*` wildcard.
84+
*/
85+
export function hasWildcardPattern(patterns: string | string[]): boolean {
86+
const list = Array.isArray(patterns) ? patterns : [patterns];
87+
return list.some(p => p.includes('*'));
88+
}

0 commit comments

Comments
 (0)