fix(C7): mitigate FMP key exposure

This commit is contained in:
Saravana Achu Mac 2026-05-04 17:07:52 -07:00
parent e72b375557
commit e2e189eede
3 changed files with 41 additions and 6 deletions

View File

@ -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 | | `PAPER_TRADING` | — | Set `true` for paper mode |
| `AZURE_KEYVAULT_URL` | Prod | Enables auto-resolution of `invttrdg-*` secrets at startup | | `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 ## Shared Dependencies
Common-platform packages are vendored from `../learning_ai_common_plat/packages/*` Common-platform packages are vendored from `../learning_ai_common_plat/packages/*`

View File

@ -1,3 +1,5 @@
import { createHash } from 'node:crypto';
export const FMP_CACHE_TTL_MS = 30 * 60 * 1000; export const FMP_CACHE_TTL_MS = 30 * 60 * 1000;
export class FmpFetchError extends Error { export class FmpFetchError extends Error {
@ -23,8 +25,25 @@ const inFlightFmpRequests = new Map<string, Promise<unknown>>();
let now = () => Date.now(); 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<unknown> { export async function fetchFmpJson(url: string, fetchJson: FetchJson = fetch): Promise<unknown> {
const cached = fmpCache.get(url); const cacheKey = buildFmpCacheKey(url);
const cached = fmpCache.get(cacheKey);
const currentTime = now(); const currentTime = now();
if (cached && cached.expiresAt > currentTime) { if (cached && cached.expiresAt > currentTime) {
@ -32,10 +51,10 @@ export async function fetchFmpJson(url: string, fetchJson: FetchJson = fetch): P
} }
if (cached) { if (cached) {
fmpCache.delete(url); fmpCache.delete(cacheKey);
} }
const inFlight = inFlightFmpRequests.get(url); const inFlight = inFlightFmpRequests.get(cacheKey);
if (inFlight) { if (inFlight) {
return inFlight; return inFlight;
} }
@ -47,17 +66,17 @@ export async function fetchFmpJson(url: string, fetchJson: FetchJson = fetch): P
} }
const data = await response.json(); const data = await response.json();
fmpCache.set(url, { fmpCache.set(cacheKey, {
data, data,
expiresAt: now() + FMP_CACHE_TTL_MS, expiresAt: now() + FMP_CACHE_TTL_MS,
}); });
return data; return data;
}) })
.finally(() => { .finally(() => {
inFlightFmpRequests.delete(url); inFlightFmpRequests.delete(cacheKey);
}); });
inFlightFmpRequests.set(url, request); inFlightFmpRequests.set(cacheKey, request);
return request; return request;
} }

View File

@ -1,9 +1,11 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
buildFmpCacheKey,
clearFmpCacheForTests, clearFmpCacheForTests,
fetchFmpJson, fetchFmpJson,
FMP_CACHE_TTL_MS, FMP_CACHE_TTL_MS,
FmpFetchError, FmpFetchError,
redactFmpUrlForLogs,
setFmpCacheNowForTests, setFmpCacheNowForTests,
} from './src/services/fmpCache.js'; } from './src/services/fmpCache.js';
@ -42,6 +44,15 @@ async function main() {
const otherUrl = 'https://financialmodelingprep.com/api/v3/profile/MSFT?apikey=test'; const otherUrl = 'https://financialmodelingprep.com/api/v3/profile/MSFT?apikey=test';
const fetcher = createFetch([{ symbol: 'AAPL' }, { symbol: 'AAPL-refresh' }]); 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.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'); assert.equal(fetcher.calls, 1, 'same full URL should be served from cache before TTL expiry');