From 99751861200954ecfe1e7ad3e81e74aaf3545201 Mon Sep 17 00:00:00 2001 From: atsu1125 Date: Fri, 16 Aug 2024 00:00:00 +0900 Subject: [PATCH] fix: API limiter --- locales/en-US.yml | 1 + locales/ja-JP.yml | 1 + src/misc/get-ip-hash.ts | 9 +++ src/server/api/api-handler.ts | 2 +- src/server/api/call.ts | 68 +++++++++++++++---- src/server/api/endpoints.ts | 1 - .../api/endpoints/drive/files/create.ts | 6 +- src/server/api/limiter.ts | 26 +++---- src/server/api/private/signin.ts | 17 +++++ src/server/api/private/signup.ts | 17 +++++ 10 files changed, 110 insertions(+), 38 deletions(-) create mode 100644 src/misc/get-ip-hash.ts diff --git a/locales/en-US.yml b/locales/en-US.yml index f9498d9b26..ca30d6b08b 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -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..." diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index effcc5fd5a..0971cb6cb1 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -942,6 +942,7 @@ themeColor: "テーマカラー" showTickerSoftwareName: "インスタンスティッカーにソフトウェア名を表示する" preferTickerSoftwareColor: "インスタンスティッカーにソフトウェアカラーを採用して表示する" typeToConfirm: "この操作を行うには {x} と入力してください" +rateLimitExceeded: "レート制限を超えました" _template: edit: "定型文を編集…" diff --git a/src/misc/get-ip-hash.ts b/src/misc/get-ip-hash.ts new file mode 100644 index 0000000000..c47e72b911 --- /dev/null +++ b/src/misc/get-ip-hash.ts @@ -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); +} diff --git a/src/server/api/api-handler.ts b/src/server/api/api-handler.ts index 80a4fd97c8..712de59b7c 100644 --- a/src/server/api/api-handler.ts +++ b/src/server/api/api-handler.ts @@ -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); diff --git a/src/server/api/call.ts b/src/server/api/call.ts index a206aa01cc..c5d3d33388 100644 --- a/src/server/api/call.ts +++ b/src/server/api/call.ts @@ -1,10 +1,12 @@ +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.', @@ -12,8 +14,9 @@ const accessDenied = { 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); @@ -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 }, 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.', @@ -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.' }); } @@ -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 { diff --git a/src/server/api/endpoints.ts b/src/server/api/endpoints.ts index 1a8fca6dfa..ed7dfd6cff 100644 --- a/src/server/api/endpoints.ts +++ b/src/server/api/endpoints.ts @@ -52,7 +52,6 @@ export interface IEndpointMeta { /** * エンドポイントのリミテーションに関するやつ * 省略した場合はリミテーションは無いものとして解釈されます。 - * また、withCredential が false の場合はリミテーションを行うことはできません。 */ limit?: { diff --git a/src/server/api/endpoints/drive/files/create.ts b/src/server/api/endpoints/drive/files/create.ts index d4243baf6a..cac5b8ca6e 100644 --- a/src/server/api/endpoints/drive/files/create.ts +++ b/src/server/api/endpoints/drive/files/create.ts @@ -45,9 +45,8 @@ 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' @@ -55,9 +54,8 @@ export const meta = { }, force: { - validator: $.optional.either($.bool, $.str), + validator: $.optional.bool, default: false, - transform: (v: any): boolean => v === true || v === 'true', desc: { 'ja-JP': 'true にすると、同じハッシュを持つファイルが既にアップロードされていても強制的にファイルを作成します。', } diff --git a/src/server/api/limiter.ts b/src/server/api/limiter.ts index 48d12d3cc6..2d95360e33 100644 --- a/src/server/api/limiter.ts +++ b/src/server/api/limiter.ts @@ -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 }, actor: string) => new Promise((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(); @@ -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! @@ -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'); @@ -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! @@ -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'); diff --git a/src/server/api/private/signin.ts b/src/server/api/private/signin.ts index 734758d63d..1fdd07f16d 100644 --- a/src/server/api/private/signin.ts +++ b/src/server/api/private/signin.ts @@ -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); @@ -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; diff --git a/src/server/api/private/signup.ts b/src/server/api/private/signup.ts index 5d51fd6c3b..d6dc7d2bbb 100644 --- a/src/server/api/private/signup.ts +++ b/src/server/api/private/signup.ts @@ -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