Skip to content

Commit

Permalink
fix: API limiter
Browse files Browse the repository at this point in the history
  • Loading branch information
atsu1125 committed Aug 15, 2024
1 parent c60c983 commit 9975186
Show file tree
Hide file tree
Showing 10 changed files with 110 additions and 38 deletions.
1 change: 1 addition & 0 deletions locales/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,7 @@ themeColor: "Theme Color"
showTickerSoftwareName: "Show Software Name on instance ticker"
preferTickerSoftwareColor: "Prefer Software Color on instance ticker"
typeToConfirm: "Please enter {x} to confirm"
rateLimitExceeded: "Rate limit exceeded"

_template:
edit: "Edit Template..."
Expand Down
1 change: 1 addition & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,7 @@ themeColor: "テーマカラー"
showTickerSoftwareName: "インスタンスティッカーにソフトウェア名を表示する"
preferTickerSoftwareColor: "インスタンスティッカーにソフトウェアカラーを採用して表示する"
typeToConfirm: "この操作を行うには {x} と入力してください"
rateLimitExceeded: "レート制限を超えました"

_template:
edit: "定型文を編集…"
Expand Down
9 changes: 9 additions & 0 deletions src/misc/get-ip-hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as IPCIDR from 'ip-cidr';

export function getIpHash(ip: string) {
// because a single person may control many IPv6 addresses,
// only a /64 subnet prefix of any IP will be taken into account.
// (this means for IPv4 the entire address is used)
const prefix = IPCIDR.createAddress(ip).mask(64);
return 'ip-' + BigInt('0b' + prefix).toString(36);
}
2 changes: 1 addition & 1 deletion src/server/api/api-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res) => {
// Authentication
authenticate(body['i']).then(([user, app]) => {
// API invoking
call(endpoint.name, user, app, body, (ctx as any).file).then((res: any) => {
call(endpoint.name, user, app, body, ctx).then((res: any) => {
reply(res);
}).catch((e: ApiError) => {
reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e);
Expand Down
68 changes: 53 additions & 15 deletions src/server/api/call.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import * as Koa from 'koa';
import { performance } from 'perf_hooks';
import limiter from './limiter';
import { limiter } from './limiter';
import { User } from '../../models/entities/user';
import endpoints from './endpoints';
import endpoints, { IEndpointMeta } from './endpoints';
import { ApiError } from './error';
import { apiLogger } from './logger';
import { AccessToken } from '../../models/entities/access-token';
import { getIpHash } from '../../misc/get-ip-hash';

const accessDenied = {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e'
};

export default async (endpoint: string, user: User | null | undefined, token: AccessToken | null | undefined, data: any, file?: any) => {
export default async (endpoint: string, user: User | null | undefined, token: AccessToken | null | undefined, data: any, ctx?: Koa.Context) => {
const isSecure = user != null && token == null;
const isModerator = user != null && (user.isModerator || user.isAdmin);

const ep = endpoints.find(e => e.name === endpoint);

Expand All @@ -30,6 +33,32 @@ export default async (endpoint: string, user: User | null | undefined, token: Ac
throw new ApiError(accessDenied);
}

if (ep.meta.limit && !isModerator) {
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
let limitActor: string;
if (user) {
limitActor = user.id;
} else {
limitActor = getIpHash(ctx!.ip);
}

const limit = Object.assign({}, ep.meta.limit);

if (!limit.key) {
limit.key = ep.name;
}

// Rate limit
await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(e => {
throw new ApiError({
message: 'Rate limit exceeded. Please try again later.',
code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429,
});
});
}

if (ep.meta.requireCredential && user == null) {
throw new ApiError({
message: 'Credential required.',
Expand All @@ -47,7 +76,7 @@ export default async (endpoint: string, user: User | null | undefined, token: Ac
throw new ApiError(accessDenied, { reason: 'You are not the admin.' });
}

if (ep.meta.requireModerator && !user!.isAdmin && !user!.isModerator) {
if (ep.meta.requireModerator && !isModerator) {
throw new ApiError(accessDenied, { reason: 'You are not a moderator.' });
}

Expand All @@ -67,21 +96,30 @@ export default async (endpoint: string, user: User | null | undefined, token: Ac
throw new ApiError(accessDenied, { reason: 'Apps cannot use moderator privileges.' });
}

if (ep.meta.requireCredential && ep.meta.limit && !user!.isAdmin && !user!.isModerator) {
// Rate limit
await limiter(ep, user!).catch(e => {
throw new ApiError({
message: 'Rate limit exceeded. Please try again later.',
code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429
});
});
// Cast non JSON input
if (ep.meta.requireFile && ep.meta.params) {
for (const k of Object.keys(ep.meta.params)) {
const param = ep.meta.params[k];
if (['Boolean', 'Number'].includes(param.validator.name) && typeof data[k] === 'string') {
try {
data[k] = JSON.parse(data[k]);
} catch (e) {
throw new ApiError({
message: 'Invalid param.',
code: 'INVALID_PARAM',
id: '0b5f1631-7c1a-41a6-b399-cce335f34d85',
}, {
param: k,
reason: `cannot cast to ${param.validator.name}`,
})
}
}
}
}

// API invoking
const before = performance.now();
return await ep.exec(data, user, token, file).catch((e: Error) => {
return await ep.exec(data, user, token, ctx?.file).catch((e: Error) => {
if (e instanceof ApiError) {
throw e;
} else {
Expand Down
1 change: 0 additions & 1 deletion src/server/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ export interface IEndpointMeta {
/**
* エンドポイントのリミテーションに関するやつ
* 省略した場合はリミテーションは無いものとして解釈されます。
* また、withCredential が false の場合はリミテーションを行うことはできません。
*/
limit?: {

Expand Down
6 changes: 2 additions & 4 deletions src/server/api/endpoints/drive/files/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,17 @@ export const meta = {
},

isSensitive: {
validator: $.optional.either($.bool, $.str),
validator: $.optional.bool,
default: false,
transform: (v: any): boolean => v === true || v === 'true',
desc: {
'ja-JP': 'このメディアが「閲覧注意」(NSFW)かどうか',
'en-US': 'Whether this media is NSFW'
}
},

force: {
validator: $.optional.either($.bool, $.str),
validator: $.optional.bool,
default: false,
transform: (v: any): boolean => v === true || v === 'true',
desc: {
'ja-JP': 'true にすると、同じハッシュを持つファイルが既にアップロードされていても強制的にファイルを作成します。',
}
Expand Down
26 changes: 9 additions & 17 deletions src/server/api/limiter.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,17 @@
import * as Limiter from 'ratelimiter';
import limiterDB from '../../db/redis';
import { IEndpoint } from './endpoints';
import getAcct from '../../misc/acct/render';
import { IEndpointMeta } from './endpoints';
import { User } from '../../models/entities/user';
import Logger from '../../services/logger';

const logger = new Logger('limiter');

export default (endpoint: IEndpoint, user: User) => new Promise((ok, reject) => {
const limitation = endpoint.meta.limit!;

const key = limitation.hasOwnProperty('key')
? limitation.key
: endpoint.name;

const hasShortTermLimit =
limitation.hasOwnProperty('minInterval');
export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((ok, reject) => {
const hasShortTermLimit = typeof limitation.minInterval === 'number';

const hasLongTermLimit =
limitation.hasOwnProperty('duration') &&
limitation.hasOwnProperty('max');
typeof limitation.duration === 'number' &&
typeof limitation.max === 'number';

if (hasShortTermLimit) {
min();
Expand All @@ -32,7 +24,7 @@ export default (endpoint: IEndpoint, user: User) => new Promise((ok, reject) =>
// Short-term limit
function min() {
const minIntervalLimiter = new Limiter({
id: `${user.id}:${key}:min`,
id: `${actor}:${limitation.key}:min`,
duration: limitation.minInterval,
max: 1,
db: limiterDB!
Expand All @@ -43,7 +35,7 @@ export default (endpoint: IEndpoint, user: User) => new Promise((ok, reject) =>
return reject('ERR');
}

logger.debug(`@${getAcct(user)} ${endpoint.name} min remaining: ${info.remaining}`);
logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);

if (info.remaining === 0) {
reject('BRIEF_REQUEST_INTERVAL');
Expand All @@ -60,7 +52,7 @@ export default (endpoint: IEndpoint, user: User) => new Promise((ok, reject) =>
// Long term limit
function max() {
const limiter = new Limiter({
id: `${user.id}:${key}`,
id: `${actor}:${limitation.key}`,
duration: limitation.duration,
max: limitation.max,
db: limiterDB!
Expand All @@ -71,7 +63,7 @@ export default (endpoint: IEndpoint, user: User) => new Promise((ok, reject) =>
return reject('ERR');
}

logger.debug(`@${getAcct(user)} ${endpoint.name} max remaining: ${info.remaining}`);
logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);

if (info.remaining === 0) {
reject('RATE_LIMIT_EXCEEDED');
Expand Down
17 changes: 17 additions & 0 deletions src/server/api/private/signin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { genId } from '../../../misc/gen-id';
import { ensure } from '../../../prelude/ensure';
import { verifyLogin, hash } from '../2fa';
import { randomBytes } from 'crypto';
import { limiter } from '../limiter';
import { getIpHash } from '../../../misc/get-ip-hash';

export default async (ctx: Koa.Context) => {
ctx.set('Access-Control-Allow-Origin', config.url);
Expand All @@ -19,6 +21,21 @@ export default async (ctx: Koa.Context) => {
const password = body['password'];
const token = body['token'];

try {
// not more than 1 attempt per second and not more than 10 attempts per hour
await limiter({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(ctx.ip));
} catch (err) {
ctx.status = 429;
ctx.body = {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
return;
}

if (typeof username != 'string') {
ctx.status = 400;
return;
Expand Down
17 changes: 17 additions & 0 deletions src/server/api/private/signup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,27 @@ import { fetchMeta } from '../../../misc/fetch-meta';
import { verifyHcaptcha, verifyRecaptcha } from '../../../misc/captcha';
import { Users, RegistrationTickets } from '../../../models';
import { signup } from '../common/signup';
import { limiter } from '../limiter';
import { getIpHash } from '../../../misc/get-ip-hash';

export default async (ctx: Koa.Context) => {
const body = ctx.request.body;

try {
// not more than 1 attempt per second and not more than 5 attempts per hour
await limiter({ key: 'signup', duration: 60 * 60 * 1000, max: 5, minInterval: 1000 }, getIpHash(ctx.ip));
} catch (err) {
ctx.status = 429;
ctx.body = {
error: {
message: 'Too many attempts to sign up. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
return;
}

const instance = await fetchMeta(true);

// Verify *Captcha
Expand Down

0 comments on commit 9975186

Please sign in to comment.