fix(C7): mitigate FMP key exposure
This commit is contained in:
parent
e72b375557
commit
e2e189eede
@ -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/*`
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user