Skip to content
This repository was archived by the owner on Sep 29, 2025. It is now read-only.

Commit 6b8ce6a

Browse files
mongodbenBen Perlmutter
andauthored
CORS for localhost in non-prod (#842)
cors for localhost in non-prod Co-authored-by: Ben Perlmutter <mongodben@mongodb.com>
1 parent d21f8b3 commit 6b8ce6a

File tree

5 files changed

+273
-6
lines changed

5 files changed

+273
-6
lines changed

package-lock.json

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

packages/chatbot-server-mongodb-public/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"ahocorasick": "^1.0.2",
3333
"common-tags": "^1.8.2",
3434
"cookie-parser": "^1.4.6",
35+
"cors": "^2.8.5",
3536
"dotenv": "^16.0.3",
3637
"express": "^4.18.2",
3738
"mongodb-chatbot-server": "*",

packages/chatbot-server-mongodb-public/src/config.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import { makeBraintrustLogger } from "mongodb-rag-core/braintrust";
6565
import { makeMongoDbScrubbedMessageStore } from "./tracing/scrubbedMessages/MongoDbScrubbedMessageStore";
6666
import { MessageAnalysis } from "./tracing/scrubbedMessages/analyzeMessage";
6767
import { createAzure } from "mongodb-rag-core/aiSdk";
68+
import { makeCorsOptions } from "./corsOptions";
6869

6970
export const {
7071
MONGODB_CONNECTION_URI,
@@ -102,7 +103,7 @@ export const braintrustLogger = makeBraintrustLogger({
102103
projectName: process.env.BRAINTRUST_CHATBOT_TRACING_PROJECT_NAME,
103104
});
104105

105-
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") || [];
106+
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") ?? [];
106107

107108
export const openAiClient = wrapOpenAI(
108109
new AzureOpenAI({
@@ -413,11 +414,7 @@ export const config: AppConfig = {
413414
},
414415
},
415416
maxRequestTimeoutMs: 60000,
416-
corsOptions: {
417-
origin: allowedOrigins,
418-
// Allow cookies from different origins to be sent to the server.
419-
credentials: true,
420-
},
417+
corsOptions: makeCorsOptions(isProduction, allowedOrigins),
421418
expressAppConfig: !isProduction
422419
? async (app) => {
423420
const staticAssetsPath = path.join(__dirname, "..", "static");
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import express from "express";
2+
import cors from "cors";
3+
import request from "supertest";
4+
import { makeCorsOptions } from "./corsOptions";
5+
6+
describe("makeCorsOptions", () => {
7+
const createTestApp = (
8+
isProduction: boolean,
9+
allowedOrigins: string[] = []
10+
) => {
11+
const app = express();
12+
const corsOptions = makeCorsOptions(isProduction, allowedOrigins);
13+
14+
app.use(cors(corsOptions));
15+
16+
// Simple test endpoint
17+
app.post("/test", (_req, res) => {
18+
res.json({ message: "success" });
19+
});
20+
21+
return app;
22+
};
23+
24+
describe("Non-production mode", () => {
25+
let app: express.Express;
26+
27+
beforeEach(() => {
28+
app = createTestApp(false, ["https://trusted-site.com"]);
29+
});
30+
31+
it("should allow localhost origins", async () => {
32+
const response = await request(app)
33+
.post("/test")
34+
.set("Origin", "http://localhost:3000")
35+
.send({ data: "test" });
36+
37+
expect(response.headers["access-control-allow-origin"]).toBe(
38+
"http://localhost:3000"
39+
);
40+
expect(response.headers["access-control-allow-credentials"]).toBe("true");
41+
expect(response.status).toBe(200);
42+
});
43+
44+
it("should allow localhost with different ports", async () => {
45+
const response = await request(app)
46+
.post("/test")
47+
.set("Origin", "http://localhost:8080")
48+
.send({ data: "test" });
49+
50+
expect(response.headers["access-control-allow-origin"]).toBe(
51+
"http://localhost:8080"
52+
);
53+
expect(response.headers["access-control-allow-credentials"]).toBe("true");
54+
});
55+
56+
it("should allow 127.0.0.1 origins", async () => {
57+
const response = await request(app)
58+
.post("/test")
59+
.set("Origin", "http://127.0.0.1:3000")
60+
.send({ data: "test" });
61+
62+
expect(response.headers["access-control-allow-origin"]).toBe(
63+
"http://127.0.0.1:3000"
64+
);
65+
expect(response.headers["access-control-allow-credentials"]).toBe("true");
66+
});
67+
68+
it("should allow requests with no origin (no CORS headers)", async () => {
69+
const response = await request(app).post("/test").send({ data: "test" });
70+
71+
expect(response.status).toBe(200);
72+
});
73+
74+
it("should allow origins from allowedOrigins array", async () => {
75+
const response = await request(app)
76+
.post("/test")
77+
.set("Origin", "https://trusted-site.com")
78+
.send({ data: "test" });
79+
80+
expect(response.headers["access-control-allow-origin"]).toBe(
81+
"https://trusted-site.com"
82+
);
83+
expect(response.headers["access-control-allow-credentials"]).toBe("true");
84+
});
85+
86+
it("should block non-localhost origins not in allowedOrigins", async () => {
87+
const response = await request(app)
88+
.options("/test")
89+
.set("Origin", "https://malicious-site.com")
90+
.set("Access-Control-Request-Method", "POST");
91+
92+
// CORS blocking is indicated by missing allow-origin header
93+
expect(response.headers["access-control-allow-origin"]).toBeUndefined();
94+
});
95+
});
96+
97+
describe("Production mode", () => {
98+
let app: express.Express;
99+
100+
beforeEach(() => {
101+
app = createTestApp(true, [
102+
"https://production-site.com",
103+
"https://another-prod-site.com",
104+
]);
105+
});
106+
107+
it("should block localhost origins in production", async () => {
108+
const response = await request(app)
109+
.options("/test")
110+
.set("Origin", "http://localhost:3000")
111+
.set("Access-Control-Request-Method", "POST");
112+
113+
// CORS blocking is indicated by missing allow-origin header
114+
expect(response.headers["access-control-allow-origin"]).toBeUndefined();
115+
});
116+
117+
it("should block 127.0.0.1 origins in production", async () => {
118+
const response = await request(app)
119+
.options("/test")
120+
.set("Origin", "http://127.0.0.1:3000")
121+
.set("Access-Control-Request-Method", "POST");
122+
123+
// CORS blocking is indicated by missing allow-origin header
124+
expect(response.headers["access-control-allow-origin"]).toBeUndefined();
125+
});
126+
127+
it("should only allow origins from allowedOrigins array", async () => {
128+
const response = await request(app)
129+
.post("/test")
130+
.set("Origin", "https://production-site.com")
131+
.send({ data: "test" });
132+
133+
expect(response.headers["access-control-allow-origin"]).toBe(
134+
"https://production-site.com"
135+
);
136+
expect(response.headers["access-control-allow-credentials"]).toBe("true");
137+
expect(response.status).toBe(200);
138+
});
139+
140+
it("should block unknown origins in production", async () => {
141+
const response = await request(app)
142+
.options("/test")
143+
.set("Origin", "https://unknown-site.com")
144+
.set("Access-Control-Request-Method", "POST");
145+
146+
// CORS blocking is indicated by missing allow-origin header
147+
expect(response.headers["access-control-allow-origin"]).toBeUndefined();
148+
});
149+
});
150+
151+
describe("CORS preflight requests", () => {
152+
let app: express.Express;
153+
154+
beforeEach(() => {
155+
app = createTestApp(false, ["https://trusted-site.com"]);
156+
});
157+
158+
it("should handle OPTIONS requests for localhost", async () => {
159+
const response = await request(app)
160+
.options("/test")
161+
.set("Origin", "http://localhost:3000")
162+
.set("Access-Control-Request-Method", "POST")
163+
.set("Access-Control-Request-Headers", "Content-Type");
164+
165+
expect(response.headers["access-control-allow-origin"]).toBe(
166+
"http://localhost:3000"
167+
);
168+
expect(response.headers["access-control-allow-methods"]).toMatch(/POST/);
169+
expect(response.headers["access-control-allow-headers"]).toMatch(
170+
/Content-Type/
171+
);
172+
expect(response.headers["access-control-allow-credentials"]).toBe("true");
173+
expect(response.status).toBe(204);
174+
});
175+
176+
it("should handle complex preflight with multiple headers", async () => {
177+
const response = await request(app)
178+
.options("/test")
179+
.set("Origin", "http://localhost:8080")
180+
.set("Access-Control-Request-Method", "POST")
181+
.set(
182+
"Access-Control-Request-Headers",
183+
"Content-Type, Authorization, X-Custom-Header"
184+
);
185+
186+
expect(response.headers["access-control-allow-origin"]).toBe(
187+
"http://localhost:8080"
188+
);
189+
expect(response.headers["access-control-allow-methods"]).toMatch(/POST/);
190+
expect(response.headers["access-control-allow-headers"]).toMatch(
191+
/Content-Type/
192+
);
193+
expect(response.headers["access-control-allow-credentials"]).toBe("true");
194+
expect(response.status).toBe(204);
195+
});
196+
197+
it("should include Vary header for Origin", async () => {
198+
const response = await request(app)
199+
.post("/test")
200+
.set("Origin", "http://localhost:3000")
201+
.send({ data: "test" });
202+
203+
expect(response.headers["vary"]).toMatch(/Origin/i);
204+
});
205+
});
206+
207+
describe("Edge cases", () => {
208+
it("should handle empty allowedOrigins array", async () => {
209+
const app = createTestApp(false, []);
210+
211+
const response = await request(app)
212+
.post("/test")
213+
.set("Origin", "http://localhost:3000")
214+
.send({ data: "test" });
215+
216+
expect(response.headers["access-control-allow-origin"]).toBe(
217+
"http://localhost:3000"
218+
);
219+
expect(response.status).toBe(200);
220+
});
221+
222+
it("should handle multiple allowedOrigins in production", async () => {
223+
const app = createTestApp(true, [
224+
"https://site1.com",
225+
"https://site2.com",
226+
"https://site3.com",
227+
]);
228+
229+
const response1 = await request(app)
230+
.post("/test")
231+
.set("Origin", "https://site2.com")
232+
.send({ data: "test" });
233+
234+
expect(response1.headers["access-control-allow-origin"]).toBe(
235+
"https://site2.com"
236+
);
237+
expect(response1.status).toBe(200);
238+
});
239+
});
240+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { CorsOptions } from "cors";
2+
3+
export const makeCorsOptions = (
4+
isProduction: boolean,
5+
allowedOrigins: string[]
6+
) =>
7+
({
8+
origin: !isProduction
9+
? (origin, callback) => {
10+
// Allow all localhost origins in non-production
11+
if (
12+
!origin ||
13+
origin.includes("localhost") ||
14+
origin.includes("127.0.0.1")
15+
) {
16+
callback(null, true);
17+
} else if (allowedOrigins.includes(origin)) {
18+
callback(null, true);
19+
} else {
20+
callback(new Error("Not allowed by CORS"));
21+
}
22+
}
23+
: allowedOrigins,
24+
// Allow cookies from different origins to be sent to the server.
25+
credentials: true,
26+
} satisfies CorsOptions);

0 commit comments

Comments
 (0)