-
Notifications
You must be signed in to change notification settings - Fork 0
Partition pre-authentication rate limits by API-key public ID #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,7 +27,7 @@ export interface RateLimitPolicy { | |
| export class AuthenticationRateLimiter { | ||
| private readonly requestsPerMinute: number; | ||
| private readonly burst: number; | ||
| private bucket: TokenBucket; | ||
| private readonly buckets = new Map<string, TokenBucket>(); | ||
|
|
||
| public constructor( | ||
| policy: RateLimitPolicy, | ||
|
|
@@ -44,26 +44,30 @@ export class AuthenticationRateLimiter { | |
| ) { | ||
| throw new Error("Authentication rate-limit policy is invalid"); | ||
| } | ||
| this.bucket = { tokens: this.burst, updatedAt: this.now() }; | ||
| } | ||
|
|
||
| public consume(): RateLimitDecision { | ||
| public consume(publicId: string): RateLimitDecision { | ||
| const now = this.now(); | ||
| const elapsed = Math.max(0, now - this.bucket.updatedAt); | ||
| this.bucket.tokens = Math.min( | ||
| const bucket = this.buckets.get(publicId) ?? { | ||
| tokens: this.burst, | ||
| updatedAt: now, | ||
| }; | ||
| const elapsed = Math.max(0, now - bucket.updatedAt); | ||
| bucket.tokens = Math.min( | ||
| this.burst, | ||
| this.bucket.tokens + (elapsed * this.requestsPerMinute) / 60_000, | ||
| bucket.tokens + (elapsed * this.requestsPerMinute) / 60_000, | ||
| ); | ||
| this.bucket.updatedAt = now; | ||
| if (this.bucket.tokens >= 1) { | ||
| this.bucket.tokens -= 1; | ||
| bucket.updatedAt = now; | ||
| this.buckets.set(publicId, bucket); | ||
| if (bucket.tokens >= 1) { | ||
| bucket.tokens -= 1; | ||
| return { allowed: true, retryAfterSeconds: 0 }; | ||
| } | ||
| return { | ||
| allowed: false, | ||
| retryAfterSeconds: Math.max( | ||
| 1, | ||
| Math.ceil(((1 - this.bucket.tokens) * 60) / this.requestsPerMinute), | ||
| Math.ceil(((1 - bucket.tokens) * 60) / this.requestsPerMinute), | ||
| ), | ||
| }; | ||
| } | ||
|
|
@@ -202,8 +206,10 @@ export function rateLimitAuthentication( | |
| return async (context, next) => { | ||
| const authorization = context.req.header("authorization"); | ||
| const match = /^Bearer ([^\s]+)$/.exec(authorization ?? ""); | ||
| if (match !== null && parseApiKeyToken(match[1] ?? "") !== undefined) { | ||
| const decision = limiter.consume(); | ||
| const parsed = | ||
| match === null ? undefined : parseApiKeyToken(match[1] ?? ""); | ||
| if (parsed !== undefined) { | ||
| const decision = limiter.consume(parsed.publicId); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
An unauthenticated client can generate a fresh syntactically valid 16-character public ID for every request, so every call receives a full new bucket and proceeds through Useful? React with 👍 / 👎. |
||
| if (!decision.allowed) { | ||
| const requestId = context.get("requestId"); | ||
| logger.emit("request_rate_limited", { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When clients rotate valid-format public IDs, each unauthenticated request permanently inserts another entry into this process-wide map. Entries are never removed after their tokens refill or IDs become inactive, allowing remote traffic to grow the heap without bound until the process restarts; add expiry/eviction or a hard capacity rather than retaining every observed ID indefinitely.
Useful? React with 👍 / 👎.