Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 55 additions & 17 deletions backend/apps/cloud/src/captcha/captcha.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,23 @@ import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'

import { AppLoggerService } from '../logger/logger.service'
import { getIPFromHeaders } from '../common/utils'
import { getIPFromHeaders, checkRateLimit } from '../common/utils'
import { CaptchaService, DUMMY_PIDS } from './captcha.service'
import { BotDetectionGuard } from '../common/guards/bot-detection.guard'
import { BotDetection } from '../common/decorators/bot-detection.decorator'
import { VerifyDto } from './dtos/manual.dto'
import { ValidateDto } from './dtos/validate.dto'
import { GenerateDto, DEFAULT_THEME } from './dtos/generate.dto'
import { GenerateDto } from './dtos/generate.dto'

dayjs.extend(utc)

// Rate limit: 30 requests per IP per minute for challenge generation
const CAPTCHA_GENERATE_RL_REQUESTS_IP = 30
const CAPTCHA_GENERATE_RL_TIMEOUT = 60 // 1 minute

// Rate limit: 100 requests per project per minute for challenge generation
const CAPTCHA_GENERATE_RL_REQUESTS_PID = 100

@Controller({
version: '1',
path: 'captcha',
Expand All @@ -36,14 +43,33 @@ export class CaptchaController {
@HttpCode(200)
@UseGuards(BotDetectionGuard)
@BotDetection()
async generateCaptcha(@Body() generateDTO: GenerateDto): Promise<any> {
async generateCaptcha(
@Body() generateDTO: GenerateDto,
@Headers() headers,
@Ip() reqIP,
): Promise<any> {
this.logger.log({ generateDTO }, 'POST /captcha/generate')

const { theme = DEFAULT_THEME, pid } = generateDTO
const { pid } = generateDTO
const ip = getIPFromHeaders(headers) || reqIP || ''

// Rate limit by IP and PID to prevent abuse
await checkRateLimit(
ip,
'captcha-generate',
CAPTCHA_GENERATE_RL_REQUESTS_IP,
CAPTCHA_GENERATE_RL_TIMEOUT,
)
await checkRateLimit(
pid,
'captcha-generate',
CAPTCHA_GENERATE_RL_REQUESTS_PID,
CAPTCHA_GENERATE_RL_TIMEOUT,
)

await this.captchaService.validatePIDForCAPTCHA(pid)

return this.captchaService.generateCaptcha(theme)
return this.captchaService.generateChallenge(pid)
}

@Post('/verify')
Expand All @@ -55,35 +81,47 @@ export class CaptchaController {
@Headers() headers,
@Ip() reqIP,
): Promise<any> {
this.logger.log({ manualDTO: verifyDto }, 'POST /captcha/verify')
// todo: add origin checks
this.logger.log({ verifyDto }, 'POST /captcha/verify')

const { code, hash, pid } = verifyDto
const { challenge, nonce, solution, pid } = verifyDto

await this.captchaService.validatePIDForCAPTCHA(pid)

const timestamp = dayjs.utc().unix() * 1000

// For dummy (test) PIDs
if (pid === DUMMY_PIDS.ALWAYS_PASS) {
const dummyToken = this.captchaService.generateDummyToken()
const dummyToken = await this.captchaService.generateDummyToken()
return {
success: true,
token: dummyToken,
timestamp,
hash,
challenge,
pid,
}
}

if (
pid === DUMMY_PIDS.ALWAYS_FAIL ||
!this.captchaService.verifyCaptcha(code, hash)
) {
throw new ForbiddenException('Incorrect captcha')
if (pid === DUMMY_PIDS.ALWAYS_FAIL) {
throw new ForbiddenException('PoW verification failed')
}

const token = await this.captchaService.generateToken(pid, hash, timestamp)
// Verify the PoW solution
const isValid = await this.captchaService.verifyPoW(
challenge,
nonce,
solution,
pid,
)

if (!isValid) {
throw new ForbiddenException('PoW verification failed')
}

const token = await this.captchaService.generateToken(
pid,
challenge,
timestamp,
)

const ip = getIPFromHeaders(headers) || reqIP || ''

Expand All @@ -99,7 +137,7 @@ export class CaptchaController {
success: true,
token,
timestamp,
hash,
challenge,
pid,
}
}
Expand Down
160 changes: 126 additions & 34 deletions backend/apps/cloud/src/captcha/captcha.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Injectable, BadRequestException } from '@nestjs/common'
import svgCaptcha from 'svg-captcha'
import * as crypto from 'crypto'
import CryptoJS from 'crypto-js'
import _toLower from 'lodash/toLower'
import { UAParser } from '@ua-parser-js/pro-business'
import _values from 'lodash/values'
import _includes from 'lodash/includes'
Expand All @@ -14,16 +13,21 @@ import {
redis,
isValidPID,
getRedisCaptchaKey,
CAPTCHA_SALT,
CAPTCHA_TOKEN_LIFETIME,
} from '../common/constants'
import { getGeoDetails, hash } from '../common/utils'
import { GeneratedCaptcha } from './interfaces/generated-captcha'
import { getGeoDetails } from '../common/utils'
import { GeneratedChallenge } from './interfaces/generated-captcha'
import { captchaTransformer } from './utils/transformers'
import { clickhouse } from '../common/integrations/clickhouse'

dayjs.extend(utc)

// Default difficulty: number of leading hex zeros required (4 = ~65k iterations avg)
export const DEFAULT_POW_DIFFICULTY = 4

// Challenge TTL in seconds (5 minutes)
const CHALLENGE_TTL = 300

export const DUMMY_PIDS = {
ALWAYS_PASS: 'AP00000000000',
ALWAYS_FAIL: 'FAIL000000000',
Expand All @@ -48,7 +52,9 @@ const decryptString = (text: string, key: string): string => {
return bytes.toString(CryptoJS.enc.Utf8)
}

const captchaString = (text: string) => `${_toLower(text)}${CAPTCHA_SALT}`
const getChallengeCacheKey = (challenge: string): string => {
return `pow_challenge:${challenge}`
}

const isTokenAlreadyUsed = async (token: string): Promise<boolean> => {
const captchaKey = getRedisCaptchaKey(token)
Expand All @@ -58,7 +64,7 @@ const isTokenAlreadyUsed = async (token: string): Promise<boolean> => {
return true
}

await redis.set(captchaKey, '1', 'EX', CAPTCHA_TOKEN_LIFETIME)
await redis.set(captchaKey, '1', 'EX', CAPTCHA_TOKEN_LIFETIME / 1000)

return false
}
Expand All @@ -70,15 +76,27 @@ export class CaptchaService {
private readonly projectService: ProjectService,
) {}

// checks if captcha is enabled for pid
// checks if captcha is enabled for pid (by checking if captchaSecretKey exists)
async _isCaptchaEnabledForPID(pid: string) {
const project = await this.projectService.getRedisProject(pid)

if (!project) {
return false
}

return project.isCaptchaEnabled
return !!project.captchaSecretKey
}

// Get project's configured PoW difficulty, or default
async _getProjectDifficulty(pid: string): Promise<number> {
if (isDummyPID(pid)) {
return DEFAULT_POW_DIFFICULTY
}

const project = await this.projectService.getRedisProject(pid)

// Use project's configured difficulty if available, otherwise default
return project?.captchaDifficulty || DEFAULT_POW_DIFFICULTY
}

// validates pid, checks if captcha is enabled and throws an error otherwise
Expand Down Expand Up @@ -144,7 +162,7 @@ export class CaptchaService {
)
}

async generateToken(pid: string, captchaHash: string, timestamp: number) {
async generateToken(pid: string, challenge: string, timestamp: number) {
if (isDummyPID(pid)) {
return this.generateDummyToken()
}
Expand All @@ -160,7 +178,7 @@ export class CaptchaService {
}

const token = {
hash: captchaHash,
challenge,
timestamp,
pid,
}
Expand All @@ -181,7 +199,8 @@ export class CaptchaService {

if (secretKey === DUMMY_SECRETS.ALWAYS_PASS) {
return {
hash: 'DUMMY_HASH00000111112222233333444445555566666777778888899999',
challenge:
'DUMMY_CHALLENGE00000111112222233333444445555566666777778888899999',
timestamp: dayjs().unix() * 1000,
pid: DUMMY_PIDS.ALWAYS_PASS,
}
Expand All @@ -207,34 +226,107 @@ export class CaptchaService {
return parsed
}

hashCaptcha(text: string): string {
return hash(captchaString(text))
// Generate SHA-256 hash of the input
sha256(input: string): string {
return crypto.createHash('sha256').update(input).digest('hex')
}

async generateCaptcha(theme: string): Promise<GeneratedCaptcha> {
const themeParams =
theme === 'light'
? {}
: {
background: '#1f2937',
color: true,
}

const captcha = svgCaptcha.create({
size: 6,
ignoreChars: '0o1iIl',
noise: 2,
...themeParams,
})
const captchaHash = this.hashCaptcha(_toLower(captcha.text))
// Check if hash has required number of leading zeros
hasValidPrefix(hash: string, difficulty: number): boolean {
const prefix = '0'.repeat(difficulty)
return hash.startsWith(prefix)
}

// Generate a PoW challenge
async generateChallenge(pid: string): Promise<GeneratedChallenge> {
const difficulty = await this._getProjectDifficulty(pid)

// Generate a random challenge string
const challenge = crypto.randomBytes(32).toString('hex')

// Store challenge in Redis with TTL to prevent replay attacks
// Include pid to prevent cross-project difficulty bypass
const cacheKey = getChallengeCacheKey(challenge)
const challengeData = JSON.stringify({ pid, difficulty })
await redis.set(cacheKey, challengeData, 'EX', CHALLENGE_TTL)

return {
data: captcha.data,
hash: captchaHash,
challenge,
difficulty,
}
}

verifyCaptcha(text: string, captchaHash: string) {
return captchaHash === this.hashCaptcha(text)
// Verify a PoW solution
async verifyPoW(
challenge: string,
nonce: number,
solution: string,
pid: string,
): Promise<boolean> {
// Check if challenge exists and hasn't been used
const cacheKey = getChallengeCacheKey(challenge)
const storedData = await redis.get(cacheKey)

if (!storedData) {
throw new BadRequestException('Invalid or expired challenge')
}

let difficulty: number
let storedPid: string | undefined

// Parse stored data - support both old string format (difficulty only)
// and new JSON format (pid + difficulty) for backward compatibility
try {
const parsed = JSON.parse(storedData)
if (
typeof parsed === 'object' &&
parsed !== null &&
'difficulty' in parsed
) {
difficulty = parsed.difficulty
storedPid = parsed.pid
} else {
// Fallback: treat as raw difficulty number
difficulty = parseInt(storedData, 10)
}
} catch {
// Old format: just the difficulty as a string
difficulty = parseInt(storedData, 10)
}

// Validate difficulty to prevent bypass via NaN or invalid values
if (
typeof difficulty !== 'number' ||
!Number.isFinite(difficulty) ||
difficulty < 1 ||
difficulty > 6
) {
throw new BadRequestException('Invalid challenge difficulty')
}

// If the challenge was bound to a specific project, verify it matches
if (storedPid !== undefined && storedPid !== pid) {
throw new BadRequestException(
'Challenge was issued for a different project',
)
}

// Compute the expected hash
const input = `${challenge}:${nonce}`
const computedHash = this.sha256(input)

// Verify the solution matches and has required prefix
if (computedHash !== solution) {
return false
}

if (!this.hasValidPrefix(computedHash, difficulty)) {
return false
}

// Mark challenge as used by deleting it
await redis.del(cacheKey)

return true
}
}
12 changes: 1 addition & 11 deletions backend/apps/cloud/src/captcha/dtos/generate.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'
import { IsOptional, IsNotEmpty, IsString } from 'class-validator'

export const DEFAULT_THEME = 'light'
import { IsNotEmpty, IsString } from 'class-validator'

export class GenerateDto {
@ApiProperty({
Expand All @@ -12,12 +10,4 @@ export class GenerateDto {
@IsNotEmpty()
@IsString()
pid: string

@ApiProperty({
default: DEFAULT_THEME,
required: false,
description: 'Captcha theme',
})
@IsOptional()
theme?: string
}
Loading