1
0

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:
Leafus
2025-11-20 16:57:33 +01:00
parent 39177b6dcc
commit 04178a2ae5
12 changed files with 35584 additions and 33808 deletions
@@ -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,
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;
}