Files
triggerdotdev--trigger.dev/apps/webapp/app/services/authCodeRateLimiter.server.ts
T
Chris Arderne 6997aeb05e
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
fix: security release 2026-07-08 (#4316)
2026-07-21 12:00:58 +01:00

86 lines
3.0 KiB
TypeScript

import { Ratelimit } from "@upstash/ratelimit";
import { createHash } from "node:crypto";
import { env } from "~/env.server";
import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server";
import { singleton } from "~/utils/singleton";
/**
* Rate limiting for the unauthenticated CLI auth-code endpoints
* (`/api/v1/authorization-code` mint + `/api/v1/token` poll). The global
* limiter keys on the Authorization header, which these endpoints don't carry,
* so it can't throttle them — this module does.
*/
export class AuthorizationCodeRateLimitError extends Error {
public readonly retryAfter: number;
constructor(retryAfter: number) {
super("Authorization code rate limit exceeded.");
this.name = "AuthorizationCodeRateLimitError";
this.retryAfter = retryAfter;
}
}
function getRedisClient() {
return createRedisRateLimitClient({
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
});
}
// Minting is unauthenticated and a real login mints one code. Cap per IP, with
// headroom for many users behind a shared NAT.
const authorizationCodeMintIpRateLimiter = singleton(
"authorizationCodeMintIpRateLimiter",
() =>
new RateLimiter({
redisClient: getRedisClient(),
keyPrefix: "auth:authcode:mint:ip",
limiter: Ratelimit.slidingWindow(30, "1 m"), // 30 code mints / min / IP
logSuccess: false,
logFailure: true,
})
);
// Keyed by the code, not the IP: the CLI polls this endpoint ~1/s per login, so
// IP-keying would break logins behind a shared NAT. The ~60/min cadence stays
// under the cap. The code is hashed first so it never lands in a Redis key or log.
const authorizationCodeTokenPollRateLimiter = singleton(
"authorizationCodeTokenPollRateLimiter",
() =>
new RateLimiter({
redisClient: getRedisClient(),
keyPrefix: "auth:authcode:token:code",
limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 polls / min / code (CLI polls ~60/min)
logSuccess: false,
logFailure: false,
})
);
function hashCode(code: string): string {
return createHash("sha256").update(code).digest("hex").slice(0, 32);
}
export async function checkAuthorizationCodeMintRateLimit(ip: string): Promise<void> {
const result = await authorizationCodeMintIpRateLimiter.limit(ip);
if (!result.success) {
const retryAfter = new Date(result.reset).getTime() - Date.now();
throw new AuthorizationCodeRateLimitError(retryAfter);
}
}
export async function checkAuthorizationCodeTokenPollRateLimit(
authorizationCode: string
): Promise<void> {
const result = await authorizationCodeTokenPollRateLimiter.limit(hashCode(authorizationCode));
if (!result.success) {
const retryAfter = new Date(result.reset).getTime() - Date.now();
throw new AuthorizationCodeRateLimitError(retryAfter);
}
}