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 |
|
| `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/*`
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user