feat(captcha): make ALTCHA the only captcha (#3035)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-29 17:00:15 +02:00
1 parent d433a039b5
commit 4f968bbc47
209 files changed
+4097 -8692

No files matched your search

@@ -1,14 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
import {createChallenge, randomInt, verifySolution} from 'altcha-lib';
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
import type {Challenge} from 'altcha-lib/types';
import {ms} from 'itty-time';
import {z} from 'zod';
export const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
const ALTCHA_CHALLENGE_TTL_MS = ms('10 minutes');
const ALTCHA_MAX_TOKEN_LENGTH = 4096;
const HEX_PATTERN = /^[0-9a-f]+$/u;
@@ -56,8 +55,7 @@ function decodePayload(token: string): AltchaPayload | null {
}
}
export class AltchaProvider implements ICaptchaProvider {
readonly type: CaptchaProviderType = 'altcha';
export class AltchaProvider {
private readonly options: AltchaProviderOptions;
private readonly now: () => number;
@@ -79,7 +77,7 @@ export class AltchaProvider implements ICaptchaProvider {
});
}
async verify({token}: VerifyCaptchaParams): Promise<boolean> {
async verify({token}: {token: string}): Promise<boolean> {
const payload = decodePayload(token);
if (!payload) return false;
try {
@@ -1,10 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
export class HcaptchaProvider extends HttpCaptchaProvider {
readonly type: CaptchaProviderType = 'hcaptcha';
protected readonly verifyUrl = 'https://api.hcaptcha.com/siteverify';
protected readonly providerName = 'hCaptcha';
}
@@ -1,105 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
import {ms} from 'itty-time';
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)';
const DEFAULT_TIMEOUT = ms('10 seconds');
export interface HttpCaptchaProviderOptions {
secretKey: string;
logger?: LoggerInterface;
timeoutMs?: number;
userAgent?: string;
fetchFn?: typeof fetch;
}
interface CaptchaVerifyResponse {
success: boolean;
'error-codes'?: Array<string>;
hostname?: string;
challenge_ts?: string;
score?: number;
}
function isCaptchaVerifyResponse(value: unknown): value is CaptchaVerifyResponse {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const data = value as Record<string, unknown>;
const errorCodes = data['error-codes'];
return (
typeof data.success === 'boolean' &&
(errorCodes === undefined || (Array.isArray(errorCodes) && errorCodes.every((code) => typeof code === 'string'))) &&
(data.hostname === undefined || typeof data.hostname === 'string') &&
(data.challenge_ts === undefined || typeof data.challenge_ts === 'string') &&
(data.score === undefined ||
(typeof data.score === 'number' && Number.isFinite(data.score) && data.score >= 0 && data.score <= 1))
);
}
export abstract class HttpCaptchaProvider implements ICaptchaProvider {
abstract readonly type: CaptchaProviderType;
protected readonly secretKey: string;
protected readonly logger: LoggerInterface | undefined;
protected readonly timeoutMs: number;
protected readonly userAgent: string;
protected readonly fetchFn: typeof fetch;
protected abstract readonly verifyUrl: string;
protected abstract readonly providerName: string;
constructor(options: HttpCaptchaProviderOptions) {
this.secretKey = options.secretKey;
this.logger = options.logger;
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
this.fetchFn = options.fetchFn ?? fetch;
}
async verify({token, remoteIp}: VerifyCaptchaParams): Promise<boolean> {
try {
const body = new URLSearchParams();
body.append('secret', this.secretKey);
body.append('response', token);
if (remoteIp) {
body.append('remoteip', remoteIp);
}
const response = await this.fetchFn(this.verifyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': this.userAgent,
},
body: body.toString(),
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok) {
await response.body?.cancel().catch(() => {
this.logger?.warn({status: response.status}, `${this.providerName} failed to cancel discarded response body`);
});
this.logger?.error({status: response.status}, `${this.providerName} verify request failed`);
return false;
}
const data: unknown = await response.json();
if (!isCaptchaVerifyResponse(data)) {
this.logger?.error({}, `${this.providerName} returned an invalid verification response`);
return false;
}
if (!data.success) {
this.logger?.warn({errorCodes: data['error-codes']}, `${this.providerName} verification failed`);
return false;
}
return this.validateResponse(data);
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
this.logger?.error({}, `${this.providerName} verification timed out after ${this.timeoutMs}ms`);
} else {
this.logger?.error({error}, `Error verifying ${this.providerName} token`);
}
return false;
}
}
protected validateResponse(_data: CaptchaVerifyResponse): boolean {
return true;
}
}
@@ -1,40 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
import type {HttpCaptchaProviderOptions} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
const DEFAULT_MINIMUM_SCORE = 0.5;
interface RecaptchaVerifyResponse {
success: boolean;
'error-codes'?: Array<string>;
score?: number;
}
export interface RecaptchaProviderOptions extends HttpCaptchaProviderOptions {
minimumScore?: number;
}
export class RecaptchaProvider extends HttpCaptchaProvider {
readonly type: CaptchaProviderType = 'recaptcha';
protected readonly verifyUrl = 'https://www.google.com/recaptcha/api/siteverify';
protected readonly providerName = 'reCAPTCHA';
private readonly minimumScore: number;
constructor(options: RecaptchaProviderOptions) {
super(options);
this.minimumScore = options.minimumScore ?? DEFAULT_MINIMUM_SCORE;
}
protected override validateResponse(data: RecaptchaVerifyResponse): boolean {
if (data.score !== undefined && data.score < this.minimumScore) {
this.logger?.warn(
{score: data.score, minimumScore: this.minimumScore},
'reCAPTCHA score below minimum threshold',
);
return false;
}
return true;
}
}
@@ -1,11 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
export class TestCaptchaProvider implements ICaptchaProvider {
readonly type: CaptchaProviderType = 'test';
async verify(_params: VerifyCaptchaParams): Promise<boolean> {
return true;
}
}
@@ -1,10 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
export class TurnstileProvider extends HttpCaptchaProvider {
readonly type: CaptchaProviderType = 'turnstile';
protected readonly verifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
protected readonly providerName = 'Turnstile';
}
@@ -1,11 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
export class UnavailableCaptchaProvider implements ICaptchaProvider {
readonly type: CaptchaProviderType = 'unavailable';
async verify(_params: VerifyCaptchaParams): Promise<boolean> {
return true;
}
}