enhance: replace signin CAPTCHA with rate limit (#8740)

* enhance: rate limit works without signed in user

* fix: make limit key required for limiter

As before the fallback limiter key will be set from the endpoint name.

* enhance: use limiter for signin

* Revert "CAPTCHA求めるのは2fa認証が無効になっているときだけにした"

This reverts commit 02a43a310f.

* Revert "feat: make captcha required when signin to improve security"

This reverts commit b21b058005.

* fix undefined reference

* fix: better error message

* enhance: only handle prefix of IPv6
This commit is contained in:
Johann150 2022-05-28 05:06:47 +02:00 committed by GitHub
parent cec3dcec8a
commit 161659de5c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 75 additions and 57 deletions

View file

@ -27,6 +27,8 @@ You should also include the user name that made the change.
Your own theme color may be unset if it was in an invalid format. Your own theme color may be unset if it was in an invalid format.
Admins should check their instance settings if in doubt. Admins should check their instance settings if in doubt.
- Perform port diagnosis at startup only when Listen fails @mei23 - Perform port diagnosis at startup only when Listen fails @mei23
- Rate limiting is now also usable for non-authenticated users. @Johann150
Admins should make sure the reverse proxy sets the `X-Forwarded-For` header to the original address.
### Bugfixes ### Bugfixes
- Client: fix settings page @tamaina - Client: fix settings page @tamaina

View file

@ -842,6 +842,7 @@ oneDay: "1日"
oneWeek: "1週間" oneWeek: "1週間"
reflectMayTakeTime: "反映されるまで時間がかかる場合があります。" reflectMayTakeTime: "反映されるまで時間がかかる場合があります。"
failedToFetchAccountInformation: "アカウント情報の取得に失敗しました" failedToFetchAccountInformation: "アカウント情報の取得に失敗しました"
rateLimitExceeded: "レート制限を超えました"
_emailUnavailable: _emailUnavailable:
used: "既に使用されています" used: "既に使用されています"

View file

@ -2,10 +2,11 @@ import Koa from 'koa';
import { performance } from 'perf_hooks'; import { performance } from 'perf_hooks';
import { limiter } from './limiter.js'; import { limiter } from './limiter.js';
import { CacheableLocalUser, User } from '@/models/entities/user.js'; import { CacheableLocalUser, User } from '@/models/entities/user.js';
import endpoints, { IEndpoint } from './endpoints.js'; import endpoints, { IEndpointMeta } from './endpoints.js';
import { ApiError } from './error.js'; import { ApiError } from './error.js';
import { apiLogger } from './logger.js'; import { apiLogger } from './logger.js';
import { AccessToken } from '@/models/entities/access-token.js'; import { AccessToken } from '@/models/entities/access-token.js';
import IPCIDR from 'ip-cidr';
const accessDenied = { const accessDenied = {
message: 'Access denied.', message: 'Access denied.',
@ -15,6 +16,7 @@ const accessDenied = {
export default async (endpoint: string, user: CacheableLocalUser | null | undefined, token: AccessToken | null | undefined, data: any, ctx?: Koa.Context) => { export default async (endpoint: string, user: CacheableLocalUser | null | undefined, token: AccessToken | null | undefined, data: any, ctx?: Koa.Context) => {
const isSecure = user != null && token == null; const isSecure = user != null && token == null;
const isModerator = user != null && (user.isModerator || user.isAdmin);
const ep = endpoints.find(e => e.name === endpoint); const ep = endpoints.find(e => e.name === endpoint);
@ -31,6 +33,37 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
throw new ApiError(accessDenied); throw new ApiError(accessDenied);
} }
if (ep.meta.requireCredential && 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 {
// 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 ip = IPCIDR.createAddress(ctx.ip).mask(64);
limitActor = 'ip-' + parseInt(ip, 2).toString(36);
}
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) { if (ep.meta.requireCredential && user == null) {
throw new ApiError({ throw new ApiError({
message: 'Credential required.', message: 'Credential required.',
@ -53,7 +86,7 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
throw new ApiError(accessDenied, { reason: 'You are not the admin.' }); 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.' }); throw new ApiError(accessDenied, { reason: 'You are not a moderator.' });
} }
@ -65,18 +98,6 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
}); });
} }
if (ep.meta.requireCredential && ep.meta.limit && !user!.isAdmin && !user!.isModerator) {
// Rate limit
await limiter(ep as IEndpoint & { meta: { limit: NonNullable<IEndpoint['meta']['limit']> } }, 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 // Cast non JSON input
if (ep.meta.requireFile && ep.params.properties) { if (ep.meta.requireFile && ep.params.properties) {
for (const k of Object.keys(ep.params.properties)) { for (const k of Object.keys(ep.params.properties)) {

View file

@ -654,7 +654,6 @@ export interface IEndpointMeta {
/** /**
* *
* *
* withCredential false
*/ */
readonly limit?: { readonly limit?: {

View file

@ -1,25 +1,17 @@
import Limiter from 'ratelimiter'; import Limiter from 'ratelimiter';
import { redisClient } from '../../db/redis.js'; import { redisClient } from '../../db/redis.js';
import { IEndpoint } from './endpoints.js'; import { IEndpointMeta } from './endpoints.js';
import * as Acct from '@/misc/acct.js';
import { CacheableLocalUser, User } from '@/models/entities/user.js'; import { CacheableLocalUser, User } from '@/models/entities/user.js';
import Logger from '@/services/logger.js'; import Logger from '@/services/logger.js';
const logger = new Logger('limiter'); const logger = new Logger('limiter');
export const limiter = (endpoint: IEndpoint & { meta: { limit: NonNullable<IEndpoint['meta']['limit']> } }, user: CacheableLocalUser) => new Promise<void>((ok, reject) => { export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((ok, reject) => {
const limitation = endpoint.meta.limit; const hasShortTermLimit = typeof limitation.minInterval === 'number';
const key = Object.prototype.hasOwnProperty.call(limitation, 'key')
? limitation.key
: endpoint.name;
const hasShortTermLimit =
Object.prototype.hasOwnProperty.call(limitation, 'minInterval');
const hasLongTermLimit = const hasLongTermLimit =
Object.prototype.hasOwnProperty.call(limitation, 'duration') && typeof limitation.duration === 'number' &&
Object.prototype.hasOwnProperty.call(limitation, 'max'); typeof limitation.max === 'number';
if (hasShortTermLimit) { if (hasShortTermLimit) {
min(); min();
@ -32,7 +24,7 @@ export const limiter = (endpoint: IEndpoint & { meta: { limit: NonNullable<IEndp
// Short-term limit // Short-term limit
function min(): void { function min(): void {
const minIntervalLimiter = new Limiter({ const minIntervalLimiter = new Limiter({
id: `${user.id}:${key}:min`, id: `${actor}:${limitation.key}:min`,
duration: limitation.minInterval, duration: limitation.minInterval,
max: 1, max: 1,
db: redisClient, db: redisClient,
@ -43,7 +35,7 @@ export const limiter = (endpoint: IEndpoint & { meta: { limit: NonNullable<IEndp
return reject('ERR'); return reject('ERR');
} }
logger.debug(`@${Acct.toString(user)} ${endpoint.name} 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'); reject('BRIEF_REQUEST_INTERVAL');
@ -60,7 +52,7 @@ export const limiter = (endpoint: IEndpoint & { meta: { limit: NonNullable<IEndp
// Long term limit // Long term limit
function max(): void { function max(): void {
const limiter = new Limiter({ const limiter = new Limiter({
id: `${user.id}:${key}`, id: `${actor}:${limitation.key}`,
duration: limitation.duration, duration: limitation.duration,
max: limitation.max, max: limitation.max,
db: redisClient, db: redisClient,
@ -71,7 +63,7 @@ export const limiter = (endpoint: IEndpoint & { meta: { limit: NonNullable<IEndp
return reject('ERR'); return reject('ERR');
} }
logger.debug(`@${Acct.toString(user)} ${endpoint.name} 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'); reject('RATE_LIMIT_EXCEEDED');

View file

@ -1,25 +1,21 @@
import { randomBytes } from 'node:crypto';
import Koa from 'koa'; import Koa from 'koa';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import * as speakeasy from 'speakeasy'; import * as speakeasy from 'speakeasy';
import { IsNull } from 'typeorm'; import signin from '../common/signin.js';
import config from '@/config/index.js'; import config from '@/config/index.js';
import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } from '@/models/index.js'; import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } from '@/models/index.js';
import { ILocalUser } from '@/models/entities/user.js'; import { ILocalUser } from '@/models/entities/user.js';
import { genId } from '@/misc/gen-id.js'; import { genId } from '@/misc/gen-id.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { verifyHcaptcha, verifyRecaptcha } from '@/misc/captcha.js';
import { verifyLogin, hash } from '../2fa.js'; import { verifyLogin, hash } from '../2fa.js';
import signin from '../common/signin.js'; import { randomBytes } from 'node:crypto';
import { IsNull } from 'typeorm';
import { limiter } from '../limiter.js';
export default async (ctx: Koa.Context) => { export default async (ctx: Koa.Context) => {
ctx.set('Access-Control-Allow-Origin', config.url); ctx.set('Access-Control-Allow-Origin', config.url);
ctx.set('Access-Control-Allow-Credentials', 'true'); ctx.set('Access-Control-Allow-Credentials', 'true');
const body = ctx.request.body as any; const body = ctx.request.body as any;
const instance = await fetchMeta(true);
const username = body['username']; const username = body['username'];
const password = body['password']; const password = body['password'];
const token = body['token']; const token = body['token'];
@ -29,6 +25,21 @@ export default async (ctx: Koa.Context) => {
ctx.body = { error }; ctx.body = { error };
} }
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 }, 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') { if (typeof username !== 'string') {
ctx.status = 400; ctx.status = 400;
return; return;
@ -84,18 +95,6 @@ export default async (ctx: Koa.Context) => {
} }
if (!profile.twoFactorEnabled) { if (!profile.twoFactorEnabled) {
if (instance.enableHcaptcha && instance.hcaptchaSecretKey) {
await verifyHcaptcha(instance.hcaptchaSecretKey, body['hcaptcha-response']).catch(e => {
ctx.throw(400, e);
});
}
if (instance.enableRecaptcha && instance.recaptchaSecretKey) {
await verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(e => {
ctx.throw(400, e);
});
}
if (same) { if (same) {
signin(ctx, user); signin(ctx, user);
return; return;
@ -172,7 +171,7 @@ export default async (ctx: Koa.Context) => {
body.credentialId body.credentialId
.replace(/-/g, '+') .replace(/-/g, '+')
.replace(/_/g, '/'), .replace(/_/g, '/'),
'base64', 'base64'
).toString('hex'), ).toString('hex'),
}); });

View file

@ -14,8 +14,6 @@
<template #prefix><i class="fas fa-lock"></i></template> <template #prefix><i class="fas fa-lock"></i></template>
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template> <template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
</MkInput> </MkInput>
<MkCaptcha v-if="meta.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" class="_formBlock captcha" provider="hcaptcha" :sitekey="meta.hcaptchaSiteKey"/>
<MkCaptcha v-if="meta.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" class="_formBlock captcha" provider="recaptcha" :sitekey="meta.recaptchaSiteKey"/>
<MkButton class="_formBlock" type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton> <MkButton class="_formBlock" type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
</div> </div>
<div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }"> <div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }">
@ -64,8 +62,6 @@ import { showSuspendedDialog } from '../scripts/show-suspended-dialog';
import { instance } from '@/instance'; import { instance } from '@/instance';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
const MkCaptcha = defineAsyncComponent(() => import('./captcha.vue'));
let signing = $ref(false); let signing = $ref(false);
let user = $ref(null); let user = $ref(null);
let username = $ref(''); let username = $ref('');
@ -217,6 +213,14 @@ function loginFailed(err) {
showSuspendedDialog(); showSuspendedDialog();
break; break;
} }
case '22d05606-fbcf-421a-a2db-b32610dcfd1b': {
os.alert({
type: 'error',
title: i18n.ts.loginFailed,
text: i18n.ts.rateLimitExceeded,
});
break;
}
default: { default: {
console.log(err) console.log(err)
os.alert({ os.alert({