From 64501c69a10323067dee739790b5a4fc5104e50d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?=
 <46447427+samunohito@users.noreply.github.com>
Date: Tue, 14 Jan 2025 19:57:58 +0900
Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Bot=E3=83=97=E3=83=AD?=
 =?UTF-8?q?=E3=83=86=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=AE=E8=A8=AD?=
 =?UTF-8?q?=E5=AE=9A=E5=A4=89=E6=9B=B4=E6=99=82=E3=81=AF=E5=AE=9F=E9=9A=9B?=
 =?UTF-8?q?=E3=81=AB=E6=A4=9C=E8=A8=BC=E3=82=92=E9=80=9A=E9=81=8E=E3=81=97?=
 =?UTF-8?q?=E3=81=AA=E3=81=84=E3=81=A8=E4=BF=9D=E5=AD=98=E3=81=A7=E3=81=8D?=
 =?UTF-8?q?=E3=81=AA=E3=81=84=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?=
 =?UTF-8?q?=20(#15151)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat(frontend): CAPTCHAの設定変更時は実際に検証を通過しないと保存できないようにする

* なしでも保存できるようにした

* fix CHANGELOG.md

* フォームが増殖するのを修正

* add comment

* add server-side verify

* fix ci

* fix

* fix

* fix i18n

* add current.ts

* fix text

* fix

* regenerate locales

* fix MkFormFooter.vue

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
 CHANGELOG.md                                  |   1 +
 locales/index.d.ts                            |  43 ++
 locales/ja-JP.yml                             |  14 +
 packages/backend/src/core/CaptchaService.ts   | 299 ++++++++-
 .../backend/src/server/api/EndpointsModule.ts |   8 +
 packages/backend/src/server/api/endpoints.ts  |   4 +
 .../api/endpoints/admin/captcha/current.ts    |  70 ++
 .../api/endpoints/admin/captcha/save.ts       | 129 ++++
 packages/backend/test/unit/CaptchaService.ts  | 622 ++++++++++++++++++
 .../frontend/src/components/MkCaptcha.vue     |  64 +-
 .../frontend/src/components/MkFormFooter.vue  |   9 +-
 packages/frontend/src/index.html              |   2 +-
 packages/frontend/src/os.ts                   |   5 +-
 .../src/pages/admin/bot-protection.vue        | 240 +++++--
 packages/misskey-js/etc/misskey-js.api.md     |   8 +
 .../misskey-js/src/autogen/apiClientJSDoc.ts  |  22 +
 packages/misskey-js/src/autogen/endpoint.ts   |   4 +
 packages/misskey-js/src/autogen/entities.ts   |   2 +
 packages/misskey-js/src/autogen/types.ts      | 140 ++++
 19 files changed, 1597 insertions(+), 89 deletions(-)
 create mode 100644 packages/backend/src/server/api/endpoints/admin/captcha/current.ts
 create mode 100644 packages/backend/src/server/api/endpoints/admin/captcha/save.ts
 create mode 100644 packages/backend/test/unit/CaptchaService.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index eb9f9aaeeb..af5d333927 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@
 - Fix: 公開範囲がホームのノートの埋め込みウィジェットが読み込まれない問題を修正  
   (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/803)
 - Fix: 絵文字管理画面で一部の絵文字が表示されない問題を修正
+- Fix: Botプロテクションの設定変更時は実際に検証を通過しないと保存できないように( #15137 )
 - Fix: ノート検索が使用できない場合でもチャンネルのノート検索欄がでていた問題を修正
 - Fix: `Ui:C:select`で値の変更が画面に反映されない問題を修正
 - Fix: MiAuth認可画面で、認可処理に失敗した場合でもコールバックURLに遷移してしまう問題を修正  
diff --git a/locales/index.d.ts b/locales/index.d.ts
index e85d6a3bd5..7c3ef5d93c 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -10668,6 +10668,49 @@ export interface Locale extends ILocale {
             "description": string;
         };
     };
+    "_captcha": {
+        /**
+         * CAPTCHAを通過してください
+         */
+        "verify": string;
+        /**
+         * サイトキーとシークレットキーにテスト用の値を入力することでプレビューを確認できます。
+         * 詳細は下記ページをご確認ください。
+         */
+        "testSiteKeyMessage": string;
+        "_error": {
+            "_requestFailed": {
+                /**
+                 * CAPTCHAのリクエストに失敗しました
+                 */
+                "title": string;
+                /**
+                 * しばらく後に実行するか、設定をもう一度ご確認ください。
+                 */
+                "text": string;
+            };
+            "_verificationFailed": {
+                /**
+                 * CAPTCHAの検証に失敗しました
+                 */
+                "title": string;
+                /**
+                 * 設定が正しいかどうかもう一度確認ください。
+                 */
+                "text": string;
+            };
+            "_unknown": {
+                /**
+                 * CAPTCHAエラー
+                 */
+                "title": string;
+                /**
+                 * 想定外のエラーが発生しました。
+                 */
+                "text": string;
+            };
+        };
+    };
 }
 declare const locales: {
     [lang: string]: Locale;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 37e51b9398..57a88062c1 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2847,3 +2847,17 @@ _remoteLookupErrors:
   _noSuchObject:
     title: "見つかりません"
     description: "要求されたリソースは見つかりませんでした。URIをもう一度お確かめください。"
+
+_captcha:
+  verify: "CAPTCHAを通過してください"
+  testSiteKeyMessage: "サイトキーとシークレットキーにテスト用の値を入力することでプレビューを確認できます。\n詳細は下記ページをご確認ください。"
+  _error:
+    _requestFailed:
+      title: "CAPTCHAのリクエストに失敗しました"
+      text: "しばらく後に実行するか、設定をもう一度ご確認ください。"
+    _verificationFailed:
+      title: "CAPTCHAの検証に失敗しました"
+      text: "設定が正しいかどうかもう一度確認ください。"
+    _unknown:
+      title: "CAPTCHAエラー"
+      text: "想定外のエラーが発生しました。"
diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts
index 206d0dbe0a..8c7f66236e 100644
--- a/packages/backend/src/core/CaptchaService.ts
+++ b/packages/backend/src/core/CaptchaService.ts
@@ -6,6 +6,65 @@
 import { Injectable } from '@nestjs/common';
 import { HttpRequestService } from '@/core/HttpRequestService.js';
 import { bindThis } from '@/decorators.js';
+import { MetaService } from '@/core/MetaService.js';
+import { MiMeta } from '@/models/Meta.js';
+import Logger from '@/logger.js';
+import { LoggerService } from './LoggerService.js';
+
+export const supportedCaptchaProviders = ['none', 'hcaptcha', 'mcaptcha', 'recaptcha', 'turnstile', 'testcaptcha'] as const;
+export type CaptchaProvider = typeof supportedCaptchaProviders[number];
+
+export const captchaErrorCodes = {
+	invalidProvider: Symbol('invalidProvider'),
+	invalidParameters: Symbol('invalidParameters'),
+	noResponseProvided: Symbol('noResponseProvided'),
+	requestFailed: Symbol('requestFailed'),
+	verificationFailed: Symbol('verificationFailed'),
+	unknown: Symbol('unknown'),
+} as const;
+export type CaptchaErrorCode = typeof captchaErrorCodes[keyof typeof captchaErrorCodes];
+
+export type CaptchaSetting = {
+	provider: CaptchaProvider;
+	hcaptcha: {
+		siteKey: string | null;
+		secretKey: string | null;
+	}
+	mcaptcha: {
+		siteKey: string | null;
+		secretKey: string | null;
+		instanceUrl: string | null;
+	}
+	recaptcha: {
+		siteKey: string | null;
+		secretKey: string | null;
+	}
+	turnstile: {
+		siteKey: string | null;
+		secretKey: string | null;
+	}
+}
+
+export class CaptchaError extends Error {
+	public readonly code: CaptchaErrorCode;
+	public readonly cause?: unknown;
+
+	constructor(code: CaptchaErrorCode, message: string, cause?: unknown) {
+		super(message);
+		this.code = code;
+		this.cause = cause;
+		this.name = 'CaptchaError';
+	}
+}
+
+export type CaptchaSaveSuccess = {
+	success: true;
+}
+export type CaptchaSaveFailure = {
+	success: false;
+	error: CaptchaError;
+}
+export type CaptchaSaveResult = CaptchaSaveSuccess | CaptchaSaveFailure;
 
 type CaptchaResponse = {
 	success: boolean;
@@ -14,9 +73,14 @@ type CaptchaResponse = {
 
 @Injectable()
 export class CaptchaService {
+	private readonly logger: Logger;
+
 	constructor(
 		private httpRequestService: HttpRequestService,
+		private metaService: MetaService,
+		loggerService: LoggerService,
 	) {
+		this.logger = loggerService.getLogger('captcha');
 	}
 
 	@bindThis
@@ -44,32 +108,32 @@ export class CaptchaService {
 	@bindThis
 	public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise<void> {
 		if (response == null) {
-			throw new Error('recaptcha-failed: no response provided');
+			throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'recaptcha-failed: no response provided');
 		}
 
 		const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => {
-			throw new Error(`recaptcha-request-failed: ${err}`);
+			throw new CaptchaError(captchaErrorCodes.requestFailed, `recaptcha-request-failed: ${err}`);
 		});
 
 		if (result.success !== true) {
 			const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
-			throw new Error(`recaptcha-failed: ${errorCodes}`);
+			throw new CaptchaError(captchaErrorCodes.verificationFailed, `recaptcha-failed: ${errorCodes}`);
 		}
 	}
 
 	@bindThis
 	public async verifyHcaptcha(secret: string, response: string | null | undefined): Promise<void> {
 		if (response == null) {
-			throw new Error('hcaptcha-failed: no response provided');
+			throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'hcaptcha-failed: no response provided');
 		}
 
 		const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => {
-			throw new Error(`hcaptcha-request-failed: ${err}`);
+			throw new CaptchaError(captchaErrorCodes.requestFailed, `hcaptcha-request-failed: ${err}`);
 		});
 
 		if (result.success !== true) {
 			const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
-			throw new Error(`hcaptcha-failed: ${errorCodes}`);
+			throw new CaptchaError(captchaErrorCodes.verificationFailed, `hcaptcha-failed: ${errorCodes}`);
 		}
 	}
 
@@ -77,7 +141,7 @@ export class CaptchaService {
 	@bindThis
 	public async verifyMcaptcha(secret: string, siteKey: string, instanceHost: string, response: string | null | undefined): Promise<void> {
 		if (response == null) {
-			throw new Error('mcaptcha-failed: no response provided');
+			throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'mcaptcha-failed: no response provided');
 		}
 
 		const endpointUrl = new URL('/api/v1/pow/siteverify', instanceHost);
@@ -91,46 +155,251 @@ export class CaptchaService {
 			headers: {
 				'Content-Type': 'application/json',
 			},
-		});
+		}, { throwErrorWhenResponseNotOk: false });
 
 		if (result.status !== 200) {
-			throw new Error('mcaptcha-failed: mcaptcha didn\'t return 200 OK');
+			throw new CaptchaError(captchaErrorCodes.requestFailed, 'mcaptcha-failed: mcaptcha didn\'t return 200 OK');
 		}
 
 		const resp = (await result.json()) as { valid: boolean };
 
 		if (!resp.valid) {
-			throw new Error('mcaptcha-request-failed');
+			throw new CaptchaError(captchaErrorCodes.verificationFailed, 'mcaptcha-request-failed');
 		}
 	}
 
 	@bindThis
 	public async verifyTurnstile(secret: string, response: string | null | undefined): Promise<void> {
 		if (response == null) {
-			throw new Error('turnstile-failed: no response provided');
+			throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'turnstile-failed: no response provided');
 		}
 
 		const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => {
-			throw new Error(`turnstile-request-failed: ${err}`);
+			throw new CaptchaError(captchaErrorCodes.requestFailed, `turnstile-request-failed: ${err}`);
 		});
 
 		if (result.success !== true) {
 			const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
-			throw new Error(`turnstile-failed: ${errorCodes}`);
+			throw new CaptchaError(captchaErrorCodes.verificationFailed, `turnstile-failed: ${errorCodes}`);
 		}
 	}
 
 	@bindThis
 	public async verifyTestcaptcha(response: string | null | undefined): Promise<void> {
 		if (response == null) {
-			throw new Error('testcaptcha-failed: no response provided');
+			throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'testcaptcha-failed: no response provided');
 		}
 
 		const success = response === 'testcaptcha-passed';
 
 		if (!success) {
-			throw new Error('testcaptcha-failed');
+			throw new CaptchaError(captchaErrorCodes.verificationFailed, 'testcaptcha-failed');
+		}
+	}
+
+	@bindThis
+	public async get(): Promise<CaptchaSetting> {
+		const meta = await this.metaService.fetch(true);
+
+		let provider: CaptchaProvider;
+		switch (true) {
+			case meta.enableHcaptcha: {
+				provider = 'hcaptcha';
+				break;
+			}
+			case meta.enableMcaptcha: {
+				provider = 'mcaptcha';
+				break;
+			}
+			case meta.enableRecaptcha: {
+				provider = 'recaptcha';
+				break;
+			}
+			case meta.enableTurnstile: {
+				provider = 'turnstile';
+				break;
+			}
+			case meta.enableTestcaptcha: {
+				provider = 'testcaptcha';
+				break;
+			}
+			default: {
+				provider = 'none';
+				break;
+			}
+		}
+
+		return {
+			provider: provider,
+			hcaptcha: {
+				siteKey: meta.hcaptchaSiteKey,
+				secretKey: meta.hcaptchaSecretKey,
+			},
+			mcaptcha: {
+				siteKey: meta.mcaptchaSitekey,
+				secretKey: meta.mcaptchaSecretKey,
+				instanceUrl: meta.mcaptchaInstanceUrl,
+			},
+			recaptcha: {
+				siteKey: meta.recaptchaSiteKey,
+				secretKey: meta.recaptchaSecretKey,
+			},
+			turnstile: {
+				siteKey: meta.turnstileSiteKey,
+				secretKey: meta.turnstileSecretKey,
+			},
+		};
+	}
+
+	/**
+	 * captchaの設定を更新します. その際、フロントエンド側で受け取ったcaptchaからの戻り値を検証し、passした場合のみ設定を更新します.
+	 * 実際の検証処理はサービス内で定義されている各captchaプロバイダの検証関数に委譲します.
+	 *
+	 * @param provider 検証するcaptchaのプロバイダ
+	 * @param params
+	 * @param params.sitekey hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsitekey. それ以外のプロバイダでは無視されます
+	 * @param params.secret hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsecret. それ以外のプロバイダでは無視されます
+	 * @param params.instanceUrl mcaptchaの場合に指定するインスタンスのURL. それ以外のプロバイダでは無視されます
+	 * @param params.captchaResult フロントエンド側で受け取ったcaptchaプロバイダからの戻り値. この値を使ってサーバサイドでの検証を行います
+	 * @see verifyHcaptcha
+	 * @see verifyMcaptcha
+	 * @see verifyRecaptcha
+	 * @see verifyTurnstile
+	 * @see verifyTestcaptcha
+	 */
+	@bindThis
+	public async save(
+		provider: CaptchaProvider,
+		params?: {
+			sitekey?: string | null;
+			secret?: string | null;
+			instanceUrl?: string | null;
+			captchaResult?: string | null;
+		},
+	): Promise<CaptchaSaveResult> {
+		if (!supportedCaptchaProviders.includes(provider)) {
+			return {
+				success: false,
+				error: new CaptchaError(captchaErrorCodes.invalidProvider, `Invalid captcha provider: ${provider}`),
+			};
+		}
+
+		const operation = {
+			none: async () => {
+				await this.updateMeta(provider, params);
+			},
+			hcaptcha: async () => {
+				if (!params?.secret || !params.captchaResult) {
+					throw new CaptchaError(captchaErrorCodes.invalidParameters, 'hcaptcha-failed: secret and captureResult are required');
+				}
+
+				await this.verifyHcaptcha(params.secret, params.captchaResult);
+				await this.updateMeta(provider, params);
+			},
+			mcaptcha: async () => {
+				if (!params?.secret || !params.sitekey || !params.instanceUrl || !params.captchaResult) {
+					throw new CaptchaError(captchaErrorCodes.invalidParameters, 'mcaptcha-failed: secret, sitekey, instanceUrl and captureResult are required');
+				}
+
+				await this.verifyMcaptcha(params.secret, params.sitekey, params.instanceUrl, params.captchaResult);
+				await this.updateMeta(provider, params);
+			},
+			recaptcha: async () => {
+				if (!params?.secret || !params.captchaResult) {
+					throw new CaptchaError(captchaErrorCodes.invalidParameters, 'recaptcha-failed: secret and captureResult are required');
+				}
+
+				await this.verifyRecaptcha(params.secret, params.captchaResult);
+				await this.updateMeta(provider, params);
+			},
+			turnstile: async () => {
+				if (!params?.secret || !params.captchaResult) {
+					throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: secret and captureResult are required');
+				}
+
+				await this.verifyTurnstile(params.secret, params.captchaResult);
+				await this.updateMeta(provider, params);
+			},
+			testcaptcha: async () => {
+				if (!params?.captchaResult) {
+					throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: captureResult are required');
+				}
+
+				await this.verifyTestcaptcha(params.captchaResult);
+				await this.updateMeta(provider, params);
+			},
+		}[provider];
+
+		return operation()
+			.then(() => ({ success: true }) as CaptchaSaveSuccess)
+			.catch(err => {
+				this.logger.info(err);
+				const error = err instanceof CaptchaError
+					? err
+					: new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`);
+				return {
+					success: false,
+					error,
+				};
+			});
+	}
+
+	@bindThis
+	private async updateMeta(
+		provider: CaptchaProvider,
+		params?: {
+			sitekey?: string | null;
+			secret?: string | null;
+			instanceUrl?: string | null;
+		},
+	) {
+		const metaPartial: Partial<
+			Pick<
+				MiMeta,
+				('enableHcaptcha' | 'hcaptchaSiteKey' | 'hcaptchaSecretKey') |
+				('enableMcaptcha' | 'mcaptchaSitekey' | 'mcaptchaSecretKey' | 'mcaptchaInstanceUrl') |
+				('enableRecaptcha' | 'recaptchaSiteKey' | 'recaptchaSecretKey') |
+				('enableTurnstile' | 'turnstileSiteKey' | 'turnstileSecretKey') |
+				('enableTestcaptcha')
+			>
+		> = {
+			enableHcaptcha: provider === 'hcaptcha',
+			enableMcaptcha: provider === 'mcaptcha',
+			enableRecaptcha: provider === 'recaptcha',
+			enableTurnstile: provider === 'turnstile',
+			enableTestcaptcha: provider === 'testcaptcha',
+		};
+
+		const updateIfNotUndefined = <K extends keyof typeof metaPartial>(key: K, value: typeof metaPartial[K]) => {
+			if (value !== undefined) {
+				metaPartial[key] = value;
+			}
+		};
+		switch (provider) {
+			case 'hcaptcha': {
+				updateIfNotUndefined('hcaptchaSiteKey', params?.sitekey);
+				updateIfNotUndefined('hcaptchaSecretKey', params?.secret);
+				break;
+			}
+			case 'mcaptcha': {
+				updateIfNotUndefined('mcaptchaSitekey', params?.sitekey);
+				updateIfNotUndefined('mcaptchaSecretKey', params?.secret);
+				updateIfNotUndefined('mcaptchaInstanceUrl', params?.instanceUrl);
+				break;
+			}
+			case 'recaptcha': {
+				updateIfNotUndefined('recaptchaSiteKey', params?.sitekey);
+				updateIfNotUndefined('recaptchaSecretKey', params?.secret);
+				break;
+			}
+			case 'turnstile': {
+				updateIfNotUndefined('turnstileSiteKey', params?.sitekey);
+				updateIfNotUndefined('turnstileSecretKey', params?.secret);
+				break;
+			}
 		}
+
+		await this.metaService.update(metaPartial);
 	}
 }
 
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index 5bb194313d..c2462d8b3d 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -28,6 +28,8 @@ import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-d
 import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js';
 import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js';
 import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js';
+import * as ep___admin_captcha_current from './endpoints/admin/captcha/current.js';
+import * as ep___admin_captcha_save from './endpoints/admin/captcha/save.js';
 import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js';
 import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js';
 import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js';
@@ -416,6 +418,8 @@ const $admin_avatarDecorations_create: Provider = { provide: 'ep:admin/avatar-de
 const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-decorations/delete', useClass: ep___admin_avatarDecorations_delete.default };
 const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default };
 const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default };
+const $admin_captcha_current: Provider = { provide: 'ep:admin/captcha/current', useClass: ep___admin_captcha_current.default };
+const $admin_captcha_save: Provider = { provide: 'ep:admin/captcha/save', useClass: ep___admin_captcha_save.default };
 const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default };
 const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default };
 const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default };
@@ -808,6 +812,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
 		$admin_avatarDecorations_delete,
 		$admin_avatarDecorations_list,
 		$admin_avatarDecorations_update,
+		$admin_captcha_current,
+		$admin_captcha_save,
 		$admin_deleteAllFilesOfAUser,
 		$admin_unsetUserAvatar,
 		$admin_unsetUserBanner,
@@ -1194,6 +1200,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
 		$admin_avatarDecorations_delete,
 		$admin_avatarDecorations_list,
 		$admin_avatarDecorations_update,
+		$admin_captcha_current,
+		$admin_captcha_save,
 		$admin_deleteAllFilesOfAUser,
 		$admin_unsetUserAvatar,
 		$admin_unsetUserBanner,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 15809b2678..86728ef381 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -33,6 +33,8 @@ import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-d
 import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js';
 import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js';
 import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js';
+import * as ep___admin_captcha_current from './endpoints/admin/captcha/current.js';
+import * as ep___admin_captcha_save from './endpoints/admin/captcha/save.js';
 import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js';
 import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js';
 import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js';
@@ -420,6 +422,8 @@ const eps = [
 	['admin/avatar-decorations/delete', ep___admin_avatarDecorations_delete],
 	['admin/avatar-decorations/list', ep___admin_avatarDecorations_list],
 	['admin/avatar-decorations/update', ep___admin_avatarDecorations_update],
+	['admin/captcha/current', ep___admin_captcha_current],
+	['admin/captcha/save', ep___admin_captcha_save],
 	['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser],
 	['admin/unset-user-avatar', ep___admin_unsetUserAvatar],
 	['admin/unset-user-banner', ep___admin_unsetUserBanner],
diff --git a/packages/backend/src/server/api/endpoints/admin/captcha/current.ts b/packages/backend/src/server/api/endpoints/admin/captcha/current.ts
new file mode 100644
index 0000000000..63ec740348
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/captcha/current.ts
@@ -0,0 +1,70 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { CaptchaService, supportedCaptchaProviders } from '@/core/CaptchaService.js';
+
+export const meta = {
+	tags: ['admin', 'captcha'],
+
+	requireCredential: true,
+	requireAdmin: true,
+
+	// 実態はmetaの取得であるため
+	kind: 'read:admin:meta',
+
+	res: {
+		type: 'object',
+		properties: {
+			provider: {
+				type: 'string',
+				enum: supportedCaptchaProviders,
+			},
+			hcaptcha: {
+				type: 'object',
+				properties: {
+					siteKey: { type: 'string', nullable: true },
+					secretKey: { type: 'string', nullable: true },
+				},
+			},
+			mcaptcha: {
+				type: 'object',
+				properties: {
+					siteKey: { type: 'string', nullable: true },
+					secretKey: { type: 'string', nullable: true },
+					instanceUrl: { type: 'string', nullable: true },
+				},
+			},
+			recaptcha: {
+				type: 'object',
+				properties: {
+					siteKey: { type: 'string', nullable: true },
+					secretKey: { type: 'string', nullable: true },
+				},
+			},
+			turnstile: {
+				type: 'object',
+				properties: {
+					siteKey: { type: 'string', nullable: true },
+					secretKey: { type: 'string', nullable: true },
+				},
+			},
+		},
+	},
+} as const;
+
+export const paramDef = {} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private captchaService: CaptchaService,
+	) {
+		super(meta, paramDef, async () => {
+			return this.captchaService.get();
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/captcha/save.ts b/packages/backend/src/server/api/endpoints/admin/captcha/save.ts
new file mode 100644
index 0000000000..98ec278ebe
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/captcha/save.ts
@@ -0,0 +1,129 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { captchaErrorCodes, CaptchaService, supportedCaptchaProviders } from '@/core/CaptchaService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['admin', 'captcha'],
+
+	requireCredential: true,
+	requireAdmin: true,
+
+	// 実態はmetaの更新であるため
+	kind: 'write:admin:meta',
+
+	errors: {
+		invalidProvider: {
+			message: 'Invalid provider.',
+			code: 'INVALID_PROVIDER',
+			id: '14bf7ae1-80cc-4363-acb2-4fd61d086af0',
+			httpStatusCode: 400,
+		},
+		invalidParameters: {
+			message: 'Invalid parameters.',
+			code: 'INVALID_PARAMETERS',
+			id: '26654194-410e-44e2-b42e-460ff6f92476',
+			httpStatusCode: 400,
+		},
+		noResponseProvided: {
+			message: 'No response provided.',
+			code: 'NO_RESPONSE_PROVIDED',
+			id: '40acbba8-0937-41fb-bb3f-474514d40afe',
+			httpStatusCode: 400,
+		},
+		requestFailed: {
+			message: 'Request failed.',
+			code: 'REQUEST_FAILED',
+			id: '0f4fe2f1-2c15-4d6e-b714-efbfcde231cd',
+			httpStatusCode: 500,
+		},
+		verificationFailed: {
+			message: 'Verification failed.',
+			code: 'VERIFICATION_FAILED',
+			id: 'c41c067f-24f3-4150-84b2-b5a3ae8c2214',
+			httpStatusCode: 400,
+		},
+		unknown: {
+			message: 'unknown',
+			code: 'UNKNOWN',
+			id: 'f868d509-e257-42a9-99c1-42614b031a97',
+			httpStatusCode: 500,
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		provider: {
+			type: 'string',
+			enum: supportedCaptchaProviders,
+		},
+		captchaResult: {
+			type: 'string', nullable: true,
+		},
+		sitekey: {
+			type: 'string', nullable: true,
+		},
+		secret: {
+			type: 'string', nullable: true,
+		},
+		instanceUrl: {
+			type: 'string', nullable: true,
+		},
+	},
+	required: ['provider'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private captchaService: CaptchaService,
+	) {
+		super(meta, paramDef, async (ps) => {
+			const result = await this.captchaService.save(ps.provider, {
+				sitekey: ps.sitekey,
+				secret: ps.secret,
+				instanceUrl: ps.instanceUrl,
+				captchaResult: ps.captchaResult,
+			});
+
+			if (!result.success) {
+				switch (result.error.code) {
+					case captchaErrorCodes.invalidProvider:
+						throw new ApiError({
+							...meta.errors.invalidProvider,
+							message: result.error.message,
+						});
+					case captchaErrorCodes.invalidParameters:
+						throw new ApiError({
+							...meta.errors.invalidParameters,
+							message: result.error.message,
+						});
+					case captchaErrorCodes.noResponseProvided:
+						throw new ApiError({
+							...meta.errors.noResponseProvided,
+							message: result.error.message,
+						});
+					case captchaErrorCodes.requestFailed:
+						throw new ApiError({
+							...meta.errors.requestFailed,
+							message: result.error.message,
+						});
+					case captchaErrorCodes.verificationFailed:
+						throw new ApiError({
+							...meta.errors.verificationFailed,
+							message: result.error.message,
+						});
+					default:
+						throw new ApiError(meta.errors.unknown);
+				}
+			}
+		});
+	}
+}
diff --git a/packages/backend/test/unit/CaptchaService.ts b/packages/backend/test/unit/CaptchaService.ts
new file mode 100644
index 0000000000..51b70b05a1
--- /dev/null
+++ b/packages/backend/test/unit/CaptchaService.ts
@@ -0,0 +1,622 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { afterAll, beforeAll, beforeEach, describe, expect, jest } from '@jest/globals';
+import { Test, TestingModule } from '@nestjs/testing';
+import { Response } from 'node-fetch';
+import {
+	CaptchaError,
+	CaptchaErrorCode,
+	captchaErrorCodes,
+	CaptchaSaveResult,
+	CaptchaService,
+} from '@/core/CaptchaService.js';
+import { GlobalModule } from '@/GlobalModule.js';
+import { HttpRequestService } from '@/core/HttpRequestService.js';
+import { MetaService } from '@/core/MetaService.js';
+import { MiMeta } from '@/models/Meta.js';
+import { LoggerService } from '@/core/LoggerService.js';
+
+describe('CaptchaService', () => {
+	let app: TestingModule;
+	let service: CaptchaService;
+	let httpRequestService: jest.Mocked<HttpRequestService>;
+	let metaService: jest.Mocked<MetaService>;
+
+	beforeAll(async () => {
+		app = await Test.createTestingModule({
+			imports: [
+				GlobalModule,
+			],
+			providers: [
+				CaptchaService,
+				LoggerService,
+				{
+					provide: HttpRequestService, useFactory: () => ({ send: jest.fn() }),
+				},
+				{
+					provide: MetaService, useFactory: () => ({
+						fetch: jest.fn(),
+						update: jest.fn(),
+					}),
+				},
+			],
+		}).compile();
+
+		app.enableShutdownHooks();
+
+		service = app.get(CaptchaService);
+		httpRequestService = app.get(HttpRequestService) as jest.Mocked<HttpRequestService>;
+		metaService = app.get(MetaService) as jest.Mocked<MetaService>;
+	});
+
+	beforeEach(() => {
+		httpRequestService.send.mockClear();
+		metaService.update.mockClear();
+		metaService.fetch.mockClear();
+	});
+
+	afterAll(async () => {
+		await app.close();
+	});
+
+	function successMock(result: object) {
+		httpRequestService.send.mockResolvedValue({
+			ok: true,
+			status: 200,
+			json: async () => (result),
+		} as Response);
+	}
+
+	function failureHttpMock() {
+		httpRequestService.send.mockResolvedValue({
+			ok: false,
+			status: 400,
+		} as Response);
+	}
+
+	function failureVerificationMock(result: object) {
+		httpRequestService.send.mockResolvedValue({
+			ok: true,
+			status: 200,
+			json: async () => (result),
+		} as Response);
+	}
+
+	async function testCaptchaError(code: CaptchaErrorCode, test: () => Promise<void>) {
+		try {
+			await test();
+			expect(false).toBe(true);
+		} catch (e) {
+			expect(e instanceof CaptchaError).toBe(true);
+
+			const _e = e as CaptchaError;
+			expect(_e.code).toBe(code);
+		}
+	}
+
+	describe('verifyRecaptcha', () => {
+		test('success', async () => {
+			successMock({ success: true });
+			await service.verifyRecaptcha('secret', 'response');
+		});
+
+		test('noResponseProvided', async () => {
+			await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyRecaptcha('secret', null));
+		});
+
+		test('requestFailed', async () => {
+			failureHttpMock();
+			await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyRecaptcha('secret', 'response'));
+		});
+
+		test('verificationFailed', async () => {
+			failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] });
+			await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyRecaptcha('secret', 'response'));
+		});
+	});
+
+	describe('verifyHcaptcha', () => {
+		test('success', async () => {
+			successMock({ success: true });
+			await service.verifyHcaptcha('secret', 'response');
+		});
+
+		test('noResponseProvided', async () => {
+			await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyHcaptcha('secret', null));
+		});
+
+		test('requestFailed', async () => {
+			failureHttpMock();
+			await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyHcaptcha('secret', 'response'));
+		});
+
+		test('verificationFailed', async () => {
+			failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] });
+			await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyHcaptcha('secret', 'response'));
+		});
+	});
+
+	describe('verifyMcaptcha', () => {
+		const host = 'https://localhost';
+
+		test('success', async () => {
+			successMock({ valid: true });
+			await service.verifyMcaptcha('secret', 'sitekey', host, 'response');
+		});
+
+		test('noResponseProvided', async () => {
+			await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyMcaptcha('secret', 'sitekey', host, null));
+		});
+
+		test('requestFailed', async () => {
+			failureHttpMock();
+			await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyMcaptcha('secret', 'sitekey', host, 'response'));
+		});
+
+		test('verificationFailed', async () => {
+			failureVerificationMock({ valid: false });
+			await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyMcaptcha('secret', 'sitekey', host, 'response'));
+		});
+	});
+
+	describe('verifyTurnstile', () => {
+		test('success', async () => {
+			successMock({ success: true });
+			await service.verifyTurnstile('secret', 'response');
+		});
+
+		test('noResponseProvided', async () => {
+			await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyTurnstile('secret', null));
+		});
+
+		test('requestFailed', async () => {
+			failureHttpMock();
+			await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyTurnstile('secret', 'response'));
+		});
+
+		test('verificationFailed', async () => {
+			failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] });
+			await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyTurnstile('secret', 'response'));
+		});
+	});
+
+	describe('verifyTestcaptcha', () => {
+		test('success', async () => {
+			await service.verifyTestcaptcha('testcaptcha-passed');
+		});
+
+		test('noResponseProvided', async () => {
+			await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyTestcaptcha(null));
+		});
+
+		test('verificationFailed', async () => {
+			await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyTestcaptcha('testcaptcha-failed'));
+		});
+	});
+
+	describe('get', () => {
+		function setupMeta(meta: Partial<MiMeta>) {
+			metaService.fetch.mockResolvedValue(meta as MiMeta);
+		}
+
+		test('values', async () => {
+			setupMeta({
+				enableHcaptcha: false,
+				enableMcaptcha: false,
+				enableRecaptcha: false,
+				enableTurnstile: false,
+				enableTestcaptcha: false,
+				hcaptchaSiteKey: 'hcaptcha-sitekey',
+				hcaptchaSecretKey: 'hcaptcha-secret',
+				mcaptchaSitekey: 'mcaptcha-sitekey',
+				mcaptchaSecretKey: 'mcaptcha-secret',
+				mcaptchaInstanceUrl: 'https://localhost',
+				recaptchaSiteKey: 'recaptcha-sitekey',
+				recaptchaSecretKey: 'recaptcha-secret',
+				turnstileSiteKey: 'turnstile-sitekey',
+				turnstileSecretKey: 'turnstile-secret',
+			});
+
+			const result = await service.get();
+			expect(result.provider).toBe('none');
+			expect(result.hcaptcha.siteKey).toBe('hcaptcha-sitekey');
+			expect(result.hcaptcha.secretKey).toBe('hcaptcha-secret');
+			expect(result.mcaptcha.siteKey).toBe('mcaptcha-sitekey');
+			expect(result.mcaptcha.secretKey).toBe('mcaptcha-secret');
+			expect(result.mcaptcha.instanceUrl).toBe('https://localhost');
+			expect(result.recaptcha.siteKey).toBe('recaptcha-sitekey');
+			expect(result.recaptcha.secretKey).toBe('recaptcha-secret');
+			expect(result.turnstile.siteKey).toBe('turnstile-sitekey');
+			expect(result.turnstile.secretKey).toBe('turnstile-secret');
+		});
+
+		describe('provider', () => {
+			test('none', async () => {
+				setupMeta({
+					enableHcaptcha: false,
+					enableMcaptcha: false,
+					enableRecaptcha: false,
+					enableTurnstile: false,
+					enableTestcaptcha: false,
+				});
+
+				const result = await service.get();
+				expect(result.provider).toBe('none');
+			});
+
+			test('hcaptcha', async () => {
+				setupMeta({
+					enableHcaptcha: true,
+					enableMcaptcha: false,
+					enableRecaptcha: false,
+					enableTurnstile: false,
+					enableTestcaptcha: false,
+				});
+
+				const result = await service.get();
+				expect(result.provider).toBe('hcaptcha');
+			});
+
+			test('mcaptcha', async () => {
+				setupMeta({
+					enableHcaptcha: false,
+					enableMcaptcha: true,
+					enableRecaptcha: false,
+					enableTurnstile: false,
+					enableTestcaptcha: false,
+				});
+
+				const result = await service.get();
+				expect(result.provider).toBe('mcaptcha');
+			});
+
+			test('recaptcha', async () => {
+				setupMeta({
+					enableHcaptcha: false,
+					enableMcaptcha: false,
+					enableRecaptcha: true,
+					enableTurnstile: false,
+					enableTestcaptcha: false,
+				});
+
+				const result = await service.get();
+				expect(result.provider).toBe('recaptcha');
+			});
+
+			test('turnstile', async () => {
+				setupMeta({
+					enableHcaptcha: false,
+					enableMcaptcha: false,
+					enableRecaptcha: false,
+					enableTurnstile: true,
+					enableTestcaptcha: false,
+				});
+
+				const result = await service.get();
+				expect(result.provider).toBe('turnstile');
+			});
+
+			test('testcaptcha', async () => {
+				setupMeta({
+					enableHcaptcha: false,
+					enableMcaptcha: false,
+					enableRecaptcha: false,
+					enableTurnstile: false,
+					enableTestcaptcha: true,
+				});
+
+				const result = await service.get();
+				expect(result.provider).toBe('testcaptcha');
+			});
+		});
+	});
+
+	describe('save', () => {
+		const host = 'https://localhost';
+
+		describe('[success] 検証に成功した時だけ保存できる+他のプロバイダの設定値を誤って更新しない', () => {
+			beforeEach(() => {
+				successMock({ success: true, valid: true });
+			});
+
+			async function assertSuccess(promise: Promise<CaptchaSaveResult>, expectMeta: Partial<MiMeta>) {
+				await expect(promise)
+					.resolves
+					.toStrictEqual({ success: true });
+				const partialParams = metaService.update.mock.calls[0][0];
+				expect(partialParams).toStrictEqual(expectMeta);
+			}
+
+			test('none', async () => {
+				await assertSuccess(
+					service.save('none'),
+					{
+						enableHcaptcha: false,
+						enableMcaptcha: false,
+						enableRecaptcha: false,
+						enableTurnstile: false,
+						enableTestcaptcha: false,
+					},
+				);
+			});
+
+			test('hcaptcha', async () => {
+				await assertSuccess(
+					service.save('hcaptcha', {
+						sitekey: 'hcaptcha-sitekey',
+						secret: 'hcaptcha-secret',
+						captchaResult: 'hcaptcha-passed',
+					}),
+					{
+						enableHcaptcha: true,
+						enableMcaptcha: false,
+						enableRecaptcha: false,
+						enableTurnstile: false,
+						enableTestcaptcha: false,
+						hcaptchaSiteKey: 'hcaptcha-sitekey',
+						hcaptchaSecretKey: 'hcaptcha-secret',
+					},
+				);
+			});
+
+			test('mcaptcha', async () => {
+				await assertSuccess(
+					service.save('mcaptcha', {
+						sitekey: 'mcaptcha-sitekey',
+						secret: 'mcaptcha-secret',
+						instanceUrl: host,
+						captchaResult: 'mcaptcha-passed',
+					}),
+					{
+						enableHcaptcha: false,
+						enableMcaptcha: true,
+						enableRecaptcha: false,
+						enableTurnstile: false,
+						enableTestcaptcha: false,
+						mcaptchaSitekey: 'mcaptcha-sitekey',
+						mcaptchaSecretKey: 'mcaptcha-secret',
+						mcaptchaInstanceUrl: host,
+					},
+				);
+			});
+
+			test('recaptcha', async () => {
+				await assertSuccess(
+					service.save('recaptcha', {
+						sitekey: 'recaptcha-sitekey',
+						secret: 'recaptcha-secret',
+						captchaResult: 'recaptcha-passed',
+					}),
+					{
+						enableHcaptcha: false,
+						enableMcaptcha: false,
+						enableRecaptcha: true,
+						enableTurnstile: false,
+						enableTestcaptcha: false,
+						recaptchaSiteKey: 'recaptcha-sitekey',
+						recaptchaSecretKey: 'recaptcha-secret',
+					},
+				);
+			});
+
+			test('turnstile', async () => {
+				await assertSuccess(
+					service.save('turnstile', {
+						sitekey: 'turnstile-sitekey',
+						secret: 'turnstile-secret',
+						captchaResult: 'turnstile-passed',
+					}),
+					{
+						enableHcaptcha: false,
+						enableMcaptcha: false,
+						enableRecaptcha: false,
+						enableTurnstile: true,
+						enableTestcaptcha: false,
+						turnstileSiteKey: 'turnstile-sitekey',
+						turnstileSecretKey: 'turnstile-secret',
+					},
+				);
+			});
+
+			test('testcaptcha', async () => {
+				await assertSuccess(
+					service.save('testcaptcha', {
+						sitekey: 'testcaptcha-sitekey',
+						secret: 'testcaptcha-secret',
+						captchaResult: 'testcaptcha-passed',
+					}),
+					{
+						enableHcaptcha: false,
+						enableMcaptcha: false,
+						enableRecaptcha: false,
+						enableTurnstile: false,
+						enableTestcaptcha: true,
+					},
+				);
+			});
+		});
+
+		describe('[failure] 検証に失敗した場合は保存できない+設定値の更新そのものが発生しない', () => {
+			async function assertFailure(code: CaptchaErrorCode, promise: Promise<CaptchaSaveResult>) {
+				const res = await promise;
+				expect(res.success).toBe(false);
+				if (!res.success) {
+					expect(res.error.code).toBe(code);
+				}
+				expect(metaService.update).not.toBeCalled();
+			}
+
+			describe('invalidParameters', () => {
+				test('hcaptcha', async () => {
+					await assertFailure(
+						captchaErrorCodes.invalidParameters,
+						service.save('hcaptcha', {
+							sitekey: 'hcaptcha-sitekey',
+							secret: 'hcaptcha-secret',
+							captchaResult: null,
+						}),
+					);
+				});
+
+				test('mcaptcha', async () => {
+					await assertFailure(
+						captchaErrorCodes.invalidParameters,
+						service.save('mcaptcha', {
+							sitekey: 'mcaptcha-sitekey',
+							secret: 'mcaptcha-secret',
+							instanceUrl: host,
+							captchaResult: null,
+						}),
+					);
+				});
+
+				test('recaptcha', async () => {
+					await assertFailure(
+						captchaErrorCodes.invalidParameters,
+						service.save('recaptcha', {
+							sitekey: 'recaptcha-sitekey',
+							secret: 'recaptcha-secret',
+							captchaResult: null,
+						}),
+					);
+				});
+
+				test('turnstile', async () => {
+					await assertFailure(
+						captchaErrorCodes.invalidParameters,
+						service.save('turnstile', {
+							sitekey: 'turnstile-sitekey',
+							secret: 'turnstile-secret',
+							captchaResult: null,
+						}),
+					);
+				});
+
+				test('testcaptcha', async () => {
+					await assertFailure(
+						captchaErrorCodes.invalidParameters,
+						service.save('testcaptcha', {
+							captchaResult: null,
+						}),
+					);
+				});
+			});
+
+			describe('requestFailed', () => {
+				beforeEach(() => {
+					failureHttpMock();
+				});
+
+				test('hcaptcha', async () => {
+					await assertFailure(
+						captchaErrorCodes.requestFailed,
+						service.save('hcaptcha', {
+							sitekey: 'hcaptcha-sitekey',
+							secret: 'hcaptcha-secret',
+							captchaResult: 'hcaptcha-passed',
+						}),
+					);
+				});
+
+				test('mcaptcha', async () => {
+					await assertFailure(
+						captchaErrorCodes.requestFailed,
+						service.save('mcaptcha', {
+							sitekey: 'mcaptcha-sitekey',
+							secret: 'mcaptcha-secret',
+							instanceUrl: host,
+							captchaResult: 'mcaptcha-passed',
+						}),
+					);
+				});
+
+				test('recaptcha', async () => {
+					await assertFailure(
+						captchaErrorCodes.requestFailed,
+						service.save('recaptcha', {
+							sitekey: 'recaptcha-sitekey',
+							secret: 'recaptcha-secret',
+							captchaResult: 'recaptcha-passed',
+						}),
+					);
+				});
+
+				test('turnstile', async () => {
+					await assertFailure(
+						captchaErrorCodes.requestFailed,
+						service.save('turnstile', {
+							sitekey: 'turnstile-sitekey',
+							secret: 'turnstile-secret',
+							captchaResult: 'turnstile-passed',
+						}),
+					);
+				});
+
+				// testchapchaはrequestFailedがない
+			});
+
+			describe('verificationFailed', () => {
+				beforeEach(() => {
+					failureVerificationMock({ success: false, valid: false, 'error-codes': ['code01', 'code02'] });
+				});
+
+				test('hcaptcha', async () => {
+					await assertFailure(
+						captchaErrorCodes.verificationFailed,
+						service.save('hcaptcha', {
+							sitekey: 'hcaptcha-sitekey',
+							secret: 'hcaptcha-secret',
+							captchaResult: 'hccaptcha-passed',
+						}),
+					);
+				});
+
+				test('mcaptcha', async () => {
+					await assertFailure(
+						captchaErrorCodes.verificationFailed,
+						service.save('mcaptcha', {
+							sitekey: 'mcaptcha-sitekey',
+							secret: 'mcaptcha-secret',
+							instanceUrl: host,
+							captchaResult: 'mcaptcha-passed',
+						}),
+					);
+				});
+
+				test('recaptcha', async () => {
+					await assertFailure(
+						captchaErrorCodes.verificationFailed,
+						service.save('recaptcha', {
+							sitekey: 'recaptcha-sitekey',
+							secret: 'recaptcha-secret',
+							captchaResult: 'recaptcha-passed',
+						}),
+					);
+				});
+
+				test('turnstile', async () => {
+					await assertFailure(
+						captchaErrorCodes.verificationFailed,
+						service.save('turnstile', {
+							sitekey: 'turnstile-sitekey',
+							secret: 'turnstile-secret',
+							captchaResult: 'turnstile-passed',
+						}),
+					);
+				});
+
+				test('testcaptcha', async () => {
+					await assertFailure(
+						captchaErrorCodes.verificationFailed,
+						service.save('testcaptcha', {
+							captchaResult: 'testcaptcha-failed',
+						}),
+					);
+				});
+			});
+		});
+	});
+});
diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue
index 264cf9af06..b1167bbac6 100644
--- a/packages/frontend/src/components/MkCaptcha.vue
+++ b/packages/frontend/src/components/MkCaptcha.vue
@@ -30,6 +30,9 @@ import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmount
 import { defaultStore } from '@/store.js';
 
 // APIs provided by Captcha services
+// see: https://docs.hcaptcha.com/configuration/#javascript-api
+// see: https://developers.google.com/recaptcha/docs/display?hl=ja
+// see: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#explicitly-render-the-turnstile-widget
 export type Captcha = {
 	render(container: string | Node, options: {
 		readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
@@ -53,6 +56,7 @@ declare global {
 const props = defineProps<{
 	provider: CaptchaProvider;
 	sitekey: string | null; // null will show error on request
+	secretKey?: string | null;
 	instanceUrl?: string | null;
 	modelValue?: string | null;
 }>();
@@ -64,7 +68,7 @@ const emit = defineEmits<{
 const available = ref(false);
 
 const captchaEl = shallowRef<HTMLDivElement | undefined>();
-
+const captchaWidgetId = ref<string | undefined>(undefined);
 const testcaptchaInput = ref('');
 const testcaptchaPassed = ref(false);
 
@@ -94,6 +98,15 @@ const scriptId = computed(() => `script-${props.provider}`);
 
 const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
 
+watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => {
+	// 変更があったときはリフレッシュと再レンダリングをしておかないと、変更後の値で再検証が出来ない
+	if (available.value) {
+		callback(undefined);
+		clearWidget();
+		await requestRender();
+	}
+});
+
 if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') {
 	available.value = true;
 } else if (src.value !== null) {
@@ -106,14 +119,38 @@ if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha')
 }
 
 function reset() {
-	if (captcha.value.reset) captcha.value.reset();
+	if (captcha.value.reset && captchaWidgetId.value !== undefined) {
+		try {
+			captcha.value.reset(captchaWidgetId.value);
+		} catch (error: unknown) {
+			// ignore
+			if (_DEV_) console.warn(error);
+		}
+	}
 	testcaptchaPassed.value = false;
 	testcaptchaInput.value = '';
 }
 
+function remove() {
+	if (captcha.value.remove && captchaWidgetId.value) {
+		try {
+			if (_DEV_) console.log('remove', props.provider, captchaWidgetId.value);
+			captcha.value.remove(captchaWidgetId.value);
+		} catch (error: unknown) {
+			// ignore
+			if (_DEV_) console.warn(error);
+		}
+	}
+}
+
 async function requestRender() {
-	if (captcha.value.render && captchaEl.value instanceof Element) {
-		captcha.value.render(captchaEl.value, {
+	if (captcha.value.render && captchaEl.value instanceof Element && props.sitekey) {
+		// reCAPTCHAのレンダリング重複判定を回避するため、captchaEl配下に仮のdivを用意する.
+		// (同じdivに対して複数回renderを呼び出すとreCAPTCHAはエラーを返すので)
+		const elem = document.createElement('div');
+		captchaEl.value.appendChild(elem);
+
+		captchaWidgetId.value = captcha.value.render(elem, {
 			sitekey: props.sitekey,
 			theme: defaultStore.state.darkMode ? 'dark' : 'light',
 			callback: callback,
@@ -133,6 +170,23 @@ async function requestRender() {
 	}
 }
 
+function clearWidget() {
+	if (props.provider === 'mcaptcha') {
+		const container = document.getElementById('mcaptcha__widget-container');
+		if (container) {
+			container.innerHTML = '';
+		}
+	} else {
+		reset();
+		remove();
+
+		if (captchaEl.value) {
+			// レンダリング先のコンテナの中身を掃除し、フォームが増殖するのを抑止
+			captchaEl.value.innerHTML = '';
+		}
+	}
+}
+
 function callback(response?: string) {
 	emit('update:modelValue', typeof response === 'string' ? response : null);
 }
@@ -165,7 +219,7 @@ onUnmounted(() => {
 });
 
 onBeforeUnmount(() => {
-	reset();
+	clearWidget();
 });
 
 defineExpose({
diff --git a/packages/frontend/src/components/MkFormFooter.vue b/packages/frontend/src/components/MkFormFooter.vue
index f409f6ce50..96214a9542 100644
--- a/packages/frontend/src/components/MkFormFooter.vue
+++ b/packages/frontend/src/components/MkFormFooter.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div :class="$style.text">{{ i18n.tsx.thereAreNChanges({ n: form.modifiedCount.value }) }}</div>
 	<div style="margin-left: auto;" class="_buttons">
 		<MkButton danger rounded @click="form.discard"><i class="ti ti-x"></i> {{ i18n.ts.discard }}</MkButton>
-		<MkButton primary rounded @click="form.save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+		<MkButton primary rounded :disabled="!canSaving" @click="form.save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
 	</div>
 </div>
 </template>
@@ -18,7 +18,7 @@ import { } from 'vue';
 import MkButton from './MkButton.vue';
 import { i18n } from '@/i18n.js';
 
-const props = defineProps<{
+const props = withDefaults(defineProps<{
 	form: {
 		modifiedCount: {
 			value: number;
@@ -26,7 +26,10 @@ const props = defineProps<{
 		discard: () => void;
 		save: () => void;
 	};
-}>();
+	canSaving?: boolean;
+}>(), {
+	canSaving: true,
+});
 </script>
 
 <style lang="scss" module>
diff --git a/packages/frontend/src/index.html b/packages/frontend/src/index.html
index 08ff0c58dd..0be589262f 100644
--- a/packages/frontend/src/index.html
+++ b/packages/frontend/src/index.html
@@ -18,7 +18,7 @@
 		http-equiv="Content-Security-Policy"
 		content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/;
 			worker-src 'self';
-			script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh;
+			script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://*.recaptcha.net https://*.gstatic.com https://challenges.cloudflare.com https://esm.sh;
 			style-src 'self' 'unsafe-inline';
 			img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
 			media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index ea1b673de9..589ace0155 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -11,6 +11,7 @@ import * as Misskey from 'misskey-js';
 import type { ComponentProps as CP } from 'vue-component-type-helpers';
 import type { Form, GetFormResultType } from '@/scripts/form.js';
 import type { MenuItem } from '@/types/menu.js';
+import type { PostFormProps } from '@/types/post-form.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { defaultStore } from '@/store.js';
 import { i18n } from '@/i18n.js';
@@ -28,15 +29,15 @@ import { pleaseLogin } from '@/scripts/please-login.js';
 import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
 import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
 import { focusParent } from '@/scripts/focus.js';
-import type { PostFormProps } from '@/types/post-form.js';
 
 export const openingWindowsCount = ref(0);
 
+export type ApiWithDialogCustomErrors = Record<string, { title?: string; text: string; }>;
 export const apiWithDialog = (<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>(
 	endpoint: E,
 	data: P,
 	token?: string | null | undefined,
-	customErrors?: Record<string, { title?: string; text: string; }>,
+	customErrors?: ApiWithDialogCustomErrors,
 ) => {
 	const promise = misskeyApi(endpoint, data, token);
 	promiseDialog(promise, null, async (err) => {
diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue
index d07add4408..498cf13943 100644
--- a/packages/frontend/src/pages/admin/bot-protection.vue
+++ b/packages/frontend/src/pages/admin/bot-protection.vue
@@ -13,13 +13,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<template v-else-if="botProtectionForm.savedState.provider === 'turnstile'" #suffix>Turnstile</template>
 	<template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template>
 	<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
-	<template v-if="botProtectionForm.modified.value" #footer>
-		<MkFormFooter :form="botProtectionForm"/>
+	<template #footer>
+		<MkFormFooter :canSaving="canSaving" :form="botProtectionForm"/>
 	</template>
 
 	<div class="_gaps_m">
 		<MkRadios v-model="botProtectionForm.state.provider">
-			<option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
+			<option value="none">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
 			<option value="hcaptcha">hCaptcha</option>
 			<option value="mcaptcha">mCaptcha</option>
 			<option value="recaptcha">reCAPTCHA</option>
@@ -28,70 +28,125 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</MkRadios>
 
 		<template v-if="botProtectionForm.state.provider === 'hcaptcha'">
-			<MkInput v-model="botProtectionForm.state.hcaptchaSiteKey">
+			<MkInput v-model="botProtectionForm.state.hcaptchaSiteKey" debounce>
 				<template #prefix><i class="ti ti-key"></i></template>
 				<template #label>{{ i18n.ts.hcaptchaSiteKey }}</template>
 			</MkInput>
-			<MkInput v-model="botProtectionForm.state.hcaptchaSecretKey">
+			<MkInput v-model="botProtectionForm.state.hcaptchaSecretKey" debounce>
 				<template #prefix><i class="ti ti-key"></i></template>
 				<template #label>{{ i18n.ts.hcaptchaSecretKey }}</template>
 			</MkInput>
-			<FormSlot>
-				<template #label>{{ i18n.ts.preview }}</template>
-				<MkCaptcha provider="hcaptcha" :sitekey="botProtectionForm.state.hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
+			<FormSlot v-if="botProtectionForm.state.hcaptchaSiteKey">
+				<template #label>{{ i18n.ts._captcha.verify }}</template>
+				<MkCaptcha
+					v-model="captchaResult"
+					provider="hcaptcha"
+					:sitekey="botProtectionForm.state.hcaptchaSiteKey"
+					:secretKey="botProtectionForm.state.hcaptchaSecretKey"
+				/>
 			</FormSlot>
+			<MkInfo>
+				<div :class="$style.captchaInfoMsg">
+					<div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div>
+					<div>
+						<span>ref: </span><a href="https://docs.hcaptcha.com/#integration-testing-test-keys" target="_blank">hCaptcha Developer Guide</a>
+					</div>
+				</div>
+			</MkInfo>
 		</template>
+
 		<template v-else-if="botProtectionForm.state.provider === 'mcaptcha'">
-			<MkInput v-model="botProtectionForm.state.mcaptchaSiteKey">
+			<MkInput v-model="botProtectionForm.state.mcaptchaSiteKey" debounce>
 				<template #prefix><i class="ti ti-key"></i></template>
 				<template #label>{{ i18n.ts.mcaptchaSiteKey }}</template>
 			</MkInput>
-			<MkInput v-model="botProtectionForm.state.mcaptchaSecretKey">
+			<MkInput v-model="botProtectionForm.state.mcaptchaSecretKey" debounce>
 				<template #prefix><i class="ti ti-key"></i></template>
 				<template #label>{{ i18n.ts.mcaptchaSecretKey }}</template>
 			</MkInput>
-			<MkInput v-model="botProtectionForm.state.mcaptchaInstanceUrl">
+			<MkInput v-model="botProtectionForm.state.mcaptchaInstanceUrl" debounce>
 				<template #prefix><i class="ti ti-link"></i></template>
 				<template #label>{{ i18n.ts.mcaptchaInstanceUrl }}</template>
 			</MkInput>
 			<FormSlot v-if="botProtectionForm.state.mcaptchaSiteKey && botProtectionForm.state.mcaptchaInstanceUrl">
-				<template #label>{{ i18n.ts.preview }}</template>
-				<MkCaptcha provider="mcaptcha" :sitekey="botProtectionForm.state.mcaptchaSiteKey" :instanceUrl="botProtectionForm.state.mcaptchaInstanceUrl"/>
+				<template #label>{{ i18n.ts._captcha.verify }}</template>
+				<MkCaptcha
+					v-model="captchaResult"
+					provider="mcaptcha"
+					:sitekey="botProtectionForm.state.mcaptchaSiteKey"
+					:secretKey="botProtectionForm.state.mcaptchaSecretKey"
+					:instanceUrl="botProtectionForm.state.mcaptchaInstanceUrl"
+				/>
 			</FormSlot>
 		</template>
+
 		<template v-else-if="botProtectionForm.state.provider === 'recaptcha'">
-			<MkInput v-model="botProtectionForm.state.recaptchaSiteKey">
+			<MkInput v-model="botProtectionForm.state.recaptchaSiteKey" debounce>
 				<template #prefix><i class="ti ti-key"></i></template>
 				<template #label>{{ i18n.ts.recaptchaSiteKey }}</template>
 			</MkInput>
-			<MkInput v-model="botProtectionForm.state.recaptchaSecretKey">
+			<MkInput v-model="botProtectionForm.state.recaptchaSecretKey" debounce>
 				<template #prefix><i class="ti ti-key"></i></template>
 				<template #label>{{ i18n.ts.recaptchaSecretKey }}</template>
 			</MkInput>
 			<FormSlot v-if="botProtectionForm.state.recaptchaSiteKey">
-				<template #label>{{ i18n.ts.preview }}</template>
-				<MkCaptcha provider="recaptcha" :sitekey="botProtectionForm.state.recaptchaSiteKey"/>
+				<template #label>{{ i18n.ts._captcha.verify }}</template>
+				<MkCaptcha
+					v-model="captchaResult"
+					provider="recaptcha"
+					:sitekey="botProtectionForm.state.recaptchaSiteKey"
+					:secretKey="botProtectionForm.state.recaptchaSecretKey"
+				/>
 			</FormSlot>
+			<MkInfo>
+				<div :class="$style.captchaInfoMsg">
+					<div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div>
+					<div>
+						<span>ref: </span>
+						<a
+							href="https://developers.google.com/recaptcha/docs/faq?hl=ja#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do"
+							target="_blank"
+						>reCAPTCHA FAQ</a>
+					</div>
+				</div>
+			</MkInfo>
 		</template>
+
 		<template v-else-if="botProtectionForm.state.provider === 'turnstile'">
-			<MkInput v-model="botProtectionForm.state.turnstileSiteKey">
+			<MkInput v-model="botProtectionForm.state.turnstileSiteKey" debounce>
 				<template #prefix><i class="ti ti-key"></i></template>
 				<template #label>{{ i18n.ts.turnstileSiteKey }}</template>
 			</MkInput>
-			<MkInput v-model="botProtectionForm.state.turnstileSecretKey">
+			<MkInput v-model="botProtectionForm.state.turnstileSecretKey" debounce>
 				<template #prefix><i class="ti ti-key"></i></template>
 				<template #label>{{ i18n.ts.turnstileSecretKey }}</template>
 			</MkInput>
-			<FormSlot>
-				<template #label>{{ i18n.ts.preview }}</template>
-				<MkCaptcha provider="turnstile" :sitekey="botProtectionForm.state.turnstileSiteKey || '1x00000000000000000000AA'"/>
+			<FormSlot v-if="botProtectionForm.state.turnstileSiteKey">
+				<template #label>{{ i18n.ts._captcha.verify }}</template>
+				<MkCaptcha
+					v-model="captchaResult"
+					provider="turnstile"
+					:sitekey="botProtectionForm.state.turnstileSiteKey"
+					:secretKey="botProtectionForm.state.turnstileSecretKey"
+				/>
 			</FormSlot>
+			<MkInfo>
+				<div :class="$style.captchaInfoMsg">
+					<div>
+						{{ i18n.ts._captcha.testSiteKeyMessage }}
+					</div>
+					<div>
+						<span>ref: </span><a href="https://developers.cloudflare.com/turnstile/troubleshooting/testing/" target="_blank">Cloudflare Docs</a>
+					</div>
+				</div>
+			</MkInfo>
 		</template>
+
 		<template v-else-if="botProtectionForm.state.provider === 'testcaptcha'">
 			<MkInfo warn><span v-html="i18n.ts.testCaptchaWarning"></span></MkInfo>
 			<FormSlot>
-				<template #label>{{ i18n.ts.preview }}</template>
-				<MkCaptcha provider="testcaptcha"/>
+				<template #label>{{ i18n.ts._captcha.verify }}</template>
+				<MkCaptcha v-model="captchaResult" provider="testcaptcha" :sitekey="null"/>
 			</FormSlot>
 		</template>
 	</div>
@@ -99,7 +154,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, ref } from 'vue';
+import { computed, defineAsyncComponent, ref, watch } from 'vue';
+import * as Misskey from 'misskey-js';
 import MkRadios from '@/components/MkRadios.vue';
 import MkInput from '@/components/MkInput.vue';
 import FormSlot from '@/components/form/slot.vue';
@@ -111,49 +167,107 @@ import { useForm } from '@/scripts/use-form.js';
 import MkFormFooter from '@/components/MkFormFooter.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkInfo from '@/components/MkInfo.vue';
+import { ApiWithDialogCustomErrors } from '@/os.js';
 
 const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue'));
 
-const meta = await misskeyApi('admin/meta');
+const errorHandler: ApiWithDialogCustomErrors = {
+	// 検証リクエストそのものに失敗
+	'0f4fe2f1-2c15-4d6e-b714-efbfcde231cd': {
+		title: i18n.ts._captcha._error._requestFailed.title,
+		text: i18n.ts._captcha._error._requestFailed.text,
+	},
+	// 検証リクエストの結果が不正
+	'c41c067f-24f3-4150-84b2-b5a3ae8c2214': {
+		title: i18n.ts._captcha._error._verificationFailed.title,
+		text: i18n.ts._captcha._error._verificationFailed.text,
+	},
+	// 不明なエラー
+	'f868d509-e257-42a9-99c1-42614b031a97': {
+		title: i18n.ts._captcha._error._unknown.title,
+		text: i18n.ts._captcha._error._unknown.text,
+	},
+};
 
+const captchaResult = ref<string | null>(null);
+
+const meta = await misskeyApi('admin/captcha/current');
 const botProtectionForm = useForm({
-	provider: meta.enableHcaptcha
-		? 'hcaptcha'
-		: meta.enableRecaptcha
-			? 'recaptcha'
-			: meta.enableTurnstile
-				? 'turnstile'
-				: meta.enableMcaptcha
-					? 'mcaptcha'
-					: meta.enableTestcaptcha
-						? 'testcaptcha'
-						: null,
-	hcaptchaSiteKey: meta.hcaptchaSiteKey,
-	hcaptchaSecretKey: meta.hcaptchaSecretKey,
-	mcaptchaSiteKey: meta.mcaptchaSiteKey,
-	mcaptchaSecretKey: meta.mcaptchaSecretKey,
-	mcaptchaInstanceUrl: meta.mcaptchaInstanceUrl,
-	recaptchaSiteKey: meta.recaptchaSiteKey,
-	recaptchaSecretKey: meta.recaptchaSecretKey,
-	turnstileSiteKey: meta.turnstileSiteKey,
-	turnstileSecretKey: meta.turnstileSecretKey,
+	provider: meta.provider,
+	hcaptchaSiteKey: meta.hcaptcha.siteKey,
+	hcaptchaSecretKey: meta.hcaptcha.secretKey,
+	mcaptchaSiteKey: meta.mcaptcha.siteKey,
+	mcaptchaSecretKey: meta.mcaptcha.secretKey,
+	mcaptchaInstanceUrl: meta.mcaptcha.instanceUrl,
+	recaptchaSiteKey: meta.recaptcha.siteKey,
+	recaptchaSecretKey: meta.recaptcha.secretKey,
+	turnstileSiteKey: meta.turnstile.siteKey,
+	turnstileSecretKey: meta.turnstile.secretKey,
 }, async (state) => {
-	await os.apiWithDialog('admin/update-meta', {
-		enableHcaptcha: state.provider === 'hcaptcha',
-		hcaptchaSiteKey: state.hcaptchaSiteKey,
-		hcaptchaSecretKey: state.hcaptchaSecretKey,
-		enableMcaptcha: state.provider === 'mcaptcha',
-		mcaptchaSiteKey: state.mcaptchaSiteKey,
-		mcaptchaSecretKey: state.mcaptchaSecretKey,
-		mcaptchaInstanceUrl: state.mcaptchaInstanceUrl,
-		enableRecaptcha: state.provider === 'recaptcha',
-		recaptchaSiteKey: state.recaptchaSiteKey,
-		recaptchaSecretKey: state.recaptchaSecretKey,
-		enableTurnstile: state.provider === 'turnstile',
-		turnstileSiteKey: state.turnstileSiteKey,
-		turnstileSecretKey: state.turnstileSecretKey,
-		enableTestcaptcha: state.provider === 'testcaptcha',
-	});
-	fetchInstance(true);
+	const provider = state.provider;
+	if (provider === 'none') {
+		await os.apiWithDialog(
+			'admin/captcha/save',
+			{ provider: provider as Misskey.entities.AdminCaptchaSaveRequest['provider'] },
+			undefined,
+			errorHandler,
+		);
+	} else {
+		const sitekey = provider === 'hcaptcha'
+			? state.hcaptchaSiteKey
+			: provider === 'mcaptcha'
+				? state.mcaptchaSiteKey
+				: provider === 'recaptcha'
+					? state.recaptchaSiteKey
+					: provider === 'turnstile'
+						? state.turnstileSiteKey
+						: null;
+		const secret = provider === 'hcaptcha'
+			? state.hcaptchaSecretKey
+			: provider === 'mcaptcha'
+				? state.mcaptchaSecretKey
+				: provider === 'recaptcha'
+					? state.recaptchaSecretKey
+					: provider === 'turnstile'
+						? state.turnstileSecretKey
+						: null;
+
+		await os.apiWithDialog(
+			'admin/captcha/save',
+			{
+				provider: provider as Misskey.entities.AdminCaptchaSaveRequest['provider'],
+				sitekey: sitekey,
+				secret: secret,
+				instanceUrl: state.mcaptchaInstanceUrl,
+				captchaResult: captchaResult.value,
+			},
+			undefined,
+			errorHandler,
+		);
+	}
+
+	await fetchInstance(true);
+});
+
+watch(botProtectionForm.state, () => {
+	captchaResult.value = null;
 });
+
+const canSaving = computed((): boolean => {
+	return (botProtectionForm.state.provider === 'none') ||
+		(botProtectionForm.state.provider === 'hcaptcha' && !!captchaResult.value) ||
+		(botProtectionForm.state.provider === 'mcaptcha' && !!captchaResult.value) ||
+		(botProtectionForm.state.provider === 'recaptcha' && !!captchaResult.value) ||
+		(botProtectionForm.state.provider === 'turnstile' && !!captchaResult.value) ||
+		(botProtectionForm.state.provider === 'testcaptcha' && !!captchaResult.value);
+});
+
 </script>
+
+<style lang="scss" module>
+.captchaInfoMsg {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+</style>
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 50002f1983..3ff8fdc844 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -136,6 +136,12 @@ type AdminAvatarDecorationsListResponse = operations['admin___avatar-decorations
 // @public (undocumented)
 type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json'];
 
+// @public (undocumented)
+type AdminCaptchaCurrentResponse = operations['admin___captcha___current']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type AdminCaptchaSaveRequest = operations['admin___captcha___save']['requestBody']['content']['application/json'];
+
 // @public (undocumented)
 type AdminDeleteAccountRequest = operations['admin___delete-account']['requestBody']['content']['application/json'];
 
@@ -1261,6 +1267,8 @@ declare namespace entities {
         AdminAvatarDecorationsListRequest,
         AdminAvatarDecorationsListResponse,
         AdminAvatarDecorationsUpdateRequest,
+        AdminCaptchaCurrentResponse,
+        AdminCaptchaSaveRequest,
         AdminDeleteAllFilesOfAUserRequest,
         AdminUnsetUserAvatarRequest,
         AdminUnsetUserBannerRequest,
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index 1837f3db4f..3bcdae6a4a 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -250,6 +250,28 @@ declare module '../api.js' {
       credential?: string | null,
     ): Promise<SwitchCaseResponseType<E, P>>;
 
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *read:admin:meta*
+     */
+    request<E extends 'admin/captcha/current', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:admin:meta*
+     */
+    request<E extends 'admin/captcha/save', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
     /**
      * No description provided.
      * 
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index cb1f4dbe96..b016d5bbcf 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -36,6 +36,8 @@ import type {
 	AdminAvatarDecorationsListRequest,
 	AdminAvatarDecorationsListResponse,
 	AdminAvatarDecorationsUpdateRequest,
+	AdminCaptchaCurrentResponse,
+	AdminCaptchaSaveRequest,
 	AdminDeleteAllFilesOfAUserRequest,
 	AdminUnsetUserAvatarRequest,
 	AdminUnsetUserBannerRequest,
@@ -604,6 +606,8 @@ export type Endpoints = {
 	'admin/avatar-decorations/delete': { req: AdminAvatarDecorationsDeleteRequest; res: EmptyResponse };
 	'admin/avatar-decorations/list': { req: AdminAvatarDecorationsListRequest; res: AdminAvatarDecorationsListResponse };
 	'admin/avatar-decorations/update': { req: AdminAvatarDecorationsUpdateRequest; res: EmptyResponse };
+	'admin/captcha/current': { req: EmptyRequest; res: AdminCaptchaCurrentResponse };
+	'admin/captcha/save': { req: AdminCaptchaSaveRequest; res: EmptyResponse };
 	'admin/delete-all-files-of-a-user': { req: AdminDeleteAllFilesOfAUserRequest; res: EmptyResponse };
 	'admin/unset-user-avatar': { req: AdminUnsetUserAvatarRequest; res: EmptyResponse };
 	'admin/unset-user-banner': { req: AdminUnsetUserBannerRequest; res: EmptyResponse };
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index a8f474c25c..02be4848c7 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -39,6 +39,8 @@ export type AdminAvatarDecorationsDeleteRequest = operations['admin___avatar-dec
 export type AdminAvatarDecorationsListRequest = operations['admin___avatar-decorations___list']['requestBody']['content']['application/json'];
 export type AdminAvatarDecorationsListResponse = operations['admin___avatar-decorations___list']['responses']['200']['content']['application/json'];
 export type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json'];
+export type AdminCaptchaCurrentResponse = operations['admin___captcha___current']['responses']['200']['content']['application/json'];
+export type AdminCaptchaSaveRequest = operations['admin___captcha___save']['requestBody']['content']['application/json'];
 export type AdminDeleteAllFilesOfAUserRequest = operations['admin___delete-all-files-of-a-user']['requestBody']['content']['application/json'];
 export type AdminUnsetUserAvatarRequest = operations['admin___unset-user-avatar']['requestBody']['content']['application/json'];
 export type AdminUnsetUserBannerRequest = operations['admin___unset-user-banner']['requestBody']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 42ca05e057..e6a9df3f5a 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -215,6 +215,24 @@ export type paths = {
      */
     post: operations['admin___avatar-decorations___update'];
   };
+  '/admin/captcha/current': {
+    /**
+     * admin/captcha/current
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *read:admin:meta*
+     */
+    post: operations['admin___captcha___current'];
+  };
+  '/admin/captcha/save': {
+    /**
+     * admin/captcha/save
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:admin:meta*
+     */
+    post: operations['admin___captcha___save'];
+  };
   '/admin/delete-all-files-of-a-user': {
     /**
      * admin/delete-all-files-of-a-user
@@ -6564,6 +6582,128 @@ export type operations = {
       };
     };
   };
+  /**
+   * admin/captcha/current
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *read:admin:meta*
+   */
+  admin___captcha___current: {
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': {
+            /** @enum {string} */
+            provider: 'none' | 'hcaptcha' | 'mcaptcha' | 'recaptcha' | 'turnstile' | 'testcaptcha';
+            hcaptcha: {
+              siteKey: string | null;
+              secretKey: string | null;
+            };
+            mcaptcha: {
+              siteKey: string | null;
+              secretKey: string | null;
+              instanceUrl: string | null;
+            };
+            recaptcha: {
+              siteKey: string | null;
+              secretKey: string | null;
+            };
+            turnstile: {
+              siteKey: string | null;
+              secretKey: string | null;
+            };
+          };
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * admin/captcha/save
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:admin:meta*
+   */
+  admin___captcha___save: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** @enum {string} */
+          provider: 'none' | 'hcaptcha' | 'mcaptcha' | 'recaptcha' | 'turnstile' | 'testcaptcha';
+          captchaResult?: string | null;
+          sitekey?: string | null;
+          secret?: string | null;
+          instanceUrl?: string | null;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (without any results) */
+      204: {
+        content: never;
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
   /**
    * admin/delete-all-files-of-a-user
    * @description No description provided.
-- 
GitLab