From e2e189eedef5b38f664a3dfb2fc95e2de3ddb433 Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Mon, 4 May 2026 17:07:52 -0700 Subject: [PATCH] fix(C7): mitigate FMP key exposure --- README.md | 5 +++++ backend/src/services/fmpCache.ts | 31 +++++++++++++++++++++++++------ backend/testFmpCache.ts | 11 +++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8d25e47..acbe628 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,11 @@ Each surface has its own `.env.example`. The root `.env.example` is the comprehe | `PAPER_TRADING` | — | Set `true` for paper mode | | `AZURE_KEYVAULT_URL` | Prod | Enables auto-resolution of `invttrdg-*` secrets at startup | +Financial Modeling Prep requires `apikey` query-string authentication, so the +backend keeps all FMP calls server-side, rejects the shared `demo` key, caches +FMP responses for 30 minutes, hashes cache keys instead of storing raw URLs, and +uses redacted URL helpers for any future FMP log output. + ## Shared Dependencies Common-platform packages are vendored from `../learning_ai_common_plat/packages/*` diff --git a/backend/src/services/fmpCache.ts b/backend/src/services/fmpCache.ts index ad5a93f..e7c2a06 100644 --- a/backend/src/services/fmpCache.ts +++ b/backend/src/services/fmpCache.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + export const FMP_CACHE_TTL_MS = 30 * 60 * 1000; export class FmpFetchError extends Error { @@ -23,8 +25,25 @@ const inFlightFmpRequests = new Map>(); let now = () => Date.now(); +export function redactFmpUrlForLogs(url: string): string { + try { + const parsed = new URL(url); + if (parsed.searchParams.has('apikey')) { + parsed.searchParams.set('apikey', '[REDACTED]'); + } + return parsed.toString(); + } catch { + return url.replace(/([?&]apikey=)[^&]+/i, '$1[REDACTED]'); + } +} + +export function buildFmpCacheKey(url: string): string { + return createHash('sha256').update(url).digest('hex'); +} + export async function fetchFmpJson(url: string, fetchJson: FetchJson = fetch): Promise { - const cached = fmpCache.get(url); + const cacheKey = buildFmpCacheKey(url); + const cached = fmpCache.get(cacheKey); const currentTime = now(); if (cached && cached.expiresAt > currentTime) { @@ -32,10 +51,10 @@ export async function fetchFmpJson(url: string, fetchJson: FetchJson = fetch): P } if (cached) { - fmpCache.delete(url); + fmpCache.delete(cacheKey); } - const inFlight = inFlightFmpRequests.get(url); + const inFlight = inFlightFmpRequests.get(cacheKey); if (inFlight) { return inFlight; } @@ -47,17 +66,17 @@ export async function fetchFmpJson(url: string, fetchJson: FetchJson = fetch): P } const data = await response.json(); - fmpCache.set(url, { + fmpCache.set(cacheKey, { data, expiresAt: now() + FMP_CACHE_TTL_MS, }); return data; }) .finally(() => { - inFlightFmpRequests.delete(url); + inFlightFmpRequests.delete(cacheKey); }); - inFlightFmpRequests.set(url, request); + inFlightFmpRequests.set(cacheKey, request); return request; } diff --git a/backend/testFmpCache.ts b/backend/testFmpCache.ts index 9abe179..f257ecf 100644 --- a/backend/testFmpCache.ts +++ b/backend/testFmpCache.ts @@ -1,9 +1,11 @@ import assert from 'node:assert/strict'; import { + buildFmpCacheKey, clearFmpCacheForTests, fetchFmpJson, FMP_CACHE_TTL_MS, FmpFetchError, + redactFmpUrlForLogs, setFmpCacheNowForTests, } from './src/services/fmpCache.js'; @@ -42,6 +44,15 @@ async function main() { const otherUrl = 'https://financialmodelingprep.com/api/v3/profile/MSFT?apikey=test'; const fetcher = createFetch([{ symbol: 'AAPL' }, { symbol: 'AAPL-refresh' }]); + const cacheKey = buildFmpCacheKey(url); + assert.notEqual(cacheKey, url, 'cache keys should not retain raw FMP URLs with query-string API keys'); + assert(!cacheKey.includes('apikey'), 'cache keys should not expose the FMP apikey parameter'); + assert.equal( + redactFmpUrlForLogs(url), + 'https://financialmodelingprep.com/api/v3/profile/AAPL?apikey=%5BREDACTED%5D', + 'log-safe FMP URLs should redact apikey values', + ); + assert.deepEqual(await fetchFmpJson(url, fetcher.fetchJson), { symbol: 'AAPL' }); assert.deepEqual(await fetchFmpJson(url, fetcher.fetchJson), { symbol: 'AAPL' }); assert.equal(fetcher.calls, 1, 'same full URL should be served from cache before TTL expiry');