aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/server/middleware
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/middleware')
-rw-r--r--src/server/middleware/cors.test.ts169
-rw-r--r--src/server/middleware/cors.ts42
-rw-r--r--src/server/middleware/index.ts2
-rw-r--r--src/server/middleware/rate-limiter.ts18
4 files changed, 231 insertions, 0 deletions
diff --git a/src/server/middleware/cors.test.ts b/src/server/middleware/cors.test.ts
new file mode 100644
index 0000000..6f413a5
--- /dev/null
+++ b/src/server/middleware/cors.test.ts
@@ -0,0 +1,169 @@
+import { Hono } from "hono";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { createCorsMiddleware } from "./cors.js";
+
+describe("createCorsMiddleware", () => {
+ const originalEnv = process.env.CORS_ORIGIN;
+
+ afterEach(() => {
+ if (originalEnv === undefined) {
+ delete process.env.CORS_ORIGIN;
+ } else {
+ process.env.CORS_ORIGIN = originalEnv;
+ }
+ });
+
+ describe("when CORS_ORIGIN is not set", () => {
+ beforeEach(() => {
+ delete process.env.CORS_ORIGIN;
+ });
+
+ it("does not add CORS headers", async () => {
+ const app = new Hono();
+ app.use("*", createCorsMiddleware());
+ app.get("/test", (c) => c.json({ ok: true }));
+
+ const res = await app.request("/test", {
+ headers: { Origin: "https://attacker.com" },
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
+ });
+
+ it("does not allow preflight requests", async () => {
+ const app = new Hono();
+ app.use("*", createCorsMiddleware());
+ app.get("/test", (c) => c.json({ ok: true }));
+
+ const res = await app.request("/test", {
+ method: "OPTIONS",
+ headers: {
+ Origin: "https://attacker.com",
+ "Access-Control-Request-Method": "POST",
+ },
+ });
+
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
+ });
+ });
+
+ describe("when CORS_ORIGIN is set to single origin", () => {
+ beforeEach(() => {
+ process.env.CORS_ORIGIN = "https://allowed.example.com";
+ });
+
+ it("allows requests from the configured origin", async () => {
+ const app = new Hono();
+ app.use("*", createCorsMiddleware());
+ app.get("/test", (c) => c.json({ ok: true }));
+
+ const res = await app.request("/test", {
+ headers: { Origin: "https://allowed.example.com" },
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
+ "https://allowed.example.com",
+ );
+ });
+
+ it("does not allow requests from other origins", async () => {
+ const app = new Hono();
+ app.use("*", createCorsMiddleware());
+ app.get("/test", (c) => c.json({ ok: true }));
+
+ const res = await app.request("/test", {
+ headers: { Origin: "https://attacker.com" },
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
+ });
+
+ it("handles preflight requests correctly", async () => {
+ const app = new Hono();
+ app.use("*", createCorsMiddleware());
+ app.post("/test", (c) => c.json({ ok: true }));
+
+ const res = await app.request("/test", {
+ method: "OPTIONS",
+ headers: {
+ Origin: "https://allowed.example.com",
+ "Access-Control-Request-Method": "POST",
+ },
+ });
+
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
+ "https://allowed.example.com",
+ );
+ expect(res.headers.get("Access-Control-Allow-Methods")).toContain("POST");
+ });
+
+ it("exposes rate limit headers", async () => {
+ const app = new Hono();
+ app.use("*", createCorsMiddleware());
+ app.get("/test", (c) => c.json({ ok: true }));
+
+ const res = await app.request("/test", {
+ method: "OPTIONS",
+ headers: {
+ Origin: "https://allowed.example.com",
+ "Access-Control-Request-Method": "GET",
+ },
+ });
+
+ const exposeHeaders = res.headers.get("Access-Control-Expose-Headers");
+ expect(exposeHeaders).toContain("RateLimit-Limit");
+ expect(exposeHeaders).toContain("RateLimit-Remaining");
+ expect(exposeHeaders).toContain("RateLimit-Reset");
+ });
+ });
+
+ describe("when CORS_ORIGIN is set to multiple origins", () => {
+ beforeEach(() => {
+ process.env.CORS_ORIGIN =
+ "https://app.example.com, https://admin.example.com";
+ });
+
+ it("allows requests from first configured origin", async () => {
+ const app = new Hono();
+ app.use("*", createCorsMiddleware());
+ app.get("/test", (c) => c.json({ ok: true }));
+
+ const res = await app.request("/test", {
+ headers: { Origin: "https://app.example.com" },
+ });
+
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
+ "https://app.example.com",
+ );
+ });
+
+ it("allows requests from second configured origin", async () => {
+ const app = new Hono();
+ app.use("*", createCorsMiddleware());
+ app.get("/test", (c) => c.json({ ok: true }));
+
+ const res = await app.request("/test", {
+ headers: { Origin: "https://admin.example.com" },
+ });
+
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
+ "https://admin.example.com",
+ );
+ });
+
+ it("does not allow requests from unlisted origins", async () => {
+ const app = new Hono();
+ app.use("*", createCorsMiddleware());
+ app.get("/test", (c) => c.json({ ok: true }));
+
+ const res = await app.request("/test", {
+ headers: { Origin: "https://other.example.com" },
+ });
+
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
+ });
+ });
+});
diff --git a/src/server/middleware/cors.ts b/src/server/middleware/cors.ts
new file mode 100644
index 0000000..ce097ac
--- /dev/null
+++ b/src/server/middleware/cors.ts
@@ -0,0 +1,42 @@
+import { cors } from "hono/cors";
+
+/**
+ * CORS middleware configuration.
+ * Uses CORS_ORIGIN environment variable to configure allowed origins.
+ * If not set, defaults to same-origin only (no CORS headers).
+ *
+ * Examples:
+ * - CORS_ORIGIN=https://kioku.example.com (single origin)
+ * - CORS_ORIGIN=https://example.com,https://app.example.com (multiple origins)
+ */
+function getAllowedOrigins(): string[] {
+ const origins = process.env.CORS_ORIGIN;
+ if (!origins) {
+ return [];
+ }
+ return origins.split(",").map((o) => o.trim());
+}
+
+export function createCorsMiddleware() {
+ const allowedOrigins = getAllowedOrigins();
+
+ // If no origins configured, don't add CORS headers
+ if (allowedOrigins.length === 0) {
+ return cors({
+ origin: () => "",
+ });
+ }
+
+ return cors({
+ origin: allowedOrigins,
+ allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
+ allowHeaders: ["Content-Type", "Authorization"],
+ exposeHeaders: [
+ "RateLimit-Limit",
+ "RateLimit-Remaining",
+ "RateLimit-Reset",
+ ],
+ maxAge: 86400, // 24 hours
+ credentials: true,
+ });
+}
diff --git a/src/server/middleware/index.ts b/src/server/middleware/index.ts
index e894a42..449e484 100644
--- a/src/server/middleware/index.ts
+++ b/src/server/middleware/index.ts
@@ -1,2 +1,4 @@
export { type AuthUser, authMiddleware, getAuthUser } from "./auth.js";
+export { createCorsMiddleware } from "./cors.js";
export { AppError, Errors, errorHandler } from "./error-handler.js";
+export { loginRateLimiter } from "./rate-limiter.js";
diff --git a/src/server/middleware/rate-limiter.ts b/src/server/middleware/rate-limiter.ts
new file mode 100644
index 0000000..d2bf7d1
--- /dev/null
+++ b/src/server/middleware/rate-limiter.ts
@@ -0,0 +1,18 @@
+import { rateLimiter } from "hono-rate-limiter";
+
+/**
+ * Rate limiter for login endpoint to prevent brute force attacks.
+ * Limits to 5 login attempts per minute per IP address.
+ */
+export const loginRateLimiter = rateLimiter({
+ windowMs: 60 * 1000, // 1 minute
+ limit: 5, // 5 requests per window
+ keyGenerator: (c) =>
+ c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip") ?? "unknown",
+ message: {
+ error: {
+ message: "Too many login attempts, please try again later",
+ code: "RATE_LIMIT_EXCEEDED",
+ },
+ },
+});