Skip to content

Commit 49712de

Browse files
committed
キー生成は検証済みのセッションを使おう
1 parent 7a7a065 commit 49712de

2 files changed

Lines changed: 49 additions & 9 deletions

File tree

app/server/lib/rate-limit.test.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { env } from "cloudflare:test";
44
import type { HonoContext } from "../../types/env";
55
import { rateLimiter } from "./rate-limit";
66
import { errorHandler } from "./errors";
7+
import { createSession } from "./session";
78

89
describe("rateLimiter middleware", () => {
910
const errorSpy = vi.spyOn(console, "error");
@@ -57,7 +58,8 @@ describe("rateLimiter middleware", () => {
5758
expect(errorSpy).toHaveBeenCalled();
5859
});
5960

60-
it("should prefer a session identifier over an IP address", async () => {
61+
it("should prefer a validated session's user id over an IP address", async () => {
62+
const sessionId = await createSession(env.KV, "user-123");
6163
const limit = vi.fn<RateLimit["limit"]>().mockResolvedValue({ success: true });
6264
const app = new Hono<HonoContext>();
6365
app.use(
@@ -74,13 +76,40 @@ describe("rateLimiter middleware", () => {
7476
"/limited",
7577
{
7678
headers: {
77-
Cookie: "session_id=test-session",
79+
Cookie: `session_id=${sessionId}`,
7880
"CF-Connecting-IP": "1.1.1.1",
7981
},
8082
},
8183
{ ...env, API_RATE_LIMITER: { limit } },
8284
);
8385

84-
expect(limit).toHaveBeenCalledWith({ key: "session:test-session" });
86+
expect(limit).toHaveBeenCalledWith({ key: "user:user-123" });
87+
});
88+
89+
it("should fall back to an IP address when the session cookie is forged", async () => {
90+
const limit = vi.fn<RateLimit["limit"]>().mockResolvedValue({ success: true });
91+
const app = new Hono<HonoContext>();
92+
app.use(
93+
"*",
94+
rateLimiter({
95+
binding: "API_RATE_LIMITER",
96+
limit: 60,
97+
period: 60,
98+
}),
99+
);
100+
app.get("/limited", (c) => c.json({ ok: true }));
101+
102+
await app.request(
103+
"/limited",
104+
{
105+
headers: {
106+
Cookie: "session_id=attacker-controlled-value",
107+
"CF-Connecting-IP": "1.1.1.1",
108+
},
109+
},
110+
{ ...env, API_RATE_LIMITER: { limit } },
111+
);
112+
113+
expect(limit).toHaveBeenCalledWith({ key: "ip:1.1.1.1" });
85114
});
86115
});

app/server/lib/rate-limit.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Context, MiddlewareHandler, Next } from "hono";
22
import { getCookie } from "hono/cookie";
33
import type { HonoContext } from "../../types/env";
44
import { RateLimitError } from "./errors";
5+
import { validateSession } from "./session";
56

67
interface RateLimitConfig {
78
/** Wranglerで設定したRate Limiting binding名 */
@@ -10,8 +11,8 @@ interface RateLimitConfig {
1011
limit: number;
1112
/** ウィンドウサイズ(秒) */
1213
period: 10 | 60;
13-
/** キー生成関数(デフォルト: セッションID、未認証時はIPアドレス) */
14-
keyGenerator?: (c: Context<HonoContext>) => string;
14+
/** キー生成関数(デフォルト: 検証済みユーザーID、未認証時はIPアドレス) */
15+
keyGenerator?: (c: Context<HonoContext>) => string | Promise<string>;
1516
}
1617

1718
/**
@@ -22,7 +23,8 @@ export function rateLimiter(config: RateLimitConfig): MiddlewareHandler<HonoCont
2223

2324
return async (c: Context<HonoContext>, next: Next) => {
2425
const rateLimit = c.env[binding];
25-
const { success } = await rateLimit.limit({ key: keyGenerator(c) });
26+
const key = await keyGenerator(c);
27+
const { success } = await rateLimit.limit({ key });
2628

2729
c.header("X-RateLimit-Limit", String(limit));
2830

@@ -37,11 +39,20 @@ export function rateLimiter(config: RateLimitConfig): MiddlewareHandler<HonoCont
3739

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

4758
const ip =
@@ -56,7 +67,7 @@ function defaultKeyGenerator(c: Context<HonoContext>): string {
5667
/**
5768
* 認証済みユーザーのキー生成関数
5869
*/
59-
function authenticatedUserKeyGenerator(c: Context<HonoContext>): string {
70+
async function authenticatedUserKeyGenerator(c: Context<HonoContext>): Promise<string> {
6071
const userId = c.get("userId");
6172
return userId ? `user:${userId}` : defaultKeyGenerator(c);
6273
}

0 commit comments

Comments
 (0)