feat: [frontend / backend / sdk] a bunch of changes
backend: - Validate registrationWord on backend - Add maxRegPerIP rate limit how much accounts can be created per-ip frontend: - Add support for bulk approval rejection / acception - Address unsafe cookies-js defaults - Address addition of maxRegPerIP sdk: - Type definitions
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
/** @type {import('typeorm').MigrationInterface} */
|
||||
export class MaxRegPerIP1763651560421 {
|
||||
name = 'MaxRegPerIP1763651560421'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "maxRegPerIp" integer NOT NULL DEFAULT 2`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "maxRegPerIp"`);
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ export class MetaEntityService {
|
||||
approvalRequiredForSignup: instance.approvalRequiredForSignup,
|
||||
regWordRequired: instance.regWordRequired,
|
||||
registrationWord: instance.registrationWord,
|
||||
maxRegPerIp: instance.maxRegPerIp,
|
||||
enableHcaptcha: instance.enableHcaptcha,
|
||||
hcaptchaSiteKey: instance.hcaptchaSiteKey,
|
||||
enableMcaptcha: instance.enableMcaptcha,
|
||||
|
||||
+227
-185
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-FileCopyrightText: pawinput and pawkey project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as argon2 from 'argon2';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { IsNull, MoreThan } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, MiRegistrationTicket, MiMeta } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
@@ -24,6 +24,9 @@ import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
export class SignupApiService {
|
||||
// In-memory rate limit tracking (poof on server restart)
|
||||
private ipSignupAttempts: Map<string, number[]> = new Map();
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
@@ -56,6 +59,66 @@ export class SignupApiService {
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private getClientIp(request: FastifyRequest): string {
|
||||
const forwardedFor = request.headers['x-forwarded-for'];
|
||||
if (forwardedFor) {
|
||||
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
|
||||
return ips.split(',')[0].trim();
|
||||
}
|
||||
|
||||
const realIp = request.headers['x-real-ip'];
|
||||
if (realIp) {
|
||||
return Array.isArray(realIp) ? realIp[0] : realIp;
|
||||
}
|
||||
|
||||
return request.ip ?? request.socket.remoteAddress ?? 'unknown';
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private checkIpRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const oneDayAgo = now - (24 * 60 * 60 * 1000);
|
||||
const maxRegistrations = Math.max(1, Math.floor(Number(this.meta.maxRegPerIp ?? 2) || 2));
|
||||
|
||||
// Get or create attempts list for this IP
|
||||
let attempts = this.ipSignupAttempts.get(ip) || [];
|
||||
|
||||
// Filter out attempts older than 24 hours
|
||||
attempts = attempts.filter(timestamp => timestamp > oneDayAgo);
|
||||
|
||||
// Check if limit exceeded
|
||||
if (attempts.length >= maxRegistrations) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Record this attempt
|
||||
attempts.push(now);
|
||||
this.ipSignupAttempts.set(ip, attempts);
|
||||
|
||||
// Cleanup old entries periodically (keep map from growing indefinitely)
|
||||
if (Math.random() < 0.01) { // 1% chance on each signup
|
||||
this.cleanupOldAttempts();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private cleanupOldAttempts(): void {
|
||||
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
|
||||
|
||||
for (const [ip, attempts] of this.ipSignupAttempts.entries()) {
|
||||
const recentAttempts = attempts.filter(timestamp => timestamp > oneDayAgo);
|
||||
|
||||
if (recentAttempts.length === 0) {
|
||||
this.ipSignupAttempts.delete(ip);
|
||||
} else {
|
||||
this.ipSignupAttempts.set(ip, recentAttempts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async signup(
|
||||
request: FastifyRequest<{
|
||||
@@ -78,6 +141,16 @@ export class SignupApiService {
|
||||
) {
|
||||
const body = request.body;
|
||||
|
||||
// Check IP rate limit (except in test mode)
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const clientIp = this.getClientIp(request);
|
||||
const isAllowed = this.checkIpRateLimit(clientIp);
|
||||
|
||||
if (!isAllowed) {
|
||||
throw new FastifyReplyError(429, 'RATE_LIMIT_EXCEEDED: Too many signups from this IP address. Please try again later.');
|
||||
}
|
||||
}
|
||||
|
||||
// Verify *Captcha
|
||||
// ただしテスト時はこの機構は障害となるため無効にする
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
@@ -143,6 +216,16 @@ export class SignupApiService {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate reg word
|
||||
if (this.meta.regWordRequired && this.meta.registrationWord && this.meta.registrationWord.trim() !== '') {
|
||||
const registrationWord = this.meta.registrationWord.toLowerCase().trim();
|
||||
const reasonLower = reason.toLowerCase();
|
||||
|
||||
if (!reasonLower.includes(registrationWord)) {
|
||||
throw new FastifyReplyError(400, 'REGISTRATION_WORD_MISSING: The signup reason must contain the required registration word.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ticket: MiRegistrationTicket | null = null;
|
||||
|
||||
@@ -47,6 +47,10 @@ export const meta = {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
maxRegPerIp: {
|
||||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableHcaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
@@ -680,6 +684,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
approvalRequiredForSignup: instance.approvalRequiredForSignup,
|
||||
registrationWord: instance.registrationWord,
|
||||
regWordRequired: instance.regWordRequired,
|
||||
maxRegPerIp: instance.maxRegPerIp,
|
||||
enableHcaptcha: instance.enableHcaptcha,
|
||||
hcaptchaSiteKey: instance.hcaptchaSiteKey,
|
||||
enableMcaptcha: instance.enableMcaptcha,
|
||||
|
||||
@@ -79,6 +79,7 @@ export const paramDef = {
|
||||
approvalRequiredForSignup: { type: 'boolean' },
|
||||
regWordRequired: { type: 'boolean', nullable: false },
|
||||
registrationWord: { type: 'string', nullable: true },
|
||||
maxRegPerIp: { type: 'integer', nullable: false },
|
||||
enableHcaptcha: { type: 'boolean' },
|
||||
hcaptchaSiteKey: { type: 'string', nullable: true },
|
||||
hcaptchaSecretKey: { type: 'string', nullable: true },
|
||||
@@ -382,6 +383,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
set.regWordRequired = ps.regWordRequired;
|
||||
}
|
||||
|
||||
if (ps.maxRegPerIp !== undefined) {
|
||||
set.maxRegPerIp = ps.maxRegPerIp;
|
||||
}
|
||||
|
||||
if (ps.enableHcaptcha !== undefined) {
|
||||
set.enableHcaptcha = ps.enableHcaptcha;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user