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
49 changes: 49 additions & 0 deletions app/server/api/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { env } from "cloudflare:test";
import { Hono } from "hono";
import type { HonoContext } from "../../types/env";
import api from ".";

describe("API rate limiting", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it.each([
["/api/books", "198.51.100.11"],
["/api/tags", "198.51.100.12"],
["/api/users/check/rate-limit-test", "198.51.100.13"],
])("applies the general API policy to %s", async (path, ip) => {
const app = new Hono<HonoContext>();
app.route("/api", api);

const response = await app.request(path, { headers: { "CF-Connecting-IP": ip } }, env);

expect(response.headers.get("X-RateLimit-Limit")).toBe("60");
});

it("keeps health checks outside the general API policy", async () => {
const app = new Hono<HonoContext>();
app.route("/api", api);

const response = await app.request("/api/health", {}, env);

expect(response.status).toBe(200);
expect(response.headers.get("X-RateLimit-Limit")).toBeNull();
});

it("keeps the dedicated auth policy", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const app = new Hono<HonoContext>();
app.route("/api", api);

const response = await app.request(
"/api/auth/session",
{ headers: { "CF-Connecting-IP": "203.0.113.101" } },
env,
);

expect(response.status).toBe(401);
expect(response.headers.get("X-RateLimit-Limit")).toBe("10");
});
});
6 changes: 6 additions & 0 deletions app/server/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Hono } from "hono";
import type { HonoContext } from "../../types/env";
import { errorHandler } from "../lib/errors";
import { apiRateLimiter } from "../lib/rate-limit";
import books from "./books";
import tags from "./tags";
import users from "./users";
Expand All @@ -12,6 +13,11 @@ const app = new Hono<HonoContext>();
// エラーハンドリング
app.onError(errorHandler);

// 一般APIにRate Limiting適用
app.use("/books/*", apiRateLimiter);
app.use("/tags/*", apiRateLimiter);
app.use("/users/*", apiRateLimiter);

// ルーティング
app.route("/books", books);
app.route("/tags", tags);
Expand Down
84 changes: 75 additions & 9 deletions app/server/lib/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { env } from "cloudflare:test";
import type { HonoContext } from "../../types/env";
import { rateLimiter } from "./rate-limit";
import { errorHandler } from "./errors";
import { createSession } from "./session";

