server: indicate Retry-After when rate limiting

This refactors the rate limiting code to throw an ApiError directly.

Changelog: Added
This commit is contained in:
Johann150 2023-01-26 08:25:19 +01:00
parent 9b76c805ec
commit 57cf6c7163
Signed by untrusted user: Johann150
GPG key ID: 9EE6577A2A06F8F1
4 changed files with 27 additions and 23 deletions

View file

@ -35,10 +35,8 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
limit.key = ep.name; limit.key = ep.name;
} }
// Rate limit // Rate limit, may throw an ApiError
await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(() => { await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor);
throw new ApiError('RATE_LIMIT_EXCEEDED');
});
} }
if (ep.meta.requireCredential && user == null) { if (ep.meta.requireCredential && user == null) {

View file

@ -29,8 +29,16 @@ export class ApiError extends Error {
*/ */
public apply(ctx: Koa.Context, endpoint: string): void { public apply(ctx: Koa.Context, endpoint: string): void {
ctx.status = this.httpStatusCode; ctx.status = this.httpStatusCode;
if (ctx.status === 401) { // set additional headers
ctx.response.set('WWW-Authenticate', 'Bearer'); switch (ctx.status) {
case 401:
ctx.response.set('WWW-Authenticate', 'Bearer');
break;
case 429:
if (typeof this.info === 'object' && typeof this.info.reset === 'number') {
ctx.respose.set('Retry-After', Math.floor(this.info.reset - (Date.now() / 1000)));
}
break;
} }
ctx.body = { ctx.body = {
error: { error: {

View file

@ -2,11 +2,12 @@ import Limiter from 'ratelimiter';
import Logger from '@/services/logger.js'; import Logger from '@/services/logger.js';
import { redisClient } from '@/db/redis.js'; import { redisClient } from '@/db/redis.js';
import { IEndpointMeta } from './endpoints.js'; import { IEndpointMeta } from './endpoints.js';
import { ApiError } from './error.js';
const logger = new Logger('limiter'); const logger = new Logger('limiter');
export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((ok, reject) => { export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((resolve) => {
if (process.env.NODE_ENV === 'test') ok(); if (process.env.NODE_ENV === 'test') resolve();
const hasShortTermLimit = typeof limitation.minInterval === 'number'; const hasShortTermLimit = typeof limitation.minInterval === 'number';
@ -19,10 +20,10 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<
} else if (hasLongTermLimit) { } else if (hasLongTermLimit) {
max(); max();
} else { } else {
ok(); resolve();
} }
// Short-term limit // Short-term limit, calls long term limit if appropriate.
function min(): void { function min(): void {
const minIntervalLimiter = new Limiter({ const minIntervalLimiter = new Limiter({
id: `${actor}:${limitation.key}:min`, id: `${actor}:${limitation.key}:min`,
@ -33,18 +34,19 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<
minIntervalLimiter.get((err, info) => { minIntervalLimiter.get((err, info) => {
if (err) { if (err) {
return reject('ERR'); logger.error(err);
throw new ApiError('INTERNAL_ERROR');
} }
logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
if (info.remaining === 0) { if (info.remaining === 0) {
reject('BRIEF_REQUEST_INTERVAL'); throw new ApiError('RATE_LIMIT_EXCEEDED', info);
} else { } else {
if (hasLongTermLimit) { if (hasLongTermLimit) {
max(); max();
} else { } else {
ok(); resolve();
} }
} }
}); });
@ -61,15 +63,16 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<
limiter.get((err, info) => { limiter.get((err, info) => {
if (err) { if (err) {
return reject('ERR'); logger.error(err);
throw new ApiError('INTERNAL_ERROR');
} }
logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`); logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
if (info.remaining === 0) { if (info.remaining === 0) {
reject('RATE_LIMIT_EXCEEDED'); throw new ApiError('RATE_LIMIT_EXCEEDED', info);
} else { } else {
ok(); resolve();
} }
}); });
} }

View file

@ -25,13 +25,8 @@ export default async (ctx: Koa.Context) => {
new ApiError(e, info).apply(ctx, 'signin'); new ApiError(e, info).apply(ctx, 'signin');
} }
try { // not more than 1 attempt per second and not more than 10 attempts per hour
// not more than 1 attempt per second and not more than 10 attempts per hour await limiter({ key: 'signin', duration: HOUR, max: 10, minInterval: SECOND }, getIpHash(ctx.ip));
await limiter({ key: 'signin', duration: HOUR, max: 10, minInterval: SECOND }, getIpHash(ctx.ip));
} catch (err) {
error('RATE_LIMIT_EXCEEDED');
return;
}
if (typeof username !== 'string') { if (typeof username !== 'string') {
error('INVALID_PARAM', { param: 'username', reason: 'not a string' }); error('INVALID_PARAM', { param: 'username', reason: 'not a string' });