Skip to content
Open
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
9 changes: 8 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,18 @@ jobs:
# ── Resource bench ─────────────────────────────────────────
# Bench tests for submit_answer budget (issue #34). Output is
# captured as an artifact so budget regressions are visible in
# the CI run summary.
# the CI run summary, and then enforced against the documented
# baselines in onchain/bench-baselines.json (issue #281) — a
# material regression fails the job.
- name: Run resource bench
working-directory: onchain
run: cargo test --workspace --locked -- bench_ --nocapture 2>&1 | tee bench-output.txt

- name: Check bench budget thresholds
# Fails when any measured metric exceeds its baseline or when
# the bench produced no measurements at all (silent regression).
run: python3 scripts/check-bench-budgets.py onchain/bench-output.txt

- name: Upload bench artifact
uses: actions/upload-artifact@v4
with:
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { PuzzleCategoryModule } from './puzzle-category/puzzle-category.module';
import { PuzzleDependencyModule } from './puzzle-dependency/puzzle-dependency.module';
import { PuzzleModule } from './puzzle/puzzle.module';
import { PuzzleSubmissionModule } from './puzzle-submission/puzzle-submission.module';
import { RateLimiterModule } from './rate-limiter/rate-limiter.module';
import { PuzzleTranslationModule } from './puzzle-translation/puzzle-translation.module';
import { ReferralModule } from './referral/referral.module';
import { ReportModule } from './report/report.module';
Expand Down Expand Up @@ -131,6 +132,7 @@ import { GracefulShutdownService } from './graceful-shutdown.service';
PuzzleModule,
PuzzleSubmissionModule,
PuzzleTranslationModule,
RateLimiterModule,
ReferralModule,
ReportModule,
RewardShopModule,
Expand Down
15 changes: 14 additions & 1 deletion backend/src/auth/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { AuthResponseDto } from '../dto/auth-response.dto';
import { RegisterDto } from '../dto/register.dto';
import { LoginDto } from '../dto/login.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RateLimit } from '../../rate-limiter/rate-limit.decorator';
import { RateLimitGuard } from '../../rate-limiter/rate-limit.guard';
import { User } from '../entities/user.entity';

