Skip to content

Commit 8f6e89c

Browse files
committed
fix(cors): normalize self-hosted origin matching
Normalize CLIENT_URL and CHECKOUT_BASE_URL to strict HTTP(S) origins, reuse dashboard origin checks in API auth middleware, and harden origin unit tests for env restoration and malformed values.
1 parent 15c2985 commit 8f6e89c

File tree

4 files changed

+76
-14
lines changed

4 files changed

+76
-14
lines changed

server/src/middleware/apiAuthMiddleware.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AuthType, ErrCode } from "@autumn/shared";
22
import { verifyKey } from "@/internal/dev/api-keys/apiKeyUtils.js";
3-
import { dashboardOrigins } from "@/utils/constants.js";
3+
import { isDashboardOrigin } from "@/utils/constants.js";
44
import RecaseError from "@/utils/errorUtils.js";
55
import { withOrgAuth } from "./authMiddleware.js";
66
import { verifyBearerPublishableKey } from "./publicAuthMiddleware.js";
@@ -15,7 +15,7 @@ const verifySecretKey = async (req: any, res: any, next: any) => {
1515

1616
if (!authHeader || !authHeader.startsWith("Bearer ")) {
1717
const origin = req.get("origin");
18-
if (dashboardOrigins.includes(origin)) {
18+
if (isDashboardOrigin({ origin })) {
1919
return withOrgAuth(req, res, next);
2020
} else {
2121
throw new RecaseError({
@@ -100,7 +100,7 @@ export const apiAuthMiddleware = async (req: any, res: any, next: any) => {
100100
} catch (error: any) {
101101
if (error instanceof RecaseError) {
102102
if (error.code === ErrCode.InvalidSecretKey) {
103-
const apiKey = req.headers["authorization"]?.split(" ")[1];
103+
const apiKey = req.headers.authorization?.split(" ")[1];
104104
error.message = `Invalid secret key: ${maskApiKey(apiKey)}`;
105105
}
106106

server/src/utils/constants.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import "dotenv/config";
22
import { CusProductStatus } from "@autumn/shared";
3+
import { getClientOrigin } from "./corsOrigins.js";
34

45
const BREAK_API_VERSION = 0.2;
56

@@ -20,14 +21,29 @@ export const ADMIN_USER_IDs = [
2021
"K7NDwSwohMCV9BXJ3Yb5MxgeXhWcwj0L", // charlie sandbox
2122
];
2223

23-
export const dashboardOrigins = [
24+
const DEFAULT_DASHBOARD_ORIGINS = [
2425
"http://localhost:3000",
2526
"https://app.useautumn.com",
2627
"https://staging.useautumn.com",
2728
"https://dev.useautumn.com",
28-
process.env.CLIENT_URL!,
2929
];
3030

31+
export const getDashboardOrigins = (): string[] => {
32+
const clientOrigin = getClientOrigin();
33+
const origins = [...DEFAULT_DASHBOARD_ORIGINS];
34+
if (clientOrigin) {
35+
origins.push(clientOrigin);
36+
}
37+
38+
return [...new Set(origins)];
39+
};
40+
41+
export const isDashboardOrigin = ({ origin }: { origin?: string }) => {
42+
if (!origin) return false;
43+
44+
return getDashboardOrigins().includes(origin);
45+
};
46+
3147
export const WEBHOOK_EVENTS = [
3248
"checkout.session.completed",
3349
"customer.subscription.created",

server/src/utils/corsOrigins.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,32 @@ export const ALLOWED_ORIGINS = [
1818

1919
const toOrigin = ({ url }: { url?: string }): string | undefined => {
2020
if (!url) return undefined;
21+
const trimmedUrl = url.trim();
22+
if (!trimmedUrl) return undefined;
2123

2224
try {
23-
return new URL(url).origin;
25+
const parsedUrl = new URL(trimmedUrl);
26+
if (!["http:", "https:"].includes(parsedUrl.protocol)) return undefined;
27+
return parsedUrl.origin;
2428
} catch {
2529
return undefined;
2630
}
2731
};
2832

33+
export const getClientOrigin = (): string | undefined => {
34+
return toOrigin({ url: process.env.CLIENT_URL });
35+
};
36+
37+
export const getCheckoutBaseOrigin = (): string | undefined => {
38+
return toOrigin({ url: process.env.CHECKOUT_BASE_URL });
39+
};
40+
2941
export const getSelfHostedOrigins = (): string[] => {
30-
const origins = [
31-
toOrigin({ url: process.env.CLIENT_URL }),
32-
toOrigin({ url: process.env.CHECKOUT_BASE_URL }),
33-
];
42+
const origins = [getClientOrigin(), getCheckoutBaseOrigin()];
3443

35-
return origins.filter((origin): origin is string => Boolean(origin));
44+
return [
45+
...new Set(origins.filter((origin): origin is string => Boolean(origin))),
46+
];
3647
};
3748

3849
/** Allow any localhost origin in dev for multi-worktree support */

server/tests/unit/corsOrigins.test.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,33 @@
11
import { afterEach, describe, expect, test } from "bun:test";
22
import { ALLOWED_ORIGINS, isAllowedOrigin } from "@/utils/corsOrigins.js";
33

4+
const restoreEnvVar = ({
5+
key,
6+
value,
7+
}: {
8+
key: "NODE_ENV" | "CLIENT_URL" | "CHECKOUT_BASE_URL";
9+
value: string | undefined;
10+
}) => {
11+
if (value === undefined) {
12+
delete process.env[key];
13+
return;
14+
}
15+
16+
process.env[key] = value;
17+
};
18+
419
describe("isAllowedOrigin", () => {
520
const originalNodeEnv = process.env.NODE_ENV;
621
const originalClientUrl = process.env.CLIENT_URL;
722
const originalCheckoutBaseUrl = process.env.CHECKOUT_BASE_URL;
823

924
afterEach(() => {
10-
process.env.NODE_ENV = originalNodeEnv;
11-
process.env.CLIENT_URL = originalClientUrl;
12-
process.env.CHECKOUT_BASE_URL = originalCheckoutBaseUrl;
25+
restoreEnvVar({ key: "NODE_ENV", value: originalNodeEnv });
26+
restoreEnvVar({ key: "CLIENT_URL", value: originalClientUrl });
27+
restoreEnvVar({
28+
key: "CHECKOUT_BASE_URL",
29+
value: originalCheckoutBaseUrl,
30+
});
1331
});
1432

1533
describe("production", () => {
@@ -96,6 +114,14 @@ describe("isAllowedOrigin", () => {
96114
).toBe("https://autumn-checkout-production.up.railway.app");
97115
});
98116

117+
test("trims whitespace around self-hosted env URLs", () => {
118+
process.env.NODE_ENV = "production";
119+
process.env.CLIENT_URL = " https://dashboard.mycompany.com/app/ ";
120+
expect(isAllowedOrigin("https://dashboard.mycompany.com")).toBe(
121+
"https://dashboard.mycompany.com",
122+
);
123+
});
124+
99125
test("allows CHECKOUT_BASE_URL origin when URL has path", () => {
100126
process.env.NODE_ENV = "production";
101127
process.env.CHECKOUT_BASE_URL =
@@ -137,6 +163,15 @@ describe("isAllowedOrigin", () => {
137163
).toBeUndefined();
138164
});
139165

166+
test("ignores non-http protocols in self-hosted env URLs", () => {
167+
process.env.NODE_ENV = "production";
168+
process.env.CLIENT_URL = "ftp://dashboard.mycompany.com";
169+
170+
expect(
171+
isAllowedOrigin("https://dashboard.mycompany.com"),
172+
).toBeUndefined();
173+
});
174+
140175
test("rejects custom domains when env URLs are unset", () => {
141176
process.env.NODE_ENV = "production";
142177
delete process.env.CLIENT_URL;

0 commit comments

Comments
 (0)