diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts index a5a054b6b7..1f0bc19a43 100644 --- a/src/auth/rate-limit.ts +++ b/src/auth/rate-limit.ts @@ -1,7 +1,7 @@ import type { Context } from "hono"; import { DurableObject } from "cloudflare:workers"; import { recordAuditEvent } from "../db/repositories"; -import { hashToken } from "./security"; +import { extractBearerToken, hashToken } from "./security"; export type RateLimitClass = "strict" | "normal" | "expensive"; @@ -116,7 +116,7 @@ export function routeClassForPath(path: string): RateLimitClass { } async function rateLimitKey(c: Context<{ Bindings: Env }>, routeClass: RateLimitClass): Promise { - const token = c.req.header("authorization")?.replace(/^Bearer\s+/i, ""); + const token = extractBearerToken(c.req.header("authorization")); const ip = c.req.header("cf-connecting-ip") ?? c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown-ip"; const pathGroup = c.req.path.replace(/\/\d+(?=\/|$)/g, "/:number").replace(/\/[^/]+\/[^/]+\/pulls\//, "/:owner/:repo/pulls/"); const identity = token ? `token:${await hashToken(token)}` : `ip:${await hashToken(ip)}`; @@ -124,7 +124,7 @@ async function rateLimitKey(c: Context<{ Bindings: Env }>, routeClass: RateLimit } async function actorHint(c: Context<{ Bindings: Env }>): Promise { - const token = c.req.header("authorization")?.replace(/^Bearer\s+/i, ""); + const token = extractBearerToken(c.req.header("authorization")); if (!token) return "anonymous"; return `token:${(await hashToken(token)).slice(0, 16)}`; } diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index a0be2c612b..7f1d4a2dcf 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { completeGitHubWebOAuth, createSessionFromGitHubToken, pollGitHubDeviceFlow, startGitHubDeviceFlow, startGitHubWebOAuth } from "../../src/auth/github-oauth"; import { enforceRateLimit, RateLimiter, routeClassForPath } from "../../src/auth/rate-limit"; -import { authenticatePrivateToken, buildBrowserSessionCookie, createSessionForGitHubUser, extractCookieValue, isAuthorizedGitHubSessionLogin, revokeSession, timingSafeEqual } from "../../src/auth/security"; +import { authenticatePrivateToken, buildBrowserSessionCookie, createSessionForGitHubUser, extractBearerToken, extractCookieValue, hashToken, isAuthorizedGitHubSessionLogin, revokeSession, timingSafeEqual } from "../../src/auth/security"; import { createTestEnv } from "../helpers/d1"; describe("private-beta auth and rate limiting", () => { @@ -92,6 +92,13 @@ describe("private-beta auth and rate limiting", () => { expect(routeClassForPath("/v1/repos")).toBe("normal"); }); + it("hashes trimmed bearer tokens for rate-limit identity", async () => { + const padded = extractBearerToken("Bearer session-token "); + const plain = extractBearerToken("Bearer session-token"); + expect(padded).toBe(plain); + expect(await hashToken(padded!)).toEqual(await hashToken(plain!)); + }); + it("enforces route limits with session and IP keys plus retry headers", async () => { const env = createTestEnv(); const noLimiter = fakeContext(env, "/v1/repos/123/pulls/456", { authorization: "Bearer session-token" });