diff --git a/packages/backend/migration/1730505338000-friendlyCaptcha.js b/packages/backend/migration/1730505338000-friendlyCaptcha.js new file mode 100644 index 0000000000..94a4f22dfa --- /dev/null +++ b/packages/backend/migration/1730505338000-friendlyCaptcha.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class friendlyCaptcha1730505338000 { + name = 'friendlyCaptcha1730505338000'; + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableFC" boolean NOT NULL DEFAULT false`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ADD "fcSiteKey" character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ADD "fcSecretKey" character varying(1024)`, undefined); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "fcSecretKey"`, undefined); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "fcSiteKey"`, undefined); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableFC"`, undefined); + } +} diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index f6b7955cd2..5a424890f2 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -10,6 +10,7 @@ import { bindThis } from '@/decorators.js'; type CaptchaResponse = { success: boolean; 'error-codes'?: string[]; + 'errors'?: string[]; }; @Injectable() @@ -73,6 +74,35 @@ export class CaptchaService { } } + @bindThis + public async verifyFriendlyCaptcha(secret: string, response: string | null | undefined): Promise { + if (response == null) { + throw new Error('recaptcha-failed: no response provided'); + } + + const result = await this.httpRequestService.send('https://api.friendlycaptcha.com/api/v1/siteverify', { + method: 'POST', + body: JSON.stringify({ + secret: secret, + solution: response, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (result.status !== 200) { + throw new Error('frc-failed: frc didn\'t return 200 OK'); + } + + const resp = await result.json() as CaptchaResponse; + + if (resp.success !== true) { + const errorCodes = resp['errors'] ? resp['errors'].join(', ') : ''; + throw new Error(`frc-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 { diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 1c463fb0c9..b2b9aebb79 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -98,6 +98,8 @@ export class MetaEntityService { recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, + enableFC: instance.enableFC, + fcSiteKey: instance.fcSiteKey, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png', diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 6f2c4ccf70..0ea6765d6a 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -269,6 +269,23 @@ export class MiMeta { }) public turnstileSecretKey: string | null; + @Column('boolean', { + default: false, + }) + public enableFC: boolean; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public fcSiteKey: string | null; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public fcSecretKey: string | null; + // chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること @Column('enum', { diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 92aff24b4b..decdbd5650 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -127,6 +127,14 @@ export const packedMetaLiteSchema = { type: 'string', optional: false, nullable: true, }, + enableFC: { + type: 'boolean', + optional: false, nullable: false, + }, + fcSiteKey: { + type: 'string', + optional: false, nullable: true, + }, enableAchievements: { type: 'boolean', optional: false, nullable: true, diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 9d33658756..6dee6ecd78 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -121,6 +121,7 @@ export class NodeinfoServerService { enableRecaptcha: meta.enableRecaptcha, enableMcaptcha: meta.enableMcaptcha, enableTurnstile: meta.enableTurnstile, + enableFC: meta.enableFC, maxNoteTextLength: this.config.maxNoteLength, maxRemoteNoteTextLength: this.config.maxRemoteNoteLength, maxCwLength: this.config.maxCwLength, diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index 709a044601..ac3b982742 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -118,6 +118,7 @@ export class ApiServerService { 'hcaptcha-response'?: string; 'g-recaptcha-response'?: string; 'turnstile-response'?: string; + 'frc-captcha-solution'?: string; } }>('/signup', (request, reply) => this.signupApiService.signup(request, reply)); diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index f21e1bd683..db860d710a 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -72,6 +72,7 @@ export class SignupApiService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'm-captcha-response'?: string; + 'frc-captcha-solution'?: string; } }>, reply: FastifyReply, @@ -104,6 +105,12 @@ export class SignupApiService { throw new FastifyReplyError(400, err); }); } + + if (this.meta.enableFC && this.meta.fcSecretKey) { + await this.captchaService.verifyFriendlyCaptcha(this.meta.fcSecretKey, body['frc-captcha-solution']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } } const username = body['username']; diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 21116ba402..6e368eff43 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -73,6 +73,14 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + enableFC: { + type: 'boolean', + optional: false, nullable: false, + }, + fcSiteKey: { + type: 'string', + optional: false, nullable: true, + }, swPublickey: { type: 'string', optional: false, nullable: true, @@ -219,6 +227,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + fcSecretKey: { + type: 'string', + optional: false, nullable: true, + }, sensitiveMediaDetection: { type: 'string', optional: false, nullable: false, @@ -600,6 +612,8 @@ export default class extends Endpoint { // eslint- recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, + enableFC: instance.enableFC, + fcSiteKey: instance.fcSiteKey, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl, @@ -634,6 +648,7 @@ export default class extends Endpoint { // eslint- mcaptchaSecretKey: instance.mcaptchaSecretKey, recaptchaSecretKey: instance.recaptchaSecretKey, turnstileSecretKey: instance.turnstileSecretKey, + fcSecretKey: instance.fcSecretKey, sensitiveMediaDetection: instance.sensitiveMediaDetection, sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity, setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 1a55dec322..98760bbcc3 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -81,6 +81,9 @@ export const paramDef = { enableTurnstile: { type: 'boolean' }, turnstileSiteKey: { type: 'string', nullable: true }, turnstileSecretKey: { type: 'string', nullable: true }, + enableFC: { type: 'boolean' }, + fcSiteKey: { type: 'string', nullable: true }, + fcSecretKey: { type: 'string', nullable: true }, sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] }, sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, setSensitiveFlagAutomatically: { type: 'boolean' }, @@ -383,6 +386,18 @@ export default class extends Endpoint { // eslint- set.turnstileSecretKey = ps.turnstileSecretKey; } + if (ps.enableFC !== undefined) { + set.enableFC = ps.enableFC; + } + + if (ps.fcSiteKey !== undefined) { + set.fcSiteKey = ps.fcSiteKey; + } + + if (ps.fcSecretKey !== undefined) { + set.fcSecretKey = ps.fcSecretKey; + } + if (ps.enableBotTrending !== undefined) { set.enableBotTrending = ps.enableBotTrending; } diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts index 06548fa7da..48da6ba27f 100644 --- a/packages/backend/test/e2e/2fa.ts +++ b/packages/backend/test/e2e/2fa.ts @@ -123,12 +123,14 @@ describe('2要素認証', () => { password: string, 'g-recaptcha-response'?: string | null, 'hcaptcha-response'?: string | null, + 'frc-captcha-solution'?: string | null, } => { return { username, password, 'g-recaptcha-response': null, 'hcaptcha-response': null, + 'frc-captcha-solution': null, }; }; diff --git a/packages/frontend-embed/src/index.html b/packages/frontend-embed/src/index.html index 2dbfcc5ddf..55be2f4ec1 100644 --- a/packages/frontend-embed/src/index.html +++ b/packages/frontend-embed/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://challenges.cloudflare.com https://esm.sh https://cdn.jsdelivr.net; 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/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index c5b6e0caed..ab00ea9930 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -27,9 +27,12 @@ export type Captcha = { execute(id: string): void; reset(id?: string): void; getResponse(id: string): string; + WidgetInstance(container: string | Node, options: { + readonly [_ in 'sitekey' | 'doneCallback' | 'errorCallback' | 'puzzleEndpoint']?: unknown; + }): void; }; -export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha'; +export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha' | 'fc'; type CaptchaContainer = { readonly [_ in CaptchaProvider]?: Captcha; @@ -60,6 +63,7 @@ const variable = computed(() => { case 'recaptcha': return 'grecaptcha'; case 'turnstile': return 'turnstile'; case 'mcaptcha': return 'mcaptcha'; + case 'fc': return 'friendlyChallenge'; } }); @@ -70,6 +74,7 @@ const src = computed(() => { case 'hcaptcha': return 'https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off'; case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit'; case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + case 'fc': return 'https://cdn.jsdelivr.net/npm/friendly-challenge@0.9.18/widget.min.js'; case 'mcaptcha': return null; } }); @@ -110,6 +115,14 @@ async function requestRender() { key: props.sitekey, }, }); + } else if (variable.value === 'friendlyChallenge' && captchaEl.value instanceof Element) { + new captcha.value.WidgetInstance(captchaEl.value, { + sitekey: props.sitekey, + doneCallback: callback, + errorCallback: callback, + }); + // The following line is needed so that the design gets applied without it the captcha will look broken + captchaEl.value.className = 'frc-captcha'; } else { window.setTimeout(requestRender, 1); } diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index 5a5c712a48..4c55831a3a 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -70,6 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only +