diff options
| -rw-r--r-- | docs/dev/roadmap.md | 4 | ||||
| -rw-r--r-- | package.json | 3 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 42 | ||||
| -rw-r--r-- | src/server/index.ts | 3 | ||||
| -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 | ||||
| -rw-r--r-- | src/server/routes/auth.test.ts | 50 | ||||
| -rw-r--r-- | src/server/routes/auth.ts | 116 |
10 files changed, 379 insertions, 70 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 38ef3be..d877d78 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -193,8 +193,8 @@ Smaller features first to enable early MVP validation. **Goal**: Address security vulnerabilities identified in code review ### High Priority -- [ ] Add rate limiting to login endpoint (brute force protection) -- [ ] Configure CORS middleware +- [x] Add rate limiting to login endpoint (brute force protection) +- [x] Configure CORS middleware ### Medium Priority - [ ] Fix card update authorization in sync push (verify existing card ownership) diff --git a/package.json b/package.json index 43b3b28..f5719e6 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "dexie": "^4.2.1", "drizzle-kit": "^0.31.8", "drizzle-orm": "^0.45.0", - "hono": "^4.10.7", + "hono": "^4.11.3", + "hono-rate-limiter": "^0.5.3", "pg": "^8.16.3", "react": "^19.2.1", "react-dom": "^19.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b97aae..b28a5da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,10 +19,10 @@ importers: version: 3.1.1(@fortawesome/fontawesome-svg-core@7.1.0)(react@19.2.1) '@hono/node-server': specifier: ^1.19.6 - version: 1.19.6(hono@4.10.7) + version: 1.19.6(hono@4.11.3) '@hono/zod-validator': specifier: ^0.7.5 - version: 0.7.5(hono@4.10.7)(zod@4.1.13) + version: 0.7.5(hono@4.11.3)(zod@4.1.13) argon2: specifier: ^0.44.0 version: 0.44.0 @@ -36,8 +36,11 @@ importers: specifier: ^0.45.0 version: 0.45.0(@types/pg@8.15.6)(pg@8.16.3) hono: - specifier: ^4.10.7 - version: 4.10.7 + specifier: ^4.11.3 + version: 4.11.3 + hono-rate-limiter: + specifier: ^0.5.3 + version: 0.5.3(hono@4.11.3) pg: specifier: ^8.16.3 version: 8.16.3 @@ -2156,8 +2159,17 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hono@4.10.7: - resolution: {integrity: sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw==} + hono-rate-limiter@0.5.3: + resolution: {integrity: sha512-M0DxbVMpPELEzLi0AJg1XyBHLGJXz7GySjsPoK+gc5YeeBsdGDGe+2RvVuCAv8ydINiwlbxqYMNxUEyYfRji/A==} + peerDependencies: + hono: ^4.10.8 + unstorage: ^1.17.3 + peerDependenciesMeta: + unstorage: + optional: true + + hono@4.11.3: + resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==} engines: {node: '>=16.9.0'} html-encoding-sniffer@4.0.0: @@ -4206,18 +4218,18 @@ snapshots: '@hono/cli@0.1.3': dependencies: - '@hono/node-server': 1.19.6(hono@4.10.7) + '@hono/node-server': 1.19.6(hono@4.11.3) commander: 14.0.2 esbuild: 0.25.12 - hono: 4.10.7 + hono: 4.11.3 - '@hono/node-server@1.19.6(hono@4.10.7)': + '@hono/node-server@1.19.6(hono@4.11.3)': dependencies: - hono: 4.10.7 + hono: 4.11.3 - '@hono/zod-validator@0.7.5(hono@4.10.7)(zod@4.1.13)': + '@hono/zod-validator@0.7.5(hono@4.11.3)(zod@4.1.13)': dependencies: - hono: 4.10.7 + hono: 4.11.3 zod: 4.1.13 '@isaacs/balanced-match@4.0.1': {} @@ -5154,7 +5166,11 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.10.7: {} + hono-rate-limiter@0.5.3(hono@4.11.3): + dependencies: + hono: 4.11.3 + + hono@4.11.3: {} html-encoding-sniffer@4.0.0: dependencies: diff --git a/src/server/index.ts b/src/server/index.ts index a2a3a77..ad7f48a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,12 +1,13 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { logger } from "hono/logger"; -import { errorHandler } from "./middleware/index.js"; +import { createCorsMiddleware, errorHandler } from "./middleware/index.js"; import { auth, cards, decks, study, sync } from "./routes/index.js"; const app = new Hono(); app.use("*", logger()); +app.use("/api/*", createCorsMiddleware()); app.onError(errorHandler); // Chain routes for RPC type inference 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", + }, + }, +}); diff --git a/src/server/routes/auth.test.ts b/src/server/routes/auth.test.ts index 5bf9f86..c3b0158 100644 --- a/src/server/routes/auth.test.ts +++ b/src/server/routes/auth.test.ts @@ -62,6 +62,56 @@ describe("POST /login", () => { app.route("/api/auth", auth); }); + it("returns rate limit headers on login request", async () => { + vi.mocked(mockUserRepo.findByUsername).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Forwarded-For": "192.168.1.1", + }, + body: JSON.stringify({ + username: "testuser", + password: "somepassword", + }), + }); + + expect(res.headers.get("RateLimit-Limit")).toBe("5"); + expect(res.headers.get("RateLimit-Remaining")).toBeDefined(); + }); + + it("returns 429 after exceeding rate limit", async () => { + vi.mocked(mockUserRepo.findByUsername).mockResolvedValue(undefined); + + const makeRequest = () => + app.request("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Forwarded-For": "10.0.0.1", + }, + body: JSON.stringify({ + username: "testuser", + password: "wrongpassword", + }), + }); + + // Make 5 requests (the limit) + for (let i = 0; i < 5; i++) { + const res = await makeRequest(); + expect(res.status).toBe(401); + } + + // 6th request should be rate limited + const rateLimitedRes = await makeRequest(); + expect(rateLimitedRes.status).toBe(429); + const body = (await rateLimitedRes.json()) as { + error?: { code: string; message: string }; + }; + expect(body.error?.code).toBe("RATE_LIMIT_EXCEEDED"); + }); + it("returns access token for valid credentials", async () => { vi.mocked(mockUserRepo.findByUsername).mockResolvedValue({ id: "user-uuid-123", diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index 06c88a6..73deb83 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -3,7 +3,7 @@ import { zValidator } from "@hono/zod-validator"; import * as argon2 from "argon2"; import { Hono } from "hono"; import { sign } from "hono/jwt"; -import { Errors } from "../middleware/index.js"; +import { Errors, loginRateLimiter } from "../middleware/index.js"; import { type RefreshTokenRepository, refreshTokenRepository, @@ -39,63 +39,73 @@ export function createAuthRouter(deps: AuthDependencies) { const { userRepo, refreshTokenRepo } = deps; return new Hono() - .post("/login", zValidator("json", loginSchema), async (c) => { - const { username, password } = c.req.valid("json"); - - // Find user by username - const user = await userRepo.findByUsername(username); - - if (!user) { - throw Errors.unauthorized( - "Invalid username or password", - "INVALID_CREDENTIALS", + .post( + "/login", + loginRateLimiter, + zValidator("json", loginSchema), + async (c) => { + const { username, password } = c.req.valid("json"); + + // Find user by username + const user = await userRepo.findByUsername(username); + + if (!user) { + throw Errors.unauthorized( + "Invalid username or password", + "INVALID_CREDENTIALS", + ); + } + + // Verify password + const isPasswordValid = await argon2.verify( + user.passwordHash, + password, ); - } - - // Verify password - const isPasswordValid = await argon2.verify(user.passwordHash, password); - if (!isPasswordValid) { - throw Errors.unauthorized( - "Invalid username or password", - "INVALID_CREDENTIALS", + if (!isPasswordValid) { + throw Errors.unauthorized( + "Invalid username or password", + "INVALID_CREDENTIALS", + ); + } + + // Generate JWT access token + const now = Math.floor(Date.now() / 1000); + const accessToken = await sign( + { + sub: user.id, + iat: now, + exp: now + ACCESS_TOKEN_EXPIRES_IN, + }, + getJwtSecret(), ); - } - - // Generate JWT access token - const now = Math.floor(Date.now() / 1000); - const accessToken = await sign( - { - sub: user.id, - iat: now, - exp: now + ACCESS_TOKEN_EXPIRES_IN, - }, - getJwtSecret(), - ); - // Generate refresh token - const refreshToken = generateRefreshToken(); - const tokenHash = hashToken(refreshToken); - const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); - - // Store refresh token in database - await refreshTokenRepo.create({ - userId: user.id, - tokenHash, - expiresAt, - }); + // Generate refresh token + const refreshToken = generateRefreshToken(); + const tokenHash = hashToken(refreshToken); + const expiresAt = new Date( + Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000, + ); - return c.json( - { - accessToken, - refreshToken, - user: { - id: user.id, - username: user.username, + // Store refresh token in database + await refreshTokenRepo.create({ + userId: user.id, + tokenHash, + expiresAt, + }); + + return c.json( + { + accessToken, + refreshToken, + user: { + id: user.id, + username: user.username, + }, }, - }, - 200, - ); - }) + 200, + ); + }, + ) .post("/refresh", zValidator("json", refreshTokenSchema), async (c) => { const { refreshToken } = c.req.valid("json"); const tokenHash = hashToken(refreshToken); |
