diff --git a/src/http.ts b/src/http.ts index c325521..a7fd3c8 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,5 +1,5 @@ export class HttpError extends Error { - constructor(readonly status: number, message: string) { + constructor(readonly status: number, message: string, readonly headers: HeadersInit = {}) { super(message); } } diff --git a/src/index.ts b/src/index.ts index 5ff214f..f49da9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,7 +100,7 @@ export default { ctx.waitUntil(processOutgoingDeliveries(env)); return response; } catch (error) { - if (error instanceof HttpError) return json({ error: error.message }, error.status); + if (error instanceof HttpError) return json({ error: error.message }, error.status, error.headers); console.error("unhandled", error); return json({ error: "internal_server_error" }, 500); } diff --git a/src/mastodon.ts b/src/mastodon.ts index 7fc8e3f..d4d6eae 100644 --- a/src/mastodon.ts +++ b/src/mastodon.ts @@ -105,6 +105,10 @@ const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 90; const MAX_STATUS_CHARS = 5000; const REPORTED_MEDIA_ATTACHMENTS_LIMIT = 9999; const MAX_MEDIA_BYTES = 10 * 1024 * 1024; +const AUTH_RATE_LIMIT_WINDOW_SECONDS = 15 * 60; +const AUTH_RATE_LIMIT_LOCK_SECONDS = 15 * 60; +const AUTH_RATE_LIMIT_MAX_IP_FAILURES = 20; +const AUTH_RATE_LIMIT_MAX_USERNAME_FAILURES = 8; const SUPPORTED_MIME = ["image/jpeg", "image/png", "image/gif", "image/webp"]; const VALID_STATUS_VISIBILITIES = new Set(["public", "unlisted", "private", "direct"]); @@ -279,10 +283,14 @@ export async function authorize(request: Request, env: Env): Promise { const redirectUri = selectRedirectUri(app, bodyString(body, "redirect_uri")); if (!redirectUri) return json({ error: "invalid_request" }, 400); - const user = await getUserByUsername(env, bodyString(body, "username")); + const username = bodyString(body, "username"); + await assertAuthNotRateLimited(request, env, username); + const user = await getUserByUsername(env, username); if (!user || !(await verifyPassword(bodyString(body, "password"), user.password_hash))) { + await recordAuthFailure(request, env, username); return html("Invalid username or password", 401); } + await clearAuthFailures(request, env, username); const code = tokenString(32); const scope = bodyString(body, "scope", app.scopes); @@ -308,8 +316,14 @@ export async function token(request: Request, env: Env): Promise { let scopes = app.scopes; if (grantType === "password") { - const user = await getUserByUsername(env, bodyString(body, "username")); - if (!user || !(await verifyPassword(bodyString(body, "password"), user.password_hash))) return json({ error: "invalid_grant" }, 400); + const username = bodyString(body, "username"); + await assertAuthNotRateLimited(request, env, username); + const user = await getUserByUsername(env, username); + if (!user || !(await verifyPassword(bodyString(body, "password"), user.password_hash))) { + await recordAuthFailure(request, env, username); + return json({ error: "invalid_grant" }, 400); + } + await clearAuthFailures(request, env, username); userId = user.id; scopes = bodyString(body, "scope", app.scopes); } else if (grantType === "client_credentials") { @@ -341,6 +355,51 @@ export async function revoke(request: Request, env: Env): Promise { return json({}); } +type AuthFailureRecord = { + count: number; + firstFailureAt: number; + lockedUntil?: number; +}; + +async function assertAuthNotRateLimited(request: Request, env: Env, username: string): Promise { + const keys = authRateLimitKeys(request, username); + const now = Math.floor(Date.now() / 1000); + const records = await Promise.all(keys.map(({ key }) => env.KV.get(key, "json"))); + const lockedUntil = records.reduce((latest, record) => Math.max(latest, record?.lockedUntil ?? 0), 0); + if (lockedUntil > now) { + const retryAfter = String(Math.max(1, lockedUntil - now)); + throw new HttpError(429, "rate_limited", { "retry-after": retryAfter }); + } +} + +async function recordAuthFailure(request: Request, env: Env, username: string): Promise { + const now = Math.floor(Date.now() / 1000); + await Promise.all(authRateLimitKeys(request, username).map(async ({ key, limit }) => { + const existing = await env.KV.get(key, "json"); + const inWindow = existing && now - existing.firstFailureAt < AUTH_RATE_LIMIT_WINDOW_SECONDS; + const next: AuthFailureRecord = { + count: inWindow ? existing.count + 1 : 1, + firstFailureAt: inWindow ? existing.firstFailureAt : now + }; + if (next.count >= limit) next.lockedUntil = now + AUTH_RATE_LIMIT_LOCK_SECONDS; + const ttl = Math.max(AUTH_RATE_LIMIT_WINDOW_SECONDS, AUTH_RATE_LIMIT_LOCK_SECONDS); + await env.KV.put(key, JSON.stringify(next), { expirationTtl: ttl }); + })); +} + +async function clearAuthFailures(request: Request, env: Env, username: string): Promise { + await Promise.all(authRateLimitKeys(request, username).map(({ key }) => env.KV.delete(key))); +} + +function authRateLimitKeys(request: Request, username: string): { key: string; limit: number }[] { + const ip = request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const normalizedUsername = username.trim().toLowerCase() || "empty"; + return [ + { key: `auth_fail:ip:${ip}`, limit: AUTH_RATE_LIMIT_MAX_IP_FAILURES }, + { key: `auth_fail:user:${normalizedUsername}`, limit: AUTH_RATE_LIMIT_MAX_USERNAME_FAILURES } + ]; +} + export async function verifyCredentials(request: Request, env: Env): Promise { const user = await requireUser(request, env); const account = await accountJson(env, user) as Record;