Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 0 additions & 22 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,6 @@ const nextConfig = {
images: {
unoptimized: true,
},
...(globalThis.process?.env?.NEXT_EXPORT !== "true" && {
async headers() {
return [
{
source: "/api/:path*",
headers: [
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Origin", value: "*" },
{
key: "Access-Control-Allow-Methods",
value: "GET,OPTIONS,POST",
},
{
key: "Access-Control-Allow-Headers",
value:
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization",
},
],
},
];
},
}),
};

export default nextConfig;
26 changes: 24 additions & 2 deletions src/app/api/chat/response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,44 @@ describe("api response helpers", () => {
it("should have correct CORS headers for allowed origins", () => {
expect(getCorsHeaders("https://amrabed.com")).toEqual({
"Access-Control-Allow-Origin": "https://amrabed.com",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "GET, OPTIONS, POST",
"Access-Control-Allow-Headers":
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400",
});

expect(
getCorsHeaders("https://amrabed.github.io")["Access-Control-Allow-Origin"],
).toBe("https://amrabed.github.io");

expect(
getCorsHeaders("https://some-project.vercel.app")[
"Access-Control-Allow-Origin"
],
).toBe("https://some-project.vercel.app");

expect(
getCorsHeaders("https://some-project.web.app")[
"Access-Control-Allow-Origin"
],
).toBe("https://some-project.web.app");
});

it("should fallback to amrabed.com for disallowed origins", () => {
expect(
getCorsHeaders("https://evil.com")["Access-Control-Allow-Origin"],
).toBe("https://amrabed.com");

expect(
getCorsHeaders("https://evilvercel.app")["Access-Control-Allow-Origin"],
).toBe("https://amrabed.com");

expect(
getCorsHeaders("https://malicious.github.io")[
"Access-Control-Allow-Origin"
],
).toBe("https://amrabed.com");
});

it("should have correct OPTIONS_RESPONSE configured", async () => {
Expand Down
34 changes: 25 additions & 9 deletions src/app/api/chat/response.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,38 @@
const ALLOWED_ORIGINS = ["https://amrabed.com", "http://localhost:3000"];
const ALLOWED_ORIGINS = [
"https://amrabed.com",
"https://amrabed.github.io",
"http://localhost:3000",
];

const isAllowedOrigin = (origin: string | null) => {
export const isAllowedOrigin = (origin: string | null) => {
if (!origin) return false;
if (ALLOWED_ORIGINS.includes(origin)) return true;
if (origin.endsWith(".vercel.app")) return true;
if (origin.endsWith(".onrender.com")) return true;
if (origin.endsWith(".web.app") || origin.endsWith(".firebaseapp.com"))
return true;
return false;

try {
const { hostname } = new URL(origin);
const isSubdomainOf = (domain: string) =>
hostname === domain || hostname.endsWith(`.${domain}`);

return (
isSubdomainOf("vercel.app") ||
isSubdomainOf("onrender.com") ||
isSubdomainOf("web.app") ||
isSubdomainOf("firebaseapp.com")
);
} catch {
return false;
}
};

export const getCorsHeaders = (origin: string | null) => {
return {
"Access-Control-Allow-Origin": isAllowedOrigin(origin)
? (origin as string)
: "https://amrabed.com",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "GET, OPTIONS, POST",
"Access-Control-Allow-Headers":
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400",
};
};
Expand Down
39 changes: 34 additions & 5 deletions src/app/api/chat/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ vi.mock("./response", () => ({
new Response("options", { headers: { origin: origin || "" } }),
errorResponse: (status: number, message: string, origin: string | null) =>
new Response(JSON.stringify({ error: message, origin }), { status }),
isAllowedOrigin: (origin: string | null) =>
origin === "https://amrabed.com" || origin === "http://localhost:3000",
}));

describe("chat api route", () => {
Expand All @@ -30,10 +32,25 @@ describe("chat api route", () => {
});

describe("POST", () => {
it("should return 403 if origin is not allowed", async () => {
const req = new Request("http://localhost/api/chat", {
method: "POST",
headers: { origin: "https://evil.com" },
});
const res = await POST(req);

expect(res.status).toBe(403);
const json = await res.json();
expect(json.error).toContain("Forbidden");
});

it("should return 429 if rate limited", async () => {
mockIsRateLimited.mockResolvedValue(true);

const req = new Request("http://localhost/api/chat", { method: "POST" });
const req = new Request("http://localhost/api/chat", {
method: "POST",
headers: { origin: "https://amrabed.com" },
});
const res = await POST(req);

expect(res.status).toBe(429);
Expand All @@ -48,7 +65,10 @@ describe("chat api route", () => {
toUIMessageStreamResponse: vi.fn().mockReturnValue(mockStreamResponse),
});

const req = new Request("http://localhost/api/chat", { method: "POST" });
const req = new Request("http://localhost/api/chat", {
method: "POST",
headers: { origin: "https://amrabed.com" },
});
const res = await POST(req);

expect(await res.text()).toBe("mock stream");
Expand All @@ -58,7 +78,10 @@ describe("chat api route", () => {
mockIsRateLimited.mockResolvedValue(false);
mockSendRequest.mockRejectedValue(new Error("Message is too long."));

const req = new Request("http://localhost/api/chat", { method: "POST" });
const req = new Request("http://localhost/api/chat", {
method: "POST",
headers: { origin: "https://amrabed.com" },
});
const res = await POST(req);

expect(res.status).toBe(400);
Expand All @@ -74,7 +97,10 @@ describe("chat api route", () => {
.spyOn(console, "error")
.mockImplementation(() => {});

const req = new Request("http://localhost/api/chat", { method: "POST" });
const req = new Request("http://localhost/api/chat", {
method: "POST",
headers: { origin: "https://amrabed.com" },
});
const res = await POST(req);

expect(res.status).toBe(500);
Expand All @@ -93,7 +119,10 @@ describe("chat api route", () => {
.spyOn(console, "error")
.mockImplementation(() => {});

const req = new Request("http://localhost/api/chat", { method: "POST" });
const req = new Request("http://localhost/api/chat", {
method: "POST",
headers: { origin: "https://amrabed.com" },
});
const res = await POST(req);

expect(res.status).toBe(500);
Expand Down
12 changes: 11 additions & 1 deletion src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import isRateLimited from "./ratelimit";
import sendRequest from "./request";
import { getCorsHeaders, optionsResponse, errorResponse } from "./response";
import {
getCorsHeaders,
optionsResponse,
errorResponse,
isAllowedOrigin,
} from "./response";

export async function OPTIONS(request: Request) {
return optionsResponse(request.headers.get("origin"));
}

export async function POST(request: Request) {
const origin = request.headers.get("origin");

if (!isAllowedOrigin(origin)) {
return errorResponse(403, "Forbidden: Invalid origin", origin);
}

if (await isRateLimited(request)) {
return errorResponse(
429,
Expand Down
Loading