diff options
Diffstat (limited to 'src/server/middleware')
| -rw-r--r-- | src/server/middleware/cors.test.ts | 169 | ||||
| -rw-r--r-- | src/server/middleware/cors.ts | 42 | ||||
| -rw-r--r-- | src/server/middleware/index.ts | 2 | ||||
| -rw-r--r-- | src/server/middleware/rate-limiter.ts | 18 |
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", + }, + }, +}); |
