diff --git a/packages/backend/migration/1763651560421-MaxRegPerIP.js b/packages/backend/migration/1763651560421-MaxRegPerIP.js new file mode 100644 index 0000000000..c4d9aa140c --- /dev/null +++ b/packages/backend/migration/1763651560421-MaxRegPerIP.js @@ -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"`); + } +} \ No newline at end of file diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 750cdb9115..d78ab31bd7 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -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, diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index cb0226bd77..e29720b960 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -3,15 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Entity, Column, PrimaryColumn, ManyToOne } from 'typeorm'; -import { type InstanceUnsignedFetchOption, instanceUnsignedFetchOptions } from '@/const.js'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; +import { Entity, Column, PrimaryColumn, ManyToOne } from "typeorm"; +import { + type InstanceUnsignedFetchOption, + instanceUnsignedFetchOptions, +} from "@/const.js"; +import { id } from "./util/id.js"; +import { MiUser } from "./User.js"; -@Entity('meta') +@Entity("meta") export class MiMeta { @PrimaryColumn({ - type: 'varchar', + type: "varchar", length: 32, }) public id: string; @@ -20,781 +23,820 @@ export class MiMeta { ...id(), nullable: true, }) - public rootUserId: MiUser['id'] | null; + public rootUserId: MiUser["id"] | null; - @ManyToOne(type => MiUser, { - onDelete: 'SET NULL', + @ManyToOne((type) => MiUser, { + onDelete: "SET NULL", nullable: true, }) public rootUser: MiUser | null; - @Column('varchar', { - length: 1024, nullable: true, + @Column("varchar", { + length: 1024, + nullable: true, }) public name: string | null; - @Column('varchar', { - length: 64, nullable: true, + @Column("varchar", { + length: 64, + nullable: true, }) public shortName: string | null; - @Column('varchar', { - length: 1024, nullable: true, + @Column("varchar", { + length: 1024, + nullable: true, }) public description: string | null; /** * メンテナの名前 */ - @Column('varchar', { - length: 1024, nullable: true, + @Column("varchar", { + length: 1024, + nullable: true, }) public maintainerName: string | null; /** * メンテナの連絡先 */ - @Column('varchar', { - length: 1024, nullable: true, + @Column("varchar", { + length: 1024, + nullable: true, }) public maintainerEmail: string | null; - @Column('boolean', { + @Column("boolean", { default: true, }) public disableRegistration: boolean; - @Column('varchar', { - length: 1024, array: true, default: '{}', + @Column("varchar", { + length: 1024, + array: true, + default: "{}", }) public langs: string[]; - @Column('varchar', { - length: 1024, array: true, default: '{}', + @Column("varchar", { + length: 1024, + array: true, + default: "{}", }) public pinnedUsers: string[]; - @Column('varchar', { - length: 1024, array: true, default: '{}', + @Column("varchar", { + length: 1024, + array: true, + default: "{}", }) public hiddenTags: string[]; - @Column('varchar', { - length: 1024, array: true, default: '{}', + @Column("varchar", { + length: 1024, + array: true, + default: "{}", }) public blockedHosts: string[]; - @Column('varchar', { - length: 1024, array: true, default: '{}', + @Column("varchar", { + length: 1024, + array: true, + default: "{}", }) public sensitiveWords: string[]; - @Column('varchar', { - length: 1024, array: true, default: '{}', + @Column("varchar", { + length: 1024, + array: true, + default: "{}", }) public prohibitedWords: string[]; - @Column('varchar', { - length: 1024, array: true, default: '{}', + @Column("varchar", { + length: 1024, + array: true, + default: "{}", }) public prohibitedWordsForNameOfUser: string[]; - @Column('varchar', { - length: 1024, array: true, default: '{}', + @Column("varchar", { + length: 1024, + array: true, + default: "{}", }) public silencedHosts: string[]; - @Column('varchar', { - length: 1024, array: true, default: '{}', + @Column("varchar", { + length: 1024, + array: true, + default: "{}", }) public mediaSilencedHosts: string[]; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public themeColor: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public mascotImageUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public bannerUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public backgroundImageUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public logoImageUrl: string | null; - @Column('boolean', { + @Column("boolean", { default: false, }) public musicSource: string | null; - @Column('boolean', { + @Column("boolean", { default: false, }) public musicEnabled: boolean; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public iconUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public app192IconUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public app512IconUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public sidebarLogoUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public serverErrorImageUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public notFoundImageUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public infoImageUrl: string | null; - @Column('boolean', { + @Column("boolean", { default: false, }) public cacheRemoteFiles: boolean; - @Column('boolean', { + @Column("boolean", { default: true, }) public cacheRemoteSensitiveFiles: boolean; - @Column('boolean', { + @Column("boolean", { default: false, }) public emailRequiredForSignup: boolean; - @Column('boolean', { + @Column("boolean", { default: false, }) public approvalRequiredForSignup: boolean; - @Column('boolean', { + @Column("boolean", { default: false, }) public regWordRequired: boolean; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public registrationWord: string | null; - @Column('varchar', { - length: 5128, - nullable: true, - }) + @Column("integer", { + nullable: false, + }) + public maxRegPerIp: number; + + @Column("varchar", { + length: 5128, + nullable: true, + }) public longDescription: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public enableHcaptcha: boolean; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public hcaptchaSiteKey: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public hcaptchaSecretKey: string | null; - @Column('boolean', { + @Column("boolean", { default: false, }) public enableMcaptcha: boolean; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public mcaptchaSitekey: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public mcaptchaSecretKey: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public mcaptchaInstanceUrl: string | null; - @Column('boolean', { + @Column("boolean", { default: false, }) public enableRecaptcha: boolean; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public recaptchaSiteKey: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public recaptchaSecretKey: string | null; - @Column('boolean', { + @Column("boolean", { default: false, }) public enableTurnstile: boolean; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public turnstileSiteKey: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public turnstileSecretKey: string | null; - @Column('boolean', { + @Column("boolean", { default: false, }) public enableFC: boolean; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public fcSiteKey: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public fcSecretKey: string | null; - @Column('boolean', { + @Column("boolean", { default: false, }) public enableTestcaptcha: boolean; // chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること - @Column('enum', { - enum: ['none', 'all', 'local', 'remote'], - default: 'none', + @Column("enum", { + enum: ["none", "all", "local", "remote"], + default: "none", }) - public sensitiveMediaDetection: 'none' | 'all' | 'local' | 'remote'; + public sensitiveMediaDetection: "none" | "all" | "local" | "remote"; - @Column('enum', { - enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'], - default: 'medium', + @Column("enum", { + enum: ["medium", "low", "high", "veryLow", "veryHigh"], + default: "medium", }) - public sensitiveMediaDetectionSensitivity: 'medium' | 'low' | 'high' | 'veryLow' | 'veryHigh'; + public sensitiveMediaDetectionSensitivity: + | "medium" + | "low" + | "high" + | "veryLow" + | "veryHigh"; - @Column('boolean', { + @Column("boolean", { default: false, }) public setSensitiveFlagAutomatically: boolean; - @Column('boolean', { + @Column("boolean", { default: false, }) public enableSensitiveMediaDetectionForVideos: boolean; - @Column('boolean', { + @Column("boolean", { default: true, }) public enableBotTrending: boolean; - @Column('boolean', { + @Column("boolean", { default: false, }) public enableEmail: boolean; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public email: string | null; - @Column('boolean', { + @Column("boolean", { default: false, }) public smtpSecure: boolean; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public smtpHost: string | null; - @Column('integer', { + @Column("integer", { nullable: true, }) public smtpPort: number | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public smtpUser: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public smtpPass: string | null; - @Column('boolean', { + @Column("boolean", { default: false, }) public enableServiceWorker: boolean; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public swPublicKey: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public swPrivateKey: string | null; - @Column('integer', { + @Column("integer", { default: 5000, - comment: 'Timeout in milliseconds for translation API requests', + comment: "Timeout in milliseconds for translation API requests", }) public translationTimeout: number; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public deeplAuthKey: string | null; - @Column('boolean', { + @Column("boolean", { default: false, }) public deeplIsPro: boolean; - @Column('boolean', { + @Column("boolean", { default: false, }) public deeplFreeMode: boolean; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public deeplFreeInstance: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public libreTranslateURL: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public libreTranslateKey: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public termsOfServiceUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, - default: 'https://git.pawlickers.org/pawkey/pawkey/', + default: "https://git.pawlickers.org/pawkey/pawkey/", nullable: false, }) public repositoryUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, - default: 'https://git.pawlickers.org/pawkey/pawkey/issues/new', + default: "https://git.pawlickers.org/pawkey/pawkey/issues/new", nullable: true, }) public feedbackUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public impressumUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public privacyPolicyUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public donationUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public inquiryUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 8192, nullable: true, }) public defaultLightTheme: string | null; - @Column('varchar', { + @Column("varchar", { length: 8192, nullable: true, }) public defaultDarkTheme: string | null; - @Column('boolean', { + @Column("boolean", { default: false, }) public useObjectStorage: boolean; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public objectStorageBucket: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public objectStoragePrefix: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public objectStorageBaseUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public objectStorageEndpoint: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public objectStorageRegion: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public objectStorageAccessKey: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public objectStorageSecretKey: string | null; - @Column('integer', { + @Column("integer", { nullable: true, }) public objectStoragePort: number | null; - @Column('boolean', { + @Column("boolean", { default: true, }) public objectStorageUseSSL: boolean; - @Column('boolean', { + @Column("boolean", { default: true, }) public objectStorageUseProxy: boolean; - @Column('boolean', { + @Column("boolean", { default: false, }) public objectStorageSetPublicRead: boolean; - @Column('boolean', { + @Column("boolean", { default: true, }) public objectStorageS3ForcePathStyle: boolean; - @Column('boolean', { + @Column("boolean", { default: false, }) public enableIpLogging: boolean; - @Column('boolean', { + @Column("boolean", { default: true, }) public enableActiveEmailValidation: boolean; - @Column('boolean', { + @Column("boolean", { default: false, }) public enableVerifymailApi: boolean; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public verifymailAuthKey: string | null; - @Column('boolean', { + @Column("boolean", { default: false, }) public enableTruemailApi: boolean; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public truemailInstance: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public truemailAuthKey: string | null; - @Column('boolean', { + @Column("boolean", { default: true, }) public enableChartsForRemoteUser: boolean; - @Column('boolean', { + @Column("boolean", { default: true, }) public enableChartsForFederatedInstances: boolean; - @Column('boolean', { + @Column("boolean", { default: true, }) public enableStatsForFederatedInstances: boolean; - @Column('boolean', { + @Column("boolean", { default: false, }) public enableServerMachineStats: boolean; - @Column('boolean', { + @Column("boolean", { default: true, }) public enableIdenticonGeneration: boolean; - @Column('boolean', { + @Column("boolean", { default: true, }) public enableAchievements: boolean; - @Column('text', { + @Column("text", { nullable: true, }) public robotsTxt: string | null; - @Column('jsonb', { - default: { }, + @Column("jsonb", { + default: {}, }) public policies: Record; - @Column('varchar', { + @Column("varchar", { length: 280, array: true, - default: '{}', + default: "{}", }) public serverRules: string[]; - @Column('varchar', { + @Column("varchar", { length: 8192, - default: '{}', + default: "{}", }) public manifestJsonOverride: string; - @Column('varchar', { + @Column("varchar", { length: 1024, array: true, - default: '{}', + default: "{}", }) public bannedEmailDomains: string[]; - @Column('varchar', { - length: 1024, array: true, default: '{admin,administrator,root,system,maintainer,host,mod,moderator,owner,superuser,staff,auth,i,me,everyone,all,mention,mentions,example,user,users,account,accounts,official,help,helps,support,supports,info,information,informations,announce,announces,announcement,announcements,notice,notification,notifications,dev,developer,developers,tech,misskey}', + @Column("varchar", { + length: 1024, + array: true, + default: + "{admin,administrator,root,system,maintainer,host,mod,moderator,owner,superuser,staff,auth,i,me,everyone,all,mention,mentions,example,user,users,account,accounts,official,help,helps,support,supports,info,information,informations,announce,announces,announcement,announcements,notice,notification,notifications,dev,developer,developers,tech,misskey}", }) public preservedUsernames: string[]; - @Column('boolean', { + @Column("boolean", { default: true, }) public enableFanoutTimeline: boolean; - @Column('boolean', { + @Column("boolean", { default: true, }) public enableFanoutTimelineDbFallback: boolean; - @Column('integer', { + @Column("integer", { default: 800, }) public perLocalUserUserTimelineCacheMax: number; - @Column('integer', { + @Column("integer", { default: 800, }) public perRemoteUserUserTimelineCacheMax: number; - @Column('integer', { + @Column("integer", { default: 800, }) public perUserHomeTimelineCacheMax: number; - @Column('integer', { + @Column("integer", { default: 800, }) public perUserListTimelineCacheMax: number; - @Column('boolean', { + @Column("boolean", { default: false, }) public enableReactionsBuffering: boolean; - @Column('integer', { + @Column("integer", { default: 0, }) public notesPerOneAd: number; - @Column('varchar', { + @Column("varchar", { length: 500, - default: '❤️', + default: "❤️", }) public defaultLike: string; - @Column('varchar', { - length: 256, array: true, default: '{}', + @Column("varchar", { + length: 256, + array: true, + default: "{}", }) public bubbleInstances: string[]; - @Column('boolean', { + @Column("boolean", { default: true, }) public urlPreviewEnabled: boolean; - @Column('integer', { + @Column("integer", { default: 10000, }) public urlPreviewTimeout: number; - @Column('bigint', { + @Column("bigint", { default: 1024 * 1024 * 10, }) public urlPreviewMaximumContentLength: number; - @Column('boolean', { + @Column("boolean", { default: false, }) public urlPreviewRequireContentLength: boolean; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public urlPreviewSummaryProxyUrl: string | null; - @Column('varchar', { + @Column("varchar", { length: 1024, nullable: true, }) public urlPreviewUserAgent: string | null; - @Column('varchar', { + @Column("varchar", { length: 3072, array: true, - default: '{}', - comment: 'An array of URL strings or regex that can be used to omit warnings about redirects to external sites. Separate them with spaces to specify AND, and enclose them with slashes to specify regular expressions. Each item is regarded as an OR.', + default: "{}", + comment: + "An array of URL strings or regex that can be used to omit warnings about redirects to external sites. Separate them with spaces to specify AND, and enclose them with slashes to specify regular expressions. Each item is regarded as an OR.", }) public trustedLinkUrlPatterns: string[]; - @Column('varchar', { + @Column("varchar", { length: 128, - default: 'all', + default: "all", }) - public federation: 'all' | 'specified' | 'none'; + public federation: "all" | "specified" | "none"; - @Column('varchar', { + @Column("varchar", { length: 1024, array: true, - default: '{}', + default: "{}", }) public federationHosts: string[]; /** * In combination with user.allowUnsignedFetch, controls enforcement of HTTP signatures for inbound ActivityPub fetches (GET requests). */ - @Column('enum', { + @Column("enum", { enum: instanceUnsignedFetchOptions, - default: 'always', + default: "always", }) public allowUnsignedFetch: InstanceUnsignedFetchOption; - @Column('boolean', { + @Column("boolean", { default: false, }) public enableProxyAccount: boolean; diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 81e3a5b706..eae0e0cd46 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -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 = 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; diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 54ec3582de..36a28a838b 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -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 { // eslint- approvalRequiredForSignup: instance.approvalRequiredForSignup, registrationWord: instance.registrationWord, regWordRequired: instance.regWordRequired, + maxRegPerIp: instance.maxRegPerIp, enableHcaptcha: instance.enableHcaptcha, hcaptchaSiteKey: instance.hcaptchaSiteKey, enableMcaptcha: instance.enableMcaptcha, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index d279d93820..8f16908f1b 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -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 { // eslint- set.regWordRequired = ps.regWordRequired; } + if (ps.maxRegPerIp !== undefined) { + set.maxRegPerIp = ps.maxRegPerIp; + } + if (ps.enableHcaptcha !== undefined) { set.enableHcaptcha = ps.enableHcaptcha; } diff --git a/packages/frontend/src/accounts.ts b/packages/frontend/src/accounts.ts index 34491a3605..4af15508a7 100644 --- a/packages/frontend/src/accounts.ts +++ b/packages/frontend/src/accounts.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-FileCopyrightText: pawinput and pawkey project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -187,7 +187,7 @@ export async function login(token: AccountWithToken['token'], redirect?: string) token, })); - Cookies.set('token', token, { expires: 365 * 100 }); + Cookies.set('token', token, { expires: 365 * 100, secure: true, sameSite: 'lax' }); await addAccount(host, me, token); diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 24eb81beca..15195c2486 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -328,7 +328,6 @@ export function inputText(props: { } | { canceled: false; result: string; }>; -// min lengthが指定されてたら result は null になり得ないことを保証する overload function export function inputText(props: { type?: 'text' | 'email' | 'password' | 'url'; title?: string; @@ -566,7 +565,7 @@ export function success(): Promise { }); } -export function waiting(text?: string | null): Promise { +export function waiting(text?: string | null): Promise<{ close: () => void }> { return new Promise(resolve => { const showing = ref(true); const { dispose } = popup(MkWaitingDialog, { @@ -574,9 +573,15 @@ export function waiting(text?: string | null): Promise { showing: showing, text, }, { - done: () => resolve(), + done: () => {}, closed: () => dispose(), }); + + resolve({ + close: () => { + showing.value = false; + } + }); }); } diff --git a/packages/frontend/src/pages/admin/approvals.vue b/packages/frontend/src/pages/admin/approvals.vue index 77b4cc38a1..cd953b730c 100644 --- a/packages/frontend/src/pages/admin/approvals.vue +++ b/packages/frontend/src/pages/admin/approvals.vue @@ -1,34 +1,59 @@