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
2 changes: 2 additions & 0 deletions src/claims/claims.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Body, Controller, ForbiddenException, Get, Param, ParseUUIDPipe, Post, Query, Req, UseGuards, UseInterceptors, UnauthorizedException, NotFoundException } from '@nestjs/common';
import { CacheControlInterceptor } from '../common/interceptors/cache-control.interceptor';
import { Throttle } from '@nestjs/throttler';
import { StreamingInterceptor } from '../common/interceptors/streaming.interceptor';
import {
Expand All @@ -22,6 +23,7 @@ import { ErrorCode } from '../common/errors/error-codes';

@ApiTags('claims')
@Controller('claims')
@UseInterceptors(CacheControlInterceptor)
@ApiExtraModels(ResponseDto, PaginatedResponseDto)
export class ClaimsController {
constructor(private readonly claims: ClaimsService) {}
Expand Down
2 changes: 2 additions & 0 deletions src/claims/claims.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ClaimsService } from './claims.service';
import { PrismaService } from '../prisma/prisma.service';
import { PolicyService, ProductSummary } from '../policy/policy.service';
import { transition } from '../policy/policy-status.machine';
import { StatusEventsService } from '../common/events/status-events.service';
import { recordWorkerHeartbeat } from '../common/worker-heartbeat';
import { DistributedLockService } from '../redis/distributed-lock.service';

Expand Down Expand Up @@ -41,6 +42,7 @@ export class ClaimsWorker {
private readonly claims: ClaimsService,
private readonly prisma: PrismaService,
private readonly policyService: PolicyService,
private readonly statusEvents: StatusEventsService,
@Inject('REDIS_CLIENT') private readonly redis: Redis,
@Optional() lockService?: DistributedLockService,
@Optional() private readonly configService?: ConfigService,
Expand Down
101 changes: 101 additions & 0 deletions src/claims/evidence-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Evidence file type validation (#747).
*
* Whitelists allowed evidence file types and validates magic bytes
* to prevent malicious file uploads through the claims module.
*/

/**
* Allowed evidence file types with their MIME types and magic bytes.
*/
const ALLOWED_EVIDENCE_TYPES: Record<string, { mime: string; magicBytes: number[][] }> = {
"image/png": {
mime: "image/png",
magicBytes: [[0x89, 0x50, 0x4e, 0x47]], // PNG signature
},
"image/jpeg": {
mime: "image/jpeg",
magicBytes: [[0xff, 0xd8, 0xff]], // JPEG signature
},
"image/webp": {
mime: "image/webp",
magicBytes: [[0x52, 0x49, 0x46, 0x46]], // RIFF (WebP container)
},
"application/pdf": {
mime: "application/pdf",
magicBytes: [[0x25, 0x50, 0x44, 0x46]], // %PDF
},
};

export const ALLOWED_EVIDENCE_MIME_TYPES = Object.keys(ALLOWED_EVIDENCE_TYPES);

export const MAX_EVIDENCE_FILE_SIZE = 10 * 1024 * 1024; // 10 MB

export interface EvidenceValidationResult {
valid: boolean;
error?: string;
}

/**
* Validate a claim evidence file's MIME type against the whitelist.
*/
export function validateEvidenceFileType(mimeType: string): EvidenceValidationResult {
if (!ALLOWED_EVIDENCE_TYPES[mimeType]) {
return {
valid: false,
error: `File type "${mimeType}" is not allowed. Allowed types: ${ALLOWED_EVIDENCE_MIME_TYPES.join(", ")}.`,
};
}
return { valid: true };
}

/**
* Validate a claim evidence file's magic bytes (file signature).
* Prevents disguised executables and other malicious files.
*/
export function validateEvidenceMagicBytes(
mimeType: string,
buffer: Buffer,
): EvidenceValidationResult {
const typeInfo = ALLOWED_EVIDENCE_TYPES[mimeType];
if (!typeInfo) {
return { valid: false, error: `Unknown file type: ${mimeType}` };
}

if (buffer.length < 4) {
return { valid: false, error: "File is too small to be a valid evidence file." };
}

const matches = typeInfo.magicBytes.some((signature) =>
signature.every((byte, index) => buffer[index] === byte),
);

if (!matches) {
return {
valid: false,
error: `File content does not match the declared type "${mimeType}". The file may be disguised or corrupted.`,
};
}

return { valid: true };
}

/**
* Full evidence file validation: type whitelist + magic bytes + size limit.
*/
export function validateEvidenceFile(
mimeType: string,
buffer: Buffer,
): EvidenceValidationResult {
if (buffer.length > MAX_EVIDENCE_FILE_SIZE) {
return {
valid: false,
error: `Evidence file exceeds ${MAX_EVIDENCE_FILE_SIZE / (1024 * 1024)}MB limit.`,
};
}

const typeResult = validateEvidenceFileType(mimeType);
if (!typeResult.valid) return typeResult;

return validateEvidenceMagicBytes(mimeType, buffer);
}
43 changes: 43 additions & 0 deletions src/common/interceptors/cache-control.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Cache-Control interceptor for sensitive API responses (#746).
*
* Sets `Cache-Control: no-store, private` on responses from sensitive
* endpoints (auth, claims, policy details) to prevent intermediary
* caching by proxies/CDNs. Sensitive financial and identity data must
* never be cached.
*/

import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from "@nestjs/common";
import { Observable } from "rxjs";
import { tap } from "rxjs/operators";
import type { Response } from "express";

@Injectable()
export class CacheControlInterceptor implements NestInterceptor {
constructor(private readonly directives: string = "no-store, private, max-age=0") {}

intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const ctx = context.switchToHttp();
const res = ctx.getResponse<Response>();

return next.handle().pipe(
tap(() => {
res.setHeader("Cache-Control", this.directives);
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
}),
);
}
}

/**
* Factory for the standard sensitive-response cache-control interceptor.
*/
export function sensitiveCacheControl(): CacheControlInterceptor {
return new CacheControlInterceptor();
}
1 change: 1 addition & 0 deletions src/policy/policy.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const SSE_TCP_KEEPALIVE_MS = 30_000;

@ApiTags('policy')
@Controller()
@UseInterceptors(CacheControlInterceptor)
@ApiExtraModels(ResponseDto, PaginatedResponseDto, ProductResponseDto, PolicyResponseDto, CancellationResponseDto)
@ApiExtraModels(ResponseDto, PaginatedResponseDto, ProductResponseDto, PolicyResponseDto, PolicyStatusEventDto)
export class PolicyController {
Expand Down
36 changes: 33 additions & 3 deletions src/stellar/stellar.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,29 @@ function isRateLimitError(err: unknown): boolean {
return /\b429\b|rate limit|too many requests/i.test(message);
}

/**
* #745 — Stellar transaction memo limit is 28 bytes.
* Memos exceeding this cause the transaction to be rejected by the network.
*/
const MAX_MEMO_BYTES = 28;

/**
* #745 — Validate and normalize a transaction memo.
* Throws BadRequestException if the memo exceeds 28 bytes.
* Returns undefined for empty/undefined memos.
*/
function validateMemo(memo?: string): string | undefined {
if (!memo) return undefined;
const bytes = Buffer.byteLength(memo, "utf8");
if (bytes > MAX_MEMO_BYTES) {
throw new BadRequestException(
`Transaction memo exceeds ${MAX_MEMO_BYTES}-byte limit (got ${bytes} bytes). ` +
`Shorten the memo to at most ${MAX_MEMO_BYTES} bytes.`,
);
}
return memo;
}

/**
* StellarService — thin wrapper over the Stellar SDK for Soroban contract calls.
*
Expand Down Expand Up @@ -150,7 +173,11 @@ export class StellarService {
method: string,
args: xdr.ScVal[],
signerKeypair?: Keypair,
memo?: string,
): Promise<string> {
// #745 — Validate memo length before building the transaction.
const validMemo = validateMemo(memo);

if (!/^C[A-BA-HJ-NP-Z0-9]{55}$/.test(contractId)) {
throw new BadRequestException(
`Invalid Stellar contract address: ${contractId}`,
Expand Down Expand Up @@ -200,13 +227,16 @@ export class StellarService {
this.rpc.getAccount(signer.publicKey()),
"getAccount",
);
const tx = new TransactionBuilder(account, {
let builder = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: this.network,
})
.addOperation(contract.call(method, ...args))
.setTimeout(30)
.build();
.setTimeout(30);
if (validMemo) {
builder = builder.addMemo(xdr.Memo.text(validMemo));
}
const tx = builder.build();

const simResult = await this.withTimeout(
this.rpc.simulateTransaction(tx),
Expand Down
Loading