From 7b0cc0d65a164211b708526bb46227c9148b9222 Mon Sep 17 00:00:00 2001 From: codemagician Date: Wed, 29 Jul 2026 12:14:38 +0100 Subject: [PATCH] fix: security headers, Soroban fee from simulation, cache stampede, shutdown timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #220: Register @fastify/helmet with OWASP-aligned defaults: restrictive CSP (default-src/script-src/object-src/frame-ancestors all 'none'), HSTS 1 yr + includeSubDomains + preload, X-Frame-Options DENY, noSniff, xssFilter, hidePoweredBy; add @fastify/helmet ^13.0.1 to dependencies - #218: Compute Soroban transaction fee from simulateTransaction result instead of hardcoding BASE_FEE. Extracts minResourceFee from the success response, adds a 20 % buffer on the resource portion, and rebuilds the transaction with the computed total before assembleTransaction — prevents on-chain fee failures - #217: Add cacheGetOrSet() to src/cache/index.ts with thundering-herd protection. On a cache miss, exactly one caller fetches from the source; all other concurrent callers for the same key await the same in-flight promise via an in-process Map, eliminating the cache stampede - #216: Add a 10 s forced-exit timer (unref'd) at the start of the shutdown handler so the process cannot hang indefinitely when DB or Redis is stuck; timer is cancelled if graceful shutdown completes within the window Closes #220 Closes #218 Closes #217 Closes #216 --- package.json | 1 + src/cache/index.ts | 47 +++++++++++++++++++++++++++++++++++++ src/server.ts | 47 +++++++++++++++++++++++++++++++++++++ src/stellar/transactions.ts | 29 ++++++++++++++++++++--- 4 files changed, 121 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 2525ad0..dbf27a2 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@fastify/cors": "^10.0.2", + "@fastify/helmet": "^13.0.1", "@fastify/jwt": "^9.0.2", "@fastify/rate-limit": "^10.2.0", "@opentelemetry/api": "^1.9.1", diff --git a/src/cache/index.ts b/src/cache/index.ts index 2a37a03..47ec63b 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -2,6 +2,11 @@ import { redis } from "../config/redis.js"; import { logger } from "../utils/logger.js"; import { Counter } from "prom-client"; +// In-process map used to coalesce concurrent cache-miss fetches for the same +// key so only one call reaches the DB/source (thundering-herd protection). +// The promise is stored while the fetch is in flight; all waiters share it. +const inFlightFetches = new Map>(); + const DEFAULT_TTL = 60; export const cacheHits = new Counter({ @@ -78,6 +83,48 @@ export async function cacheSet( } } +/** + * #217: Cache-aside with thundering-herd protection. + * + * Returns the cached value when present. On a miss, exactly ONE concurrent + * caller executes `fetchFn`; all other callers for the same key await the + * same in-flight promise so the backing store receives only one request + * regardless of how many requests arrive simultaneously. + * + * @param namespace - Prometheus label and key segment. + * @param key - Full Redis key (built with cacheKey()). + * @param fetchFn - Async function that fetches the value from the source. + * @param ttl - Cache TTL in seconds (default: DEFAULT_TTL). + */ +export async function cacheGetOrSet( + namespace: string, + key: string, + fetchFn: () => Promise, + ttl: number = DEFAULT_TTL, +): Promise { + const cached = await cacheGet(namespace, key); + if (cached !== null) return cached; + + // Another request for this key is already in flight — join it. + const existing = inFlightFetches.get(key); + if (existing) { + return existing as Promise; + } + + // We are the winner: fetch and populate the cache. + const fetching = fetchFn() + .then(async (value) => { + await cacheSet(key, value, ttl); + return value; + }) + .finally(() => { + inFlightFetches.delete(key); + }); + + inFlightFetches.set(key, fetching); + return fetching; +} + /** * Deletes precise keys safely. Avoids high-latency KEYS scanning in production. */ diff --git a/src/server.ts b/src/server.ts index 7f48261..b4541c6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,6 +2,7 @@ import { initTracing, shutdownTracing } from "./tracing.js"; import Fastify from "fastify"; import cors from "@fastify/cors"; +import helmet from "@fastify/helmet"; import jwt from "@fastify/jwt"; import rateLimit from "@fastify/rate-limit"; import { sql } from "drizzle-orm"; @@ -70,6 +71,37 @@ async function buildApp() { }); // ─── Plugins ──────────────────────────────────────────────────────────── + + // #220: OWASP security headers via @fastify/helmet. + // Content-Security-Policy is set to a restrictive baseline — the API only + // serves JSON so no scripts/styles are needed; adjust if a docs UI is added. + await app.register(helmet, { + contentSecurityPolicy: { + directives: { + defaultSrc: ["'none'"], + scriptSrc: ["'none'"], + objectSrc: ["'none'"], + frameAncestors: ["'none'"], + }, + }, + // X-Frame-Options: DENY (redundant with CSP frameAncestors but kept for + // older clients that don't support CSP). + frameguard: { action: "deny" }, + // Strict-Transport-Security: 1 year, includeSubDomains, preload. + hsts: { + maxAge: 31_536_000, + includeSubDomains: true, + preload: true, + }, + referrerPolicy: { policy: "no-referrer" }, + // X-Content-Type-Options: nosniff (prevents MIME-sniffing attacks). + noSniff: true, + // X-XSS-Protection is legacy but still useful for older browsers. + xssFilter: true, + // Remove X-Powered-By to avoid fingerprinting. + hidePoweredBy: true, + }); + // CSRF note: auth is via the `Authorization: Bearer` header, which is // CSRF-safe (cross-site requests can't set custom headers). `credentials: // true` only matters if auth ever moves to cookies — if it does, add a CSRF @@ -182,7 +214,21 @@ async function start() { logger.error({ error }, "Cache warmer initialization failed"); } + const SHUTDOWN_TIMEOUT_MS = 10_000; + const shutdown = async (signal: string) => { + // #216: Force-exit if graceful shutdown exceeds the deadline so the + // process doesn't hang indefinitely when DB or Redis is stuck. + const forceExit = setTimeout(() => { + logger.error( + { timeoutMs: SHUTDOWN_TIMEOUT_MS }, + "Graceful shutdown timed out — forcing exit", + ); + process.exit(1); + }, SHUTDOWN_TIMEOUT_MS); + // Don't let this timer itself keep the event loop alive. + forceExit.unref(); + logger.info({ signal }, "Received shutdown signal"); stopRetryProcessor(); stopIdempotencyCleanup(); @@ -193,6 +239,7 @@ async function start() { await closeDatabase(); await closeRedis(); await shutdownTracing(); + clearTimeout(forceExit); logger.info("Server shut down cleanly"); process.exit(0); }; diff --git a/src/stellar/transactions.ts b/src/stellar/transactions.ts index 31edd0e..0d16cb5 100644 --- a/src/stellar/transactions.ts +++ b/src/stellar/transactions.ts @@ -33,7 +33,8 @@ export async function invokeContract( const seqNum = await sequenceCache.getNextSequence(keypair.publicKey()); const account = new StellarSdk.Account(keypair.publicKey(), seqNum); - const tx = new StellarSdk.TransactionBuilder(account, { + // Build an initial transaction for simulation; fee doesn't matter here. + const txForSim = new StellarSdk.TransactionBuilder(account, { fee: StellarSdk.BASE_FEE, networkPassphrase: getNetworkPassphrase(), }) @@ -45,14 +46,36 @@ export async function invokeContract( // simulation is wasted — assembleTransaction produces a new tx that // must be signed separately) const soroban = getSorobanServer(); - const simResult = await soroban.simulateTransaction(tx); + const simResult = await soroban.simulateTransaction(txForSim); if (StellarSdk.rpc.Api.isSimulationError(simResult)) { logger.error({ error: simResult.error }, "Simulation failed"); throw new StellarError(`Simulation failed: ${simResult.error}`); } + // #218: Compute the total fee from the simulation result. + // minResourceFee covers Soroban resource costs; BASE_FEE covers + // ledger inclusion. A 20 % buffer absorbs fee-market fluctuations + // between simulation and submission without over-paying significantly. + const resourceFee = BigInt( + (simResult as StellarSdk.rpc.Api.SimulateTransactionSuccessResponse) + .minResourceFee ?? "0", + ); + const inclusionFee = BigInt(StellarSdk.BASE_FEE); + const totalFee = String( + inclusionFee + (resourceFee * 120n) / 100n, + ); + + // Rebuild the transaction with the computed fee before assembling. + const txWithFee = new StellarSdk.TransactionBuilder(account, { + fee: totalFee, + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(60) + .build(); + // Prepare the transaction with the simulation results - const preparedTx = StellarSdk.rpc.assembleTransaction(tx, simResult).build(); + const preparedTx = StellarSdk.rpc.assembleTransaction(txWithFee, simResult).build(); preparedTx.sign(keypair); const result = await stellarClient.submitTransaction(preparedTx);