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 |
| `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/*`

View File

@ -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<string, Promise<unknown>>();
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> {
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;
}

View File

@ -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');