describe("rateLimiter middleware", () => {
const errorSpy = vi.spyOn(console, "error");
Expand All @@ -15,35 +17,99 @@ describe("rateLimiter middleware", () => {
errorSpy.mockReset();
});

it("should enforce limits per window", async () => {
const app = new Hono();
it("should enforce the binding outcome", async () => {
const limit = vi
.fn<RateLimit["limit"]>()
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({ success: false });
const app = new Hono<HonoContext>();
app.onError(errorHandler);
app.use(
"*",
rateLimiter({
windowSec: 60,
binding: "API_RATE_LIMITER",
limit: 1,
keyPrefix: "test",
period: 60,
}),
);
app.get("/limited", (c) => c.json({ ok: true }));

const testEnv = {
...env,
API_RATE_LIMITER: { limit },
};

const first = await app.request(
"/limited",
{ headers: { "CF-Connecting-IP": "1.1.1.1" } },
env,
testEnv,
);
expect(first.status).toBe(200);
expect(first.headers.get("X-RateLimit-Limit")).toBe("1");
expect(first.headers.get("X-RateLimit-Remaining")).toBe("0");
expect(limit).toHaveBeenLastCalledWith({ key: "ip:1.1.1.1" });

const second = await app.request(
"/limited",
{ headers: { "CF-Connecting-IP": "1.1.1.1" } },
env,
testEnv,
);
expect(second.status).toBe(429);
expect(second.headers.get("Retry-After")).toBeTruthy();
expect(second.headers.get("Retry-After")).toBe("60");
expect(errorSpy).toHaveBeenCalled();
});

it("should prefer a validated session's user id over an IP address", async () => {
const sessionId = await createSession(env.KV, "user-123");
const limit = vi.fn<RateLimit["limit"]>().mockResolvedValue({ success: true });
const app = new Hono<HonoContext>();
app.use(
"*",
rateLimiter({
binding: "API_RATE_LIMITER",
limit: 60,
period: 60,
}),
);
app.get("/limited", (c) => c.json({ ok: true }));

await app.request(
"/limited",
{
headers: {
Cookie: `session_id=${sessionId}`,
"CF-Connecting-IP": "1.1.1.1",
},
},
{ ...env, API_RATE_LIMITER: { limit } },
);

expect(limit).toHaveBeenCalledWith({ key: "user:user-123" });
});

it("should fall back to an IP address when the session cookie is forged", async () => {
const limit = vi.fn<RateLimit["limit"]>().mockResolvedValue({ success: true });
const app = new Hono<HonoContext>();
app.use(
"*",
rateLimiter({
binding: "API_RATE_LIMITER",
limit: 60,
period: 60,
}),
);
app.get("/limited", (c) => c.json({ ok: true }));

await app.request(
"/limited",
{
headers: {
Cookie: "session_id=attacker-controlled-value",
"CF-Connecting-IP": "1.1.1.1",
},
},
{ ...env, API_RATE_LIMITER: { limit } },
);

expect(limit).toHaveBeenCalledWith({ key: "ip:1.1.1.1" });
});
});
117 changes: 49 additions & 68 deletions app/server/lib/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -1,120 +1,101 @@
import type { Context, MiddlewareHandler, Next } from "hono";
import { getCookie } from "hono/cookie";
import type { HonoContext } from "../../types/env";
import { RateLimitError } from "./errors";
import { validateSession } from "./session";

interface RateLimitConfig {
/** ウィンドウサイズ(秒) */
windowSec: number;
/** Wranglerで設定したRate Limiting binding名 */
binding: "AUTH_RATE_LIMITER" | "SEARCH_RATE_LIMITER" | "API_RATE_LIMITER";
/** ウィンドウ内の最大リクエスト数 */
limit: number;
/** レート制限のキープレフィックス */
keyPrefix: string;
/** キー生成関数(デフォルト: IPアドレス) */
keyGenerator?: (c: Context<HonoContext>) => string;
}

interface RateLimitEntry {
/** ウィンドウ内のリクエスト数 */
count: number;
/** ウィンドウのリセット時間 */
resetAt: number;
/** ウィンドウサイズ(秒) */
period: 10 | 60;
/** キー生成関数(デフォルト: 検証済みユーザーID、未認証時はIPアドレス) */
keyGenerator?: (c: Context<HonoContext>) => string | Promise<string>;
}

/**
* レートリミット用ミドルウェア
* Cloudflare Rate Limiting binding用ミドルウェア
*/
export function rateLimiter(config: RateLimitConfig): MiddlewareHandler<HonoContext> {
const { windowSec, limit, keyPrefix, keyGenerator = defaultKeyGenerator } = config;
const { binding, limit, period, keyGenerator = defaultKeyGenerator } = config;

return async (c: Context<HonoContext>, next: Next) => {
const kv = c.env.KV;
const identifier = keyGenerator(c);
const key = `ratelimit:${keyPrefix}:${identifier}`;
const now = Math.floor(Date.now() / 1000);

// 現在のレート制限エントリを取得
const entryJson = await kv.get(key);
let entry: RateLimitEntry;

if (entryJson) {
entry = JSON.parse(entryJson);

// ウィンドウがリセットされている場合
if (now >= entry.resetAt) {
entry = {
count: 1,
resetAt: now + windowSec,
};
} else {
entry.count += 1;
}
} else {
entry = {
count: 1,
resetAt: now + windowSec,
};
}

// レート制限ヘッダーを設定
const remaining = Math.max(0, limit - entry.count);
const retryAfter = entry.resetAt - now;
const rateLimit = c.env[binding];
const key = await keyGenerator(c);
const { success } = await rateLimit.limit({ key });

c.header("X-RateLimit-Limit", String(limit));
c.header("X-RateLimit-Remaining", String(remaining));
c.header("X-RateLimit-Reset", String(entry.resetAt));

// 制限を超えている場合
if (entry.count > limit) {
c.header("Retry-After", String(retryAfter));
throw new RateLimitError("Too many requests. Please try again later.", retryAfter);
if (!success) {
c.header("Retry-After", String(period));
throw new RateLimitError("Too many requests. Please try again later.", period);
}

// エントリを更新(TTLをウィンドウサイズに設定)
await kv.put(key, JSON.stringify(entry), {
expirationTtl: windowSec + 1,
});

await next();
};
}

/**
* デフォルトのキー生成関数
*
* session_idクッキーはクライアントが自由に設定できるため、
* 生の値をキーに使うとレート制限を回避できてしまう。
* 必ずKVで検証し、検証済みのユーザーIDのみをキーに採用する。
*/
function defaultKeyGenerator(c: Context<HonoContext>): string {
// CF-Connecting-IP ヘッダーでクライアントIPを取得
async function defaultKeyGenerator(c: Context<HonoContext>): Promise<string> {
const sessionId = getCookie(c, "session_id");
if (sessionId) {
try {
const userId = await validateSession(c.env.KV, sessionId);
return `user:${userId}`;
} catch {
// 無効・期限切れのセッションはIPアドレスにフォールバック
}
}

const ip =
c.req.header("CF-Connecting-IP") ||
c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() ||
c.req.header("X-Real-IP") ||
"unknown";

return ip;
return `ip:${ip}`;
}

/**
* 認証済みユーザーのキー生成関数
*/
async function authenticatedUserKeyGenerator(c: Context<HonoContext>): Promise<string> {
const userId = c.get("userId");
return userId ? `user:${userId}` : defaultKeyGenerator(c);
}

/**
* 認証用のRate Limiter設定(15分間で100リクエストまで)
* 認証用のRate Limiter設定(1分間で10リクエストまで)
*/
export const authRateLimiter = rateLimiter({
windowSec: 15 * 60, // 15分
limit: 100,
keyPrefix: "auth",
binding: "AUTH_RATE_LIMITER",
limit: 10,
period: 60,
});

/**
* 検索API用のRate Limiter設定(1分間で30リクエストまで)
*/
export const searchRateLimiter = rateLimiter({
windowSec: 60, // 1分
binding: "SEARCH_RATE_LIMITER",
limit: 30,
keyPrefix: "search",
period: 60,
keyGenerator: authenticatedUserKeyGenerator,
});

/**
* 一般API用のRate Limiter設定(1分間で60リクエストまで)
*/
export const apiRateLimiter = rateLimiter({
windowSec: 60, // 1分
binding: "API_RATE_LIMITER",
limit: 60,
keyPrefix: "api",
period: 60,
});
6 changes: 6 additions & 0 deletions app/test/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ import type { Env } from "../types/env";

type D1Database = Env["DB"];
type KVNamespace = Env["KV"];
type AuthRateLimiter = Env["AUTH_RATE_LIMITER"];
type SearchRateLimiter = Env["SEARCH_RATE_LIMITER"];
type ApiRateLimiter = Env["API_RATE_LIMITER"];

declare module "cloudflare:test" {
interface ProvidedEnv {
DB: D1Database;
KV: KVNamespace;
AUTH_RATE_LIMITER: AuthRateLimiter;
SEARCH_RATE_LIMITER: SearchRateLimiter;
API_RATE_LIMITER: ApiRateLimiter;
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,6 @@
"typescript": "^5.9.3",
"vite": "^6.3.5",
"vitest": "^3.2.4",
"wrangler": "^4.4.0"
"wrangler": "^4.36.0"
}
}
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading