aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-30 22:08:47 +0900
committernsfisis <nsfisis@gmail.com>2025-12-30 22:08:47 +0900
commitc2eb7513834eeb5adfa53fff897f585de87e4821 (patch)
tree9e914051ca67e2f9e1fa301119bdec398ec9e55f
parentb839cae49efd4b9d35c2868a4137101a4d71bd7f (diff)
downloadkioku-c2eb7513834eeb5adfa53fff897f585de87e4821.tar.gz
kioku-c2eb7513834eeb5adfa53fff897f585de87e4821.tar.zst
kioku-c2eb7513834eeb5adfa53fff897f585de87e4821.zip
feat(security): add rate limiting and CORS middleware
- Add rate limiting to login endpoint (5 requests/minute per IP) - Configure CORS middleware with environment-based origin control - Expose rate limit headers in CORS for client visibility - Update hono to 4.11.3 for rate limiter peer dependency 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--docs/dev/roadmap.md4
-rw-r--r--package.json3
-rw-r--r--pnpm-lock.yaml42
-rw-r--r--src/server/index.ts3
-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
-rw-r--r--src/server/routes/auth.test.ts50
-rw-r--r--src/server/routes/auth.ts116
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);