From d25a3d6746c07760b4004ab5c854b868e8451d1f Mon Sep 17 00:00:00 2001 From: franciscodes-ops Date: Sun, 18 Oct 2026 18:42:43 +0100 Subject: [PATCH 1/4] fix(#745): validate memo length against Stellar 28-byte limit Added validateMemo() that rejects memos exceeding 28 bytes (Stellar limit) with a clear error message. invokeContract now accepts an optional memo parameter, validated before transaction building. --- src/stellar/stellar.service.ts | 36 +++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/stellar/stellar.service.ts b/src/stellar/stellar.service.ts index 3406ff9..391c2f7 100644 --- a/src/stellar/stellar.service.ts +++ b/src/stellar/stellar.service.ts @@ -43,6 +43,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. * @@ -123,7 +146,11 @@ export class StellarService { method: string, args: xdr.ScVal[], signerKeypair?: Keypair, + memo?: string, ): Promise { + // #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}`, @@ -161,13 +188,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), From 003568c80f6410bdd4d2978342e882efae254ea1 Mon Sep 17 00:00:00 2001 From: franciscodes-ops Date: Sun, 18 Oct 2026 18:42:50 +0100 Subject: [PATCH 2/4] fix(#746): set Cache-Control: no-store on sensitive API responses Added CacheControlInterceptor that sets no-store, private, max-age=0 with Pragma: no-cache and Expires: 0 headers. Applied to claims and policy controllers to prevent intermediary caching of sensitive data. --- src/claims/claims.controller.ts | 2 + .../interceptors/cache-control.interceptor.ts | 43 +++++++++++++++++++ src/policy/policy.controller.ts | 2 + 3 files changed, 47 insertions(+) create mode 100644 src/common/interceptors/cache-control.interceptor.ts diff --git a/src/claims/claims.controller.ts b/src/claims/claims.controller.ts index 709db9b..b593469 100644 --- a/src/claims/claims.controller.ts +++ b/src/claims/claims.controller.ts @@ -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 { @@ -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) {} diff --git a/src/common/interceptors/cache-control.interceptor.ts b/src/common/interceptors/cache-control.interceptor.ts new file mode 100644 index 0000000..2fa65df --- /dev/null +++ b/src/common/interceptors/cache-control.interceptor.ts @@ -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 { + const ctx = context.switchToHttp(); + const res = ctx.getResponse(); + + 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(); +} diff --git a/src/policy/policy.controller.ts b/src/policy/policy.controller.ts index 3eaebe1..05ed217 100644 --- a/src/policy/policy.controller.ts +++ b/src/policy/policy.controller.ts @@ -20,6 +20,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { StreamingInterceptor } from '../common/interceptors/streaming.interceptor'; +import { CacheControlInterceptor } from '../common/interceptors/cache-control.interceptor'; import { Observable } from 'rxjs'; import { ApiTags, @@ -54,6 +55,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 { From 67cfd31a38c210ac0d440f641334edc72d649177 Mon Sep 17 00:00:00 2001 From: franciscodes-ops Date: Sun, 18 Oct 2026 18:43:08 +0100 Subject: [PATCH 3/4] fix(#747): validate evidence file types in claims module Added evidence-validation.ts with whitelist of allowed file types (PNG, JPEG, WebP, PDF), magic byte validation to prevent disguised executables, and 10MB size limit. Full validation function combines type check + magic bytes + size. --- src/claims/evidence-validation.ts | 101 ++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/claims/evidence-validation.ts diff --git a/src/claims/evidence-validation.ts b/src/claims/evidence-validation.ts new file mode 100644 index 0000000..7eb7efa --- /dev/null +++ b/src/claims/evidence-validation.ts @@ -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 = { + "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); +} From fc18f690b6c5f4473e42045d44d59612114887b1 Mon Sep 17 00:00:00 2001 From: franciscodes-ops Date: Sun, 18 Oct 2026 18:43:27 +0100 Subject: [PATCH 4/4] fix(#748): emit status update event on policy expiration ClaimsWorker now emits PolicyStatusChange(EXPIRED) via StatusEventsService when a policy expires, so downstream systems are aware of the status change. Previously the transition happened silently. --- src/claims/claims.worker.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/claims/claims.worker.ts b/src/claims/claims.worker.ts index b15c1b9..1d0e3b4 100644 --- a/src/claims/claims.worker.ts +++ b/src/claims/claims.worker.ts @@ -6,6 +6,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'; const BATCH_SIZE = 10; @@ -38,6 +39,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, ) {} @@ -150,6 +152,12 @@ export class ClaimsWorker { this.logger.warn( `Policy ${policy.id} status guard missed on EXPIRED transition — status changed underneath the worker`, ); + } else { + // #748 — Emit expiration event so downstream systems are aware. + this.statusEvents.emitPolicyStatusChange( + policy.id, + PolicyStatus.EXPIRED, + ); } }