From 7f99256863058330c92b745a04d1dae43ebab57c Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 12 Oct 2026 20:18:36 +0200 Subject: [PATCH] Verify ALTCHA before upload body parse with single-use replay store. Run challenge TTL and replay tracking in middleware, remove the guard, and wire ScheduleModule for pruning used solutions. --- backend/package-lock.json | 80 +++++++++- backend/package.json | 3 + backend/src/AppModule.ts | 4 +- backend/src/consts/altchaHeader.ts | 1 + backend/src/main.ts | 41 ++++- backend/src/middlewares/altchaMiddleware.ts | 29 ++++ backend/src/modules/altcha/AltchaModule.ts | 5 +- .../altcha/controllers/AltchaController.ts | 2 +- .../src/modules/altcha/guards/AltchaGuard.ts | 36 ----- .../altcha/services/AltchaService.spec.ts | 147 +++++++++++++++--- .../modules/altcha/services/AltchaService.ts | 41 ++++- .../services/UsedAltchaStoreService.spec.ts | 88 +++++++++++ .../altcha/services/UsedAltchaStoreService.ts | 38 +++++ .../altcha/types/AltchaVerificationStatus.ts | 6 + backend/src/types/HttpMethod.ts | 9 ++ 15 files changed, 446 insertions(+), 84 deletions(-) create mode 100644 backend/src/consts/altchaHeader.ts create mode 100644 backend/src/middlewares/altchaMiddleware.ts delete mode 100644 backend/src/modules/altcha/guards/AltchaGuard.ts create mode 100644 backend/src/modules/altcha/services/UsedAltchaStoreService.spec.ts create mode 100644 backend/src/modules/altcha/services/UsedAltchaStoreService.ts create mode 100644 backend/src/modules/altcha/types/AltchaVerificationStatus.ts create mode 100644 backend/src/types/HttpMethod.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 0ac44d3..97ffb63 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,11 +13,14 @@ "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.3", "@nestjs/throttler": "^6.7.1", "@nestjs/typeorm": "^12.0.2", "altcha-lib": "^2.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "dayjs": "^1.11.23", + "file-type": "^22.1.1", "pg": "^8.23.1", "reflect-metadata": "^0.2.2", "typeorm": "^0.3.28" @@ -1841,6 +1844,24 @@ } } }, + "node_modules/@nestjs/common/node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/@nestjs/config": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", @@ -1916,6 +1937,19 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/schedule": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz", + "integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==", + "license": "MIT", + "dependencies": { + "cron": "4.4.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", @@ -2603,6 +2637,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz", + "integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", @@ -4251,6 +4291,23 @@ "devOptional": true, "license": "MIT" }, + "node_modules/cron": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", + "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/intcreator" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4815,18 +4872,18 @@ } }, "node_modules/file-type": { - "version": "21.3.4", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", - "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "version": "22.1.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.1.1.tgz", + "integrity": "sha512-sEB0oStmeLGtpmSAD88RDNuLsvhv4iC3u0lE25CUvfCULvj49xoQ3l3K525Vn+ZzsZ6DOLajQCV6RmhwwhqiJg==", "license": "MIT", "dependencies": { "@tokenizer/inflate": "^0.4.1", - "strtok3": "^10.3.4", - "token-types": "^6.1.1", - "uint8array-extras": "^1.4.0" + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0" }, "engines": { - "node": ">=20" + "node": ">=22" }, "funding": { "url": "https://github.com/sindresorhus/file-type?sponsor=1" @@ -6417,6 +6474,15 @@ "node": "20 || >=22" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", diff --git a/backend/package.json b/backend/package.json index de9e26b..5c83a44 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,11 +24,14 @@ "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.3", "@nestjs/throttler": "^6.7.1", "@nestjs/typeorm": "^12.0.2", "altcha-lib": "^2.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "dayjs": "^1.11.23", + "file-type": "^22.1.1", "pg": "^8.23.1", "reflect-metadata": "^0.2.2", "typeorm": "^0.3.28" diff --git a/backend/src/AppModule.ts b/backend/src/AppModule.ts index 762e603..1fe4ad0 100644 --- a/backend/src/AppModule.ts +++ b/backend/src/AppModule.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService, registerAs } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; import { APP_GUARD } from '@nestjs/core'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -9,10 +10,11 @@ import { AltchaModule } from './modules/altcha/AltchaModule'; import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule'; import { StorageModule } from './modules/storage/StorageModule'; import { UploadModule } from './modules/upload/UploadModule'; -import { Config } from './types/Config'; +import { Config } from './types/config/Config'; @Module({ imports: [ + ScheduleModule.forRoot(), ConfigModule.forRoot({ isGlobal: true, validate, diff --git a/backend/src/consts/altchaHeader.ts b/backend/src/consts/altchaHeader.ts new file mode 100644 index 0000000..1dbde6e --- /dev/null +++ b/backend/src/consts/altchaHeader.ts @@ -0,0 +1 @@ +export const ALTCHA_REQUEST_HEADER = 'x-altcha'; diff --git a/backend/src/main.ts b/backend/src/main.ts index dc1cc2b..6223018 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,17 +1,48 @@ import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import express, { type NextFunction, type Request, type Response } from 'express'; import { AppModule } from './AppModule'; -import { Config } from './types/Config'; +import { altchaMiddleware } from './middlewares/altchaMiddleware'; +import { AltchaService } from './modules/altcha/services/AltchaService'; +import { Config } from './types/config/Config'; +import { HttpMethod } from './types/HttpMethod'; async function bootstrap() { - const app = await NestFactory.create(AppModule); - - const configService = app.get(ConfigService); - const { port, corsOrigins } = configService.get('app') as Config['app']; + const app = await NestFactory.create(AppModule, { bodyParser: false }); app.setGlobalPrefix('api'); + const configService = app.get(ConfigService); + const altchaService = app.get(AltchaService); + + const { + port, + corsOrigins, + validation: { + upload: { maxRequestBodyBytes } + } + } = configService.get('app') as Config['app']; + + const defaultJsonParser = express.json(); + const uploadJsonParser = express.json({ limit: maxRequestBodyBytes }); + const uploadAltchaMiddleware = altchaMiddleware(altchaService); + + app.use((req: Request, res: Response, next: NextFunction) => { + if (req.method === HttpMethod.Post && req.path.startsWith('/api/uploads')) { + return uploadAltchaMiddleware(req, res, (err: unknown) => { + if (err) { + return next(err); + } + + return uploadJsonParser(req, res, next); + }); + } + + return defaultJsonParser(req, res, next); + }); + app.useGlobalPipes( new ValidationPipe({ transform: true, diff --git a/backend/src/middlewares/altchaMiddleware.ts b/backend/src/middlewares/altchaMiddleware.ts new file mode 100644 index 0000000..dcaa066 --- /dev/null +++ b/backend/src/middlewares/altchaMiddleware.ts @@ -0,0 +1,29 @@ +import { HttpStatus } from '@nestjs/common'; +import type { RequestHandler } from 'express'; +import { ALTCHA_REQUEST_HEADER } from '../consts/altchaHeader'; +import type { AltchaService } from '../modules/altcha/services/AltchaService'; +import { AltchaVerificationStatus } from '../modules/altcha/types/AltchaVerificationStatus'; + +const altchaVerificationMessage: Record, string> = { + [AltchaVerificationStatus.Missing]: 'ALTCHA verification required', + [AltchaVerificationStatus.Invalid]: 'ALTCHA verification failed', + [AltchaVerificationStatus.AlreadyUsed]: 'ALTCHA already used' +}; + +export const altchaMiddleware = (altchaService: AltchaService): RequestHandler => { + return async (req, res, next) => { + try { + const altchaHeader = req.get(ALTCHA_REQUEST_HEADER); + + const status = await altchaService.verifySolution(altchaHeader); + + if (status === AltchaVerificationStatus.Ok) { + return next(); + } + + return res.status(HttpStatus.BAD_REQUEST).send({ message: altchaVerificationMessage[status] }); + } catch (error) { + return next(error); + } + }; +}; diff --git a/backend/src/modules/altcha/AltchaModule.ts b/backend/src/modules/altcha/AltchaModule.ts index 0037b45..e52076e 100644 --- a/backend/src/modules/altcha/AltchaModule.ts +++ b/backend/src/modules/altcha/AltchaModule.ts @@ -1,11 +1,10 @@ import { Module } from '@nestjs/common'; import { AltchaController } from './controllers/AltchaController'; -import { AltchaGuard } from './guards/AltchaGuard'; import { AltchaService } from './services/AltchaService'; +import { UsedAltchaStoreService } from './services/UsedAltchaStoreService'; @Module({ controllers: [AltchaController], - providers: [AltchaService, AltchaGuard], - exports: [AltchaGuard] + providers: [AltchaService, UsedAltchaStoreService] }) export class AltchaModule {} diff --git a/backend/src/modules/altcha/controllers/AltchaController.ts b/backend/src/modules/altcha/controllers/AltchaController.ts index 0ae75e0..617d01b 100644 --- a/backend/src/modules/altcha/controllers/AltchaController.ts +++ b/backend/src/modules/altcha/controllers/AltchaController.ts @@ -1,7 +1,7 @@ import { Controller, Get, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { Config } from '../../../types/config/Config'; import { AltchaService } from '../services/AltchaService'; -import { Config } from '../../../types/Config'; @Controller('altcha') export class AltchaController { diff --git a/backend/src/modules/altcha/guards/AltchaGuard.ts b/backend/src/modules/altcha/guards/AltchaGuard.ts deleted file mode 100644 index d213756..0000000 --- a/backend/src/modules/altcha/guards/AltchaGuard.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { BadRequestException, CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Request } from 'express'; -import { AltchaService } from '../services/AltchaService'; -import { Config } from '../../../types/Config'; - -@Injectable() -export class AltchaGuard implements CanActivate { - constructor( - private readonly altchaService: AltchaService, - private readonly configService: ConfigService - ) {} - - async canActivate(context: ExecutionContext): Promise { - const { enabled } = this.configService.get('altcha') as Config['altcha']; - - if (!enabled) { - return true; - } - - const request = context.switchToHttp().getRequest(); - const altcha = request.body?.altcha; - - if (typeof altcha !== 'string' || altcha.length === 0) { - throw new BadRequestException('ALTCHA verification required'); - } - - const verified = await this.altchaService.verifyPayload(altcha); - - if (!verified) { - throw new BadRequestException('ALTCHA verification failed'); - } - - return true; - } -} diff --git a/backend/src/modules/altcha/services/AltchaService.spec.ts b/backend/src/modules/altcha/services/AltchaService.spec.ts index de6c458..019f598 100644 --- a/backend/src/modules/altcha/services/AltchaService.spec.ts +++ b/backend/src/modules/altcha/services/AltchaService.spec.ts @@ -1,39 +1,138 @@ import { ConfigService } from '@nestjs/config'; -import { createChallenge, solveChallenge } from 'altcha-lib/v1'; -import type { Payload } from 'altcha-lib/v1/types'; +import { createChallenge, extractParams, solveChallenge } from 'altcha-lib/v1'; +import type { Challenge, Payload } from 'altcha-lib/v1/types'; +import { AltchaVerificationStatus } from '../types/AltchaVerificationStatus'; import { AltchaService } from './AltchaService'; +import { UsedAltchaStoreService } from './UsedAltchaStoreService'; const hmacKey = 'test-hmac-key'; const encodePayload = (payload: Payload): string => Buffer.from(JSON.stringify(payload), 'utf8').toString('base64'); +const encodeSolvedChallenge = async (challenge: Challenge): Promise => { + const { promise } = solveChallenge(challenge.challenge, challenge.salt, challenge.algorithm, 100_000); + const solution = await promise; + + expect(solution).not.toBeNull(); + + return encodePayload({ + algorithm: challenge.algorithm, + challenge: challenge.challenge, + number: solution!.number, + salt: challenge.salt, + signature: challenge.signature + }); +}; + +const solveChallengePayload = async (): Promise => + encodeSolvedChallenge(await createChallenge({ hmacKey, number: 42, maxNumber: 100_000 })); + +const createConfigService = (enabled: boolean): ConfigService => + ({ + get: jest.fn((key: string) => { + if (key === 'altcha') { + return { + enabled, + hmacKey, + maxNumber: 100_000, + challengeTtlMs: 600_000 + }; + } + + return undefined; + }) + }) as unknown as ConfigService; + +const createAltchaService = (enabled: boolean): AltchaService => { + const configService = createConfigService(enabled); + + return new AltchaService(configService, new UsedAltchaStoreService(configService)); +}; + describe('AltchaService', () => { - const configService = { - get: jest.fn(() => ({ enabled: true, hmacKey, maxNumber: 100_000 })) - } as unknown as ConfigService; + it('verifies and consumes a valid ALTCHA payload', async () => { + const service = createAltchaService(true); + const altcha = await solveChallengePayload(); - const service = new AltchaService(configService); - - it('verifies a valid ALTCHA payload', async () => { - const challenge = await createChallenge({ hmacKey, number: 42, maxNumber: 100_000 }); - const { promise } = solveChallenge(challenge.challenge, challenge.salt, challenge.algorithm, 100_000); - const solution = await promise; - - expect(solution).not.toBeNull(); - - const payload: Payload = { - algorithm: challenge.algorithm, - challenge: challenge.challenge, - number: solution!.number, - salt: challenge.salt, - signature: challenge.signature - }; - - await expect(service.verifyPayload(encodePayload(payload))).resolves.toBe(true); + await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok); }); it('rejects an invalid ALTCHA payload', async () => { - await expect(service.verifyPayload('not-valid-base64-json')).resolves.toBe(false); + const service = createAltchaService(true); + + await expect(service.verifySolution('not-valid-base64-json')).resolves.toBe( + AltchaVerificationStatus.Invalid + ); + }); + + it('rejects an expired ALTCHA payload', async () => { + const service = createAltchaService(true); + const altcha = await encodeSolvedChallenge( + await createChallenge({ + hmacKey, + number: 42, + maxNumber: 100_000, + expires: new Date(Date.now() - 60_000) + }) + ); + + await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Invalid); + }); + + it('rejects a payload signed with a different hmac key', async () => { + const service = createAltchaService(true); + const altcha = await encodeSolvedChallenge( + await createChallenge({ hmacKey: 'other-key', number: 42, maxNumber: 100_000 }) + ); + + await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Invalid); + }); + + it('rejects a missing ALTCHA payload when enabled', async () => { + const service = createAltchaService(true); + + await expect(service.verifySolution(undefined)).resolves.toBe(AltchaVerificationStatus.Missing); + await expect(service.verifySolution('')).resolves.toBe(AltchaVerificationStatus.Missing); + }); + + it('rejects an immediate replay', async () => { + const service = createAltchaService(true); + const altcha = await solveChallengePayload(); + + await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok); + await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.AlreadyUsed); + }); + + it('skips verification when ALTCHA is disabled', async () => { + const service = createAltchaService(false); + + await expect(service.verifySolution(undefined)).resolves.toBe(AltchaVerificationStatus.Ok); + }); + + it('does not enforce replay protection when ALTCHA is disabled', async () => { + const service = createAltchaService(false); + const altcha = await solveChallengePayload(); + + await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok); + await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok); + }); + + describe('createChallenge', () => { + it('applies maxNumber and challenge TTL from config', async () => { + const service = createAltchaService(true); + const before = Date.now(); + + const challenge = await service.createChallenge(); + + expect(challenge.maxnumber).toBe(100_000); + expect(challenge.signature).toBeTruthy(); + + const { expires } = extractParams(challenge); + const expiresAtMs = Number(expires) * 1000; + + expect(expiresAtMs).toBeGreaterThanOrEqual(before + 600_000 - 1000); + expect(expiresAtMs).toBeLessThanOrEqual(before + 600_000 + 5000); + }); }); }); diff --git a/backend/src/modules/altcha/services/AltchaService.ts b/backend/src/modules/altcha/services/AltchaService.ts index 137cafb..d49d2f4 100644 --- a/backend/src/modules/altcha/services/AltchaService.ts +++ b/backend/src/modules/altcha/services/AltchaService.ts @@ -1,25 +1,52 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { createChallenge, verifySolution } from 'altcha-lib/v1'; +import dayjs from 'dayjs'; import type { Challenge } from 'altcha-lib/v1/types'; -import { Config } from '../../../types/Config'; +import { Config } from '../../../types/config/Config'; +import { AltchaVerificationStatus } from '../types/AltchaVerificationStatus'; +import { UsedAltchaStoreService } from './UsedAltchaStoreService'; @Injectable() export class AltchaService { - constructor(private readonly configService: ConfigService) {} + constructor( + private readonly configService: ConfigService, + private readonly usedAltchaStore: UsedAltchaStoreService + ) {} async createChallenge(): Promise { - const { hmacKey, maxNumber } = this.configService.get('altcha') as Config['altcha']; + const { hmacKey, maxNumber, challengeTtlMs } = this.configService.get('altcha') as Config['altcha']; return createChallenge({ hmacKey, - maxNumber + maxNumber, + expires: dayjs().add(challengeTtlMs, 'millisecond').toDate() }); } - async verifyPayload(altcha: string): Promise { - const { hmacKey } = this.configService.get('altcha') as Config['altcha']; + async verifySolution(altcha: string | undefined): Promise { + const { enabled, hmacKey } = this.configService.get('altcha') as Config['altcha']; - return verifySolution(altcha, hmacKey); + if (!enabled) { + return AltchaVerificationStatus.Ok; + } + + if (!altcha) { + return AltchaVerificationStatus.Missing; + } + + const verified = await verifySolution(altcha, hmacKey); + + if (!verified) { + return AltchaVerificationStatus.Invalid; + } + + const consumed = this.usedAltchaStore.tryConsume(altcha); + + if (!consumed) { + return AltchaVerificationStatus.AlreadyUsed; + } + + return AltchaVerificationStatus.Ok; } } diff --git a/backend/src/modules/altcha/services/UsedAltchaStoreService.spec.ts b/backend/src/modules/altcha/services/UsedAltchaStoreService.spec.ts new file mode 100644 index 0000000..a50f138 --- /dev/null +++ b/backend/src/modules/altcha/services/UsedAltchaStoreService.spec.ts @@ -0,0 +1,88 @@ +import { ConfigService } from '@nestjs/config'; +import { UsedAltchaStoreService } from './UsedAltchaStoreService'; + +const createConfigService = (challengeTtlMs: number): ConfigService => + ({ + get: jest.fn((key: string) => { + if (key === 'altcha') { + return { challengeTtlMs }; + } + + return undefined; + }) + }) as unknown as ConfigService; + +describe('UsedAltchaStoreService', () => { + it('allows the first consume and rejects an immediate replay', () => { + const store = new UsedAltchaStoreService(createConfigService(60_000)); + const altcha = 'valid-solution-payload'; + + expect(store.tryConsume(altcha)).toBe(true); + expect(store.tryConsume(altcha)).toBe(false); + }); + + it('rejects replay until prune removes the entry after expiry', () => { + const store = new UsedAltchaStoreService(createConfigService(1)); + const altcha = 'expires-quickly'; + + expect(store.tryConsume(altcha)).toBe(true); + + return new Promise(resolve => { + setTimeout(() => { + expect(store.tryConsume(altcha)).toBe(false); + store.pruneExpired(); + expect(store.tryConsume(altcha)).toBe(true); + resolve(); + }, 5); + }); + }); + + it('pruneExpired removes expired entries', () => { + const store = new UsedAltchaStoreService(createConfigService(1)); + const altcha = 'prune-me'; + + expect(store.tryConsume(altcha)).toBe(true); + + return new Promise(resolve => { + setTimeout(() => { + store.pruneExpired(); + expect(store.tryConsume(altcha)).toBe(true); + resolve(); + }, 5); + }); + }); + + it('tracks different payloads independently', () => { + const store = new UsedAltchaStoreService(createConfigService(60_000)); + + expect(store.tryConsume('payload-a')).toBe(true); + expect(store.tryConsume('payload-b')).toBe(true); + expect(store.tryConsume('payload-a')).toBe(false); + expect(store.tryConsume('payload-b')).toBe(false); + }); + + it('pruneExpired does not remove entries before their expiry', () => { + const store = new UsedAltchaStoreService(createConfigService(60_000)); + + expect(store.tryConsume('still-active')).toBe(true); + store.pruneExpired(); + + expect(store.tryConsume('still-active')).toBe(false); + }); + + it('pruneExpired clears every expired entry in one pass', () => { + const store = new UsedAltchaStoreService(createConfigService(1)); + + expect(store.tryConsume('first')).toBe(true); + expect(store.tryConsume('second')).toBe(true); + + return new Promise(resolve => { + setTimeout(() => { + store.pruneExpired(); + expect(store.tryConsume('first')).toBe(true); + expect(store.tryConsume('second')).toBe(true); + resolve(); + }, 5); + }); + }); +}); diff --git a/backend/src/modules/altcha/services/UsedAltchaStoreService.ts b/backend/src/modules/altcha/services/UsedAltchaStoreService.ts new file mode 100644 index 0000000..0dcb068 --- /dev/null +++ b/backend/src/modules/altcha/services/UsedAltchaStoreService.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import dayjs from 'dayjs'; +import { createHash } from 'node:crypto'; +import { Config } from '../../../types/config/Config'; + +@Injectable() +export class UsedAltchaStoreService { + private readonly entries = new Map(); + + constructor(private readonly configService: ConfigService) {} + + tryConsume(altcha: string): boolean { + const key = createHash('sha256').update(altcha, 'utf8').digest('hex'); + + if (this.entries.has(key)) { + return false; + } + + const { challengeTtlMs } = this.configService.get('altcha') as Config['altcha']; + + this.entries.set(key, dayjs().add(challengeTtlMs, 'millisecond').valueOf()); + + return true; + } + + @Cron(CronExpression.EVERY_MINUTE) + pruneExpired(): void { + const now = dayjs(); + + for (const [key, expiresAt] of this.entries) { + if (now.isAfter(expiresAt)) { + this.entries.delete(key); + } + } + } +} diff --git a/backend/src/modules/altcha/types/AltchaVerificationStatus.ts b/backend/src/modules/altcha/types/AltchaVerificationStatus.ts new file mode 100644 index 0000000..05e73ba --- /dev/null +++ b/backend/src/modules/altcha/types/AltchaVerificationStatus.ts @@ -0,0 +1,6 @@ +export enum AltchaVerificationStatus { + Ok = 'ok', + Missing = 'missing', + Invalid = 'invalid', + AlreadyUsed = 'already_used' +} diff --git a/backend/src/types/HttpMethod.ts b/backend/src/types/HttpMethod.ts new file mode 100644 index 0000000..ed3a6b1 --- /dev/null +++ b/backend/src/types/HttpMethod.ts @@ -0,0 +1,9 @@ +export enum HttpMethod { + Get = 'GET', + Head = 'HEAD', + Post = 'POST', + Put = 'PUT', + Patch = 'PATCH', + Delete = 'DELETE', + Options = 'OPTIONS' +}