diff --git a/.changeset/cuddly-tires-tan.md b/.changeset/cuddly-tires-tan.md new file mode 100644 index 00000000..b4f26d94 --- /dev/null +++ b/.changeset/cuddly-tires-tan.md @@ -0,0 +1,5 @@ +--- +'@fingerprint/aws-cloudfront-proxy': minor +--- + +Introduce TTL for secret caching diff --git a/proxy/app.ts b/proxy/app.ts index 3cc694d0..568a69a0 100644 --- a/proxy/app.ts +++ b/proxy/app.ts @@ -8,6 +8,7 @@ import type { CloudFrontRequest } from 'aws-lambda/common/cloudfront' import { createIngressHandler } from './handlers/handleIngress' import { handleStatus } from './handlers/handleStatus' import { V4_INGRESS_PATH } from './utils/paths' +import { getSecretCacheTtlMs } from './utils/headers' export type Route = { pathPattern: RegExp @@ -57,7 +58,7 @@ export const handler = async (event: CloudFrontRequestEvent): Promise { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + test('should store and retrieve values', () => { + const cache = new TTLCache(1000) + cache.set('key1', 'value1') + + expect(cache.get('key1')).toBe('value1') + expect(cache.has('key1')).toBe(true) + }) + + test('should return undefined for missing keys', () => { + const cache = new TTLCache(1000) + + expect(cache.get('missingKey')).toBeUndefined() + expect(cache.has('missingKey')).toBe(false) + }) + + test('should expire items after default TTL', () => { + const cache = new TTLCache(1000) + cache.set('key1', 'value1') + + expect(cache.get('key1')).toBe('value1') + + // Advance time past TTL + jest.advanceTimersByTime(1001) + + expect(cache.get('key1')).toBeUndefined() + expect(cache.has('key1')).toBe(false) + }) + + test('should expire items after default TTL for null values', () => { + const cache = new TTLCache(1000) + cache.set('key1', null) + + expect(cache.get('key1')).toBeNull() + expect(cache.has('key1')).toBe(true) + + // Advance time past TTL + jest.advanceTimersByTime(1001) + + expect(cache.get('key1')).toBeUndefined() + expect(cache.has('key1')).toBe(false) + }) + + test('should respect custom TTL per item', () => { + const cache = new TTLCache(1000) + + // Item with shorter TTL + cache.set('key1', 'value1', 500) + // Item with longer TTL + cache.set('key2', 'value2', 2000) + + // Advance past first item's TTL but before second item's TTL + jest.advanceTimersByTime(1000) + + expect(cache.get('key1')).toBeUndefined() + expect(cache.get('key2')).toBe('value2') + + // Advance past second item's TTL + jest.advanceTimersByTime(1001) + + expect(cache.get('key2')).toBeUndefined() + }) + + test('should fallback to default TTL if passed TTL is NaN', () => { + const cache = new TTLCache(1000) + + cache.set('key1', 'value1', NaN) + expect(cache.get('key1')).toBe('value1') + + jest.advanceTimersByTime(1001) + + expect(cache.get('key1')).toBeUndefined() + }) + + test('should delete items', () => { + const cache = new TTLCache(1000) + cache.set('key1', 'value1') + + expect(cache.get('key1')).toBe('value1') + + cache.delete('key1') + + expect(cache.get('key1')).toBeUndefined() + expect(cache.has('key1')).toBe(false) + }) + + test('should clear all items', () => { + const cache = new TTLCache(1000) + cache.set('key1', 'value1') + cache.set('key2', 'value2') + + cache.clear() + + expect(cache.get('key1')).toBeUndefined() + expect(cache.get('key2')).toBeUndefined() + }) + + test('has() should trigger eviction if item is expired', () => { + const cache = new TTLCache(1000) + cache.set('key1', 'value1') + + jest.advanceTimersByTime(1001) + + expect(cache.has('key1')).toBe(false) + // Underlying map should have been cleaned up by has() which calls get() + expect(cache.get('key1')).toBeUndefined() + }) +}) diff --git a/proxy/test/utils/customer-variables/secrets-manager/retrieve-secret.test.ts b/proxy/test/utils/customer-variables/secrets-manager/retrieve-secret.test.ts index 157908f9..9225dad4 100644 --- a/proxy/test/utils/customer-variables/secrets-manager/retrieve-secret.test.ts +++ b/proxy/test/utils/customer-variables/secrets-manager/retrieve-secret.test.ts @@ -9,11 +9,16 @@ const client = new SecretsManagerClient({}) describe('retrieve secret', () => { beforeEach(() => { + jest.useFakeTimers() clearSecretsCache() mock.reset() }) + afterEach(() => { + jest.useRealTimers() + }) + it('caches result even if it is null', async () => { mock .on(GetSecretValueCommand, { @@ -27,6 +32,26 @@ describe('retrieve secret', () => { expect(mock).toHaveReceivedCommandTimes(GetSecretValueCommand, 1) }) + it('refetches secret after cache expires', async () => { + mock + .on(GetSecretValueCommand, { + SecretId: secretName, + }) + .resolves({}) + + await retrieveSecret(client, secretName) + await retrieveSecret(client, secretName) + + expect(mock).toHaveReceivedCommandTimes(GetSecretValueCommand, 1) + + jest.advanceTimersByTime(500_001) + + await retrieveSecret(client, secretName) + await retrieveSecret(client, secretName) + + expect(mock).toHaveReceivedCommandTimes(GetSecretValueCommand, 2) + }) + it('caches result even if it secrets manager throws', async () => { mock .on(GetSecretValueCommand, { diff --git a/proxy/utils/cache.ts b/proxy/utils/cache.ts new file mode 100644 index 00000000..629949ac --- /dev/null +++ b/proxy/utils/cache.ts @@ -0,0 +1,56 @@ +interface CacheItem { + value: T + expiresAt: number +} + +export class TTLCache { + private cache: Map> + private readonly ttlMs: number + + // Default TTL is 5 minutes + private static readonly DEFAULT_TTL_MS = 300_000 + + constructor(ttlMs: number) { + this.cache = new Map() + this.ttlMs = TTLCache.isValidTTL(ttlMs) ? ttlMs : TTLCache.DEFAULT_TTL_MS + } + + get(key: K): V | undefined { + const item = this.cache.get(key) + if (item === undefined) { + return undefined + } + + if (Date.now() >= item.expiresAt) { + this.cache.delete(key) + return undefined + } + + return item.value + } + + set(key: K, value: V, customTtlMs?: number): void { + const ttlMsToUse = TTLCache.isValidTTL(customTtlMs) ? customTtlMs : this.ttlMs + + this.cache.set(key, { + value, + expiresAt: Date.now() + ttlMsToUse, + }) + } + + has(key: K): boolean { + return this.get(key) !== undefined + } + + delete(key: K): void { + this.cache.delete(key) + } + + clear(): void { + this.cache.clear() + } + + static isValidTTL(value?: number): value is number { + return typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value) && value >= 0 + } +} diff --git a/proxy/utils/customer-variables/secrets-manager/retrieve-secret.ts b/proxy/utils/customer-variables/secrets-manager/retrieve-secret.ts index f91c70d1..f48d791c 100644 --- a/proxy/utils/customer-variables/secrets-manager/retrieve-secret.ts +++ b/proxy/utils/customer-variables/secrets-manager/retrieve-secret.ts @@ -1,37 +1,32 @@ import { CustomerVariablesRecord } from '../types' import { - SecretsManagerClient, GetSecretValueCommand, GetSecretValueCommandOutput, + SecretsManagerClient, } from '@aws-sdk/client-secrets-manager' import { arrayBufferToString } from '../../buffer' import { validateSecret } from './validate-secret' import { normalizeSecret } from './normalize-secret' - -interface CacheEntry { - value: CustomerVariablesRecord | null -} +import { TTLCache } from '../../cache' /** * Global cache for customer variables fetched from Secrets Manager. + * By default, the cache is set to expire after 5 minutes. * */ -const cache = new Map() +const cache = new TTLCache(300_000) /** * Retrieves a secret from Secrets Manager and caches it or returns it from cache if it's still valid. * */ -export async function retrieveSecret(secretsManager: SecretsManagerClient, key: string) { - if (cache.has(key)) { - const entry = cache.get(key)! - - return entry.value +export async function retrieveSecret(secretsManager: SecretsManagerClient, key: string, cacheTtlMs?: number) { + const cached = cache.get(key) + if (cached !== undefined) { + return cached } const result = await fetchSecret(secretsManager, key) - cache.set(key, { - value: result, - }) + cache.set(key, result, cacheTtlMs) return result } diff --git a/proxy/utils/customer-variables/secrets-manager/secrets-manager-variables.ts b/proxy/utils/customer-variables/secrets-manager/secrets-manager-variables.ts index d3f33f20..7a36c1a7 100644 --- a/proxy/utils/customer-variables/secrets-manager/secrets-manager-variables.ts +++ b/proxy/utils/customer-variables/secrets-manager/secrets-manager-variables.ts @@ -18,7 +18,10 @@ export class SecretsManagerVariables implements CustomerVariableProvider { private readonly secretsManager?: SecretsManagerClient - constructor(private readonly request: CloudFrontRequest) { + constructor( + private readonly request: CloudFrontRequest, + private readonly cacheTtlMs?: number + ) { this.readSecretsInfoFromHeaders() if (SecretsManagerVariables.isValidSecretInfo(this.secretsInfo)) { @@ -45,7 +48,7 @@ export class SecretsManagerVariables implements CustomerVariableProvider { } try { - return await retrieveSecret(this.secretsManager, this.secretsInfo!.secretName!) + return await retrieveSecret(this.secretsManager, this.secretsInfo!.secretName!, this.cacheTtlMs) } catch (error) { console.error('Error retrieving secret from secrets manager', { error, diff --git a/proxy/utils/headers.ts b/proxy/utils/headers.ts index 762637a4..77721c15 100644 --- a/proxy/utils/headers.ts +++ b/proxy/utils/headers.ts @@ -4,6 +4,7 @@ import { filterCookie } from './cookie' import { updateCacheControlHeader } from './cache-control' import { CustomerVariables } from './customer-variables/customer-variables' import { getPreSharedSecret } from './customer-variables/selectors' +import { TTLCache } from './cache' export const BLACKLISTED_HEADERS = new Set([ 'age', @@ -225,3 +226,22 @@ export function getHeaderValue(request: CloudFrontRequest, name: string): string } return headers[name][0].value } + +/** + * Retrieves the secret cache time-to-live (TTL) value in milliseconds from the request headers. + * + * @param {CloudFrontRequest} request - The CloudFront request object containing headers. + * @return {number|undefined} The parsed TTL value in milliseconds if present and valid; otherwise, undefined. + */ +export function getSecretCacheTtlMs(request: CloudFrontRequest): number | undefined { + const value = getHeaderValue(request, 'fpjs_proxy_secret_cache_ttl_ms') + + if (value) { + const parsedValue = parseInt(value, 10) + if (TTLCache.isValidTTL(parsedValue)) { + return parsedValue + } + } + + return undefined +}