@ApiTags('Authentication')
Expand All @@ -30,6 +32,8 @@ export class AuthController {

@Post('register')
@Auth(AuthType.None)
@UseGuards(RateLimitGuard)
@RateLimit({ ttl: 900, limit: 10 })
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Register a new user',
Expand Down Expand Up @@ -60,6 +64,14 @@ export class AuthController {

@Post('login')
@Auth(AuthType.None) // Public route
@UseGuards(RateLimitGuard)
// Account-aware throttle: keyed by email so brute-forcing one account
// is limited even when the attacker rotates IPs. Falls back to IP.
@RateLimit({
ttl: 900,
limit: 10,
keyGenerator: (req) => req.body?.email,
})
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'User login',
Expand Down Expand Up @@ -109,7 +121,8 @@ export class AuthController {
}

@Post('validate-token')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, RateLimitGuard)
@RateLimit({ ttl: 60, limit: 30 })
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
BadRequestException,
Controller,
Get,
Post,
Expand Down Expand Up @@ -32,6 +33,12 @@ import type {
ReviewStats,
} from '../interfaces/review.interface';

// Runtime allowlists (issue #277). TS union types are compile-time only —
// query strings reach the controller as arbitrary values, so they are
// validated here before they can reach the query builder.
const REVIEW_SORT_FIELDS = new Set(['createdAt', 'rating', 'helpfulCount']);
const SORT_ORDERS = new Set(['ASC', 'DESC']);

@ApiTags('Puzzle Reviews')
@Controller('puzzle-reviews')
export class PuzzleReviewController {
Expand Down Expand Up @@ -313,8 +320,23 @@ export class PuzzleReviewController {
if (minRating && minRating > 0) filters.minRating = minRating;
if (maxRating && maxRating > 0) filters.maxRating = maxRating;
if (reviewType) filters.reviewType = reviewType;
if (sortBy) filters.sortBy = sortBy;
if (sortOrder) filters.sortOrder = sortOrder;
if (sortBy !== undefined) {
if (!REVIEW_SORT_FIELDS.has(sortBy)) {
throw new BadRequestException(
`Invalid sortBy field: "${sortBy}"`,
);
}
filters.sortBy = sortBy;
}
if (sortOrder !== undefined) {
const normalized = sortOrder.toUpperCase();
if (!SORT_ORDERS.has(normalized)) {
throw new BadRequestException(
`Invalid sortOrder: "${sortOrder}" (expected ASC or DESC)`,
);
}
filters.sortOrder = normalized as 'ASC' | 'DESC';
}

const result = await this.reviewService.getReviews(filters, page, limit);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { BadRequestException } from '@nestjs/common';
import { PuzzleReviewService } from './puzzle-review.service';

function createMockQueryBuilder() {
const queryBuilder: any = {
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getCount: jest.fn().mockResolvedValue(0),
getMany: jest.fn().mockResolvedValue([]),
};
return queryBuilder;
}

describe('PuzzleReviewService (filter/sort allowlists)', () => {
let service: PuzzleReviewService;
let repository: any;

beforeEach(() => {
repository = {
createQueryBuilder: jest.fn(),
};
service = new PuzzleReviewService(repository);
});

it('maps allowlisted sort keys to known database columns', async () => {
const queryBuilder = createMockQueryBuilder();
repository.createQueryBuilder.mockReturnValue(queryBuilder);

await service.getReviews({ sortBy: 'helpfulCount', sortOrder: 'ASC' });

expect(queryBuilder.orderBy).toHaveBeenCalledWith(
'review.helpfulCount',
'ASC',
);
});

it('defaults sorting to createdAt DESC', async () => {
const queryBuilder = createMockQueryBuilder();
repository.createQueryBuilder.mockReturnValue(queryBuilder);

await service.getReviews({});

expect(queryBuilder.orderBy).toHaveBeenCalledWith(
'review.createdAt',
'DESC',
);
});

it('rejects sort fields outside the allowlist', async () => {
const queryBuilder = createMockQueryBuilder();
repository.createQueryBuilder.mockReturnValue(queryBuilder);

await expect(
service.getReviews({ sortBy: 'createdAt); DROP TABLE reviews;--' as any }),
).rejects.toThrow(BadRequestException);

expect(queryBuilder.orderBy).not.toHaveBeenCalled();
});

it('rejects lowercase or arbitrary sort orders', async () => {
const queryBuilder = createMockQueryBuilder();
repository.createQueryBuilder.mockReturnValue(queryBuilder);

await expect(
service.getReviews({ sortOrder: 'desc; DROP TABLE reviews;--' as any }),
).rejects.toThrow(BadRequestException);

expect(queryBuilder.orderBy).not.toHaveBeenCalled();
});

it('accepts case-insensitive valid sort orders', async () => {
const queryBuilder = createMockQueryBuilder();
repository.createQueryBuilder.mockReturnValue(queryBuilder);

await service.getReviews({ sortBy: 'rating', sortOrder: 'asc' as any });

expect(queryBuilder.orderBy).toHaveBeenCalledWith('review.rating', 'ASC');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ import type {
ReviewValidationResult,
} from '../interfaces/review.interface';

// Allowlist mapping public sort keys to known database columns (issue
// #277). Arbitrary strings are never interpolated into the query builder;
// anything not in this map is rejected with a 400.
const REVIEW_SORT_COLUMNS: Record<string, string> = {
createdAt: 'review.createdAt',
rating: 'review.rating',
helpfulCount: 'review.helpfulCount',
};

const SORT_ORDERS = new Set(['ASC', 'DESC']);

@Injectable()
export class PuzzleReviewService {
private readonly logger = new Logger(PuzzleReviewService.name);
Expand Down Expand Up @@ -298,10 +309,22 @@ export class PuzzleReviewService {
// Get total count
const total = await queryBuilder.getCount();

// Apply sorting
// Apply sorting. `sortBy` / `sortOrder` are runtime inputs (TS union
// types do not validate them), so map them through the allowlists
// before touching the query builder — never interpolate raw values.
const sortBy = filters?.sortBy || 'createdAt';
const sortOrder = filters?.sortOrder || 'DESC';
queryBuilder.orderBy(`review.${sortBy}`, sortOrder);
const sortColumn = REVIEW_SORT_COLUMNS[sortBy];
if (!sortColumn) {
throw new BadRequestException(`Invalid sortBy field: "${sortBy}"`);
}

const sortOrder = (filters?.sortOrder || 'DESC').toUpperCase();
if (!SORT_ORDERS.has(sortOrder)) {
throw new BadRequestException(
`Invalid sortOrder: "${sortOrder}" (expected ASC or DESC)`,
);
}
queryBuilder.orderBy(sortColumn, sortOrder as 'ASC' | 'DESC');

// Apply pagination
const reviews = await queryBuilder
Expand Down
4 changes: 2 additions & 2 deletions backend/src/puzzle-submission/puzzle-submission.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { PuzzleSubmissionService } from './puzzle-submission.service';
import { RateLimit } from 'src/rate-limiter/rate-limit.decorator';
import { RateLimitGuard } from 'src/rate-limiter/rate-limit.guard';
import { RateLimit } from '../rate-limiter/rate-limit.decorator';
import { RateLimitGuard } from '../rate-limiter/rate-limit.guard';

@Controller('puzzle-submission')
export class PuzzleSubmissionController {
Expand Down
3 changes: 2 additions & 1 deletion backend/src/rate-limiter/rate-limit.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { SetMetadata } from '@nestjs/common';
import { RATE_LIMIT_KEY } from './rate-limit.guard';
import type { RateLimitConfig } from './rate-limit.interface';

export const RateLimit = (config: { ttl: number; limit: number }) =>
export const RateLimit = (config: RateLimitConfig) =>
SetMetadata(RATE_LIMIT_KEY, config);
Loading
Loading