Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(frontend): Botプロテクションの設定変更時は実際に検証を通過しないと保存できないようにする #15151

Open
wants to merge 16 commits into
base: develop
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- Fix: 公開範囲がホームのノートの埋め込みウィジェットが読み込まれない問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/803)
- Fix: 絵文字管理画面で一部の絵文字が表示されない問題を修正
- Fix: Botプロテクションの設定変更時は実際に検証を通過しないと保存できないように( #15137 )

### Server
- Fix: ユーザーのプロフィール画面をアドレス入力などで直接表示した際に概要タブの描画に失敗する問題の修正( #15032 )
Expand Down
213 changes: 199 additions & 14 deletions packages/backend/src/core/CaptchaService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,40 @@
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';

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 class CaptchaError extends Error {
public readonly code: CaptchaErrorCode;

constructor(code: CaptchaErrorCode, message: string) {
super(message);
this.code = code;
this.name = 'CaptchaError';
}
}

export type CaptchaSaveSuccess = {
success: true;
}
export type CaptchaSaveFailure = {
success: false;
error: CaptchaError;
}
export type CaptchaSaveResult = CaptchaSaveSuccess | CaptchaSaveFailure;

type CaptchaResponse = {
success: boolean;
Expand All @@ -16,6 +50,7 @@ type CaptchaResponse = {
export class CaptchaService {
constructor(
private httpRequestService: HttpRequestService,
private metaService: MetaService,
) {
}

Expand Down Expand Up @@ -44,40 +79,40 @@ 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}`);
}
}

// https://codeberg.org/Gusted/mCaptcha/src/branch/main/mcaptcha.go
@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);
Expand All @@ -94,43 +129,193 @@ export class CaptchaService {
});

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');
}
}

/**
* 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 => {
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);
}
}

4 changes: 4 additions & 0 deletions packages/backend/src/server/api/EndpointsModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ 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_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';
Expand Down Expand Up @@ -416,6 +417,7 @@ 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_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 };
Expand Down Expand Up @@ -808,6 +810,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_avatarDecorations_delete,
$admin_avatarDecorations_list,
$admin_avatarDecorations_update,
$admin_captcha_save,
$admin_deleteAllFilesOfAUser,
$admin_unsetUserAvatar,
$admin_unsetUserBanner,
Expand Down Expand Up @@ -1194,6 +1197,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_avatarDecorations_delete,
$admin_avatarDecorations_list,
$admin_avatarDecorations_update,
$admin_captcha_save,
$admin_deleteAllFilesOfAUser,
$admin_unsetUserAvatar,
$admin_unsetUserBanner,
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/server/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ 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_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';
Expand Down Expand Up @@ -420,6 +421,7 @@ 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/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],
Expand Down
Loading
Loading