From 19c80efa3e1b73e8e5a392628acf87106de15db9 Mon Sep 17 00:00:00 2001 From: "m.aliarslan" <86300262+Kewe63@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:39:08 +0300 Subject: [PATCH] fix(cache): harden response cache files (#764) --- .changeset/harden-response-cache-files.md | 5 + src/__tests__/api-cache.test.js | 201 ++++++++++++++++++++++ src/api.js | 93 +++++++--- 3 files changed, 273 insertions(+), 26 deletions(-) create mode 100644 .changeset/harden-response-cache-files.md diff --git a/.changeset/harden-response-cache-files.md b/.changeset/harden-response-cache-files.md new file mode 100644 index 00000000..8a182531 --- /dev/null +++ b/.changeset/harden-response-cache-files.md @@ -0,0 +1,5 @@ +--- +"nansen-cli": patch +--- + +Harden response-cache reads against linked or non-regular entries, and atomically publish private cache writes without following linked destinations. diff --git a/src/__tests__/api-cache.test.js b/src/__tests__/api-cache.test.js index 2f85ec32..582ac3f1 100644 --- a/src/__tests__/api-cache.test.js +++ b/src/__tests__/api-cache.test.js @@ -34,6 +34,17 @@ async function freshApi(payload, options = {}) { }); } +async function freshCache() { + vi.resetModules(); + return import('../api.js'); +} + +function onlyCacheFile(cacheDir) { + const entries = fs.readdirSync(cacheDir); + expect(entries).toHaveLength(1); + return path.join(cacheDir, entries[0]); +} + beforeEach(() => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-api-cache-')); prevHome = process.env.HOME; @@ -43,6 +54,7 @@ beforeEach(() => { }); afterEach(() => { + vi.restoreAllMocks(); if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; if (prevUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prevUserProfile; fs.rmSync(tempHome, { recursive: true, force: true }); @@ -100,3 +112,192 @@ describe('response cache preserves the payload shape', () => { expect(cached).toBe('plain-text-body'); }); }); + +describe('response cache file safety', () => { + it.skipIf(process.platform === 'win32')('does not read a final-component symlink', async () => { + const { getCacheDir, getCachedResponse, setCachedResponse } = await freshCache(); + const endpoint = '/api/v1/linked-cache'; + const body = { chain: 'ethereum' }; + setCachedResponse(endpoint, body, { source: 'cache' }); + const cacheFile = onlyCacheFile(getCacheDir()); + const outsider = path.join(tempHome, 'outside.json'); + fs.writeFileSync(outsider, JSON.stringify({ + timestamp: Date.now(), + data: { source: 'outside' }, + })); + fs.unlinkSync(cacheFile); + fs.symlinkSync(outsider, cacheFile); + + expect(getCachedResponse(endpoint, body)).toBeNull(); + }); + + it('does not read a non-regular cache entry', async () => { + const { getCacheDir, getCachedResponse, setCachedResponse } = await freshCache(); + const endpoint = '/api/v1/non-regular-cache'; + const body = { chain: 'ethereum' }; + setCachedResponse(endpoint, body, { source: 'cache' }); + const cacheFile = onlyCacheFile(getCacheDir()); + fs.unlinkSync(cacheFile); + fs.mkdirSync(cacheFile); + + expect(getCachedResponse(endpoint, body)).toBeNull(); + }); + + it.skipIf(process.platform === 'win32')('tightens an existing cache file to mode 0600', async () => { + const { getCacheDir, setCachedResponse } = await freshCache(); + const endpoint = '/api/v1/cache-mode'; + const body = { chain: 'ethereum' }; + setCachedResponse(endpoint, body, { generation: 1 }); + const cacheFile = onlyCacheFile(getCacheDir()); + expect(fs.statSync(cacheFile).mode & 0o777).toBe(0o600); + fs.chmodSync(cacheFile, 0o644); + expect(fs.statSync(cacheFile).mode & 0o777).toBe(0o644); + + setCachedResponse(endpoint, body, { generation: 2 }); + + expect(JSON.parse(fs.readFileSync(cacheFile, 'utf8')).data.generation).toBe(2); + expect(fs.statSync(cacheFile).mode & 0o777).toBe(0o600); + }); + + it.skipIf(process.platform === 'win32')('does not overwrite a symlink target', async () => { + const { getCacheDir, setCachedResponse } = await freshCache(); + const endpoint = '/api/v1/linked-write'; + const body = { chain: 'ethereum' }; + setCachedResponse(endpoint, body, { generation: 1 }); + const cacheFile = onlyCacheFile(getCacheDir()); + const outsider = path.join(tempHome, 'outside-write.json'); + fs.writeFileSync(outsider, 'leave unchanged'); + fs.unlinkSync(cacheFile); + fs.symlinkSync(outsider, cacheFile); + + setCachedResponse(endpoint, body, { generation: 2 }); + + expect(fs.readFileSync(outsider, 'utf8')).toBe('leave unchanged'); + expect(fs.lstatSync(cacheFile).isFile()).toBe(true); + expect(JSON.parse(fs.readFileSync(cacheFile, 'utf8')).data.generation).toBe(2); + }); + + it.skipIf(process.platform === 'win32')('replaces a hardlink entry without changing its outside inode', async () => { + const { getCacheDir, setCachedResponse } = await freshCache(); + const endpoint = '/api/v1/hardlinked-write'; + const body = { chain: 'ethereum' }; + setCachedResponse(endpoint, body, { generation: 1 }); + const cacheFile = onlyCacheFile(getCacheDir()); + const outsider = path.join(tempHome, 'outside-hardlink.json'); + fs.writeFileSync(outsider, 'leave unchanged'); + fs.unlinkSync(cacheFile); + fs.linkSync(outsider, cacheFile); + + setCachedResponse(endpoint, body, { generation: 2 }); + + expect(fs.readFileSync(outsider, 'utf8')).toBe('leave unchanged'); + expect(fs.statSync(cacheFile).nlink).toBe(1); + expect(JSON.parse(fs.readFileSync(cacheFile, 'utf8')).data.generation).toBe(2); + }); + + it('does not unlink a valid successor installed while reading an invalid entry', async () => { + const { getCacheDir, getCachedResponse, setCachedResponse } = await freshCache(); + const endpoint = '/api/v1/replaced-invalid-cache'; + const body = { chain: 'ethereum' }; + setCachedResponse(endpoint, body, { generation: 1 }); + const cacheFile = onlyCacheFile(getCacheDir()); + fs.writeFileSync(cacheFile, '{invalid cache json'); + const successor = `${cacheFile}.successor`; + fs.writeFileSync(successor, JSON.stringify({ + timestamp: Date.now(), + endpoint, + data: { generation: 2 }, + }), { mode: 0o600 }); + const originalReadFile = fs.readFileSync; + const readFile = vi.spyOn(fs, 'readFileSync').mockImplementation((file, ...args) => { + const contents = originalReadFile(file, ...args); + if (typeof file === 'number') fs.renameSync(successor, cacheFile); + return contents; + }); + const originalClose = fs.closeSync; + const close = vi.spyOn(fs, 'closeSync').mockImplementationOnce(fd => { + originalClose(fd); + throw new Error('synthetic close failure'); + }); + + expect(getCachedResponse(endpoint, body)).toBeNull(); + readFile.mockRestore(); + close.mockRestore(); + + expect(JSON.parse(fs.readFileSync(cacheFile, 'utf8')).data.generation).toBe(2); + }); + + it('closes the read descriptor and surfaces a close-only failure', async () => { + const { getCachedResponse, setCachedResponse } = await freshCache(); + const endpoint = '/api/v1/cache-read-close'; + const body = { chain: 'ethereum' }; + setCachedResponse(endpoint, body, { generation: 1 }); + const originalClose = fs.closeSync; + const close = vi.spyOn(fs, 'closeSync').mockImplementationOnce(fd => { + originalClose(fd); + throw new Error('synthetic close failure'); + }); + + expect(() => getCachedResponse(endpoint, body)).toThrow('synthetic close failure'); + expect(close).toHaveBeenCalledOnce(); + }); + + it('keeps the previous valid entry when a replacement write fails', async () => { + const { getCacheDir, setCachedResponse } = await freshCache(); + const endpoint = '/api/v1/failed-cache-write'; + const body = { chain: 'ethereum' }; + setCachedResponse(endpoint, body, { generation: 1 }); + const cacheFile = onlyCacheFile(getCacheDir()); + const write = vi.spyOn(fs, 'writeFileSync').mockImplementationOnce(() => { + throw new Error('synthetic write failure'); + }); + + expect(() => setCachedResponse(endpoint, body, { generation: 2 })).toThrow('synthetic write failure'); + write.mockRestore(); + + expect(JSON.parse(fs.readFileSync(cacheFile, 'utf8')).data.generation).toBe(1); + expect(fs.readdirSync(getCacheDir())).toEqual([path.basename(cacheFile)]); + }); + + it('surfaces a writer close-only failure and removes the uncommitted temp file', async () => { + const { getCacheDir, setCachedResponse } = await freshCache(); + const endpoint = '/api/v1/cache-write-close'; + const body = { chain: 'ethereum' }; + setCachedResponse(endpoint, body, { generation: 1 }); + const cacheFile = onlyCacheFile(getCacheDir()); + const originalClose = fs.closeSync; + const close = vi.spyOn(fs, 'closeSync').mockImplementationOnce(fd => { + originalClose(fd); + throw new Error('synthetic close failure'); + }); + + expect(() => setCachedResponse(endpoint, body, { generation: 2 })).toThrow('synthetic close failure'); + close.mockRestore(); + + expect(JSON.parse(fs.readFileSync(cacheFile, 'utf8')).data.generation).toBe(1); + expect(fs.readdirSync(getCacheDir())).toEqual([path.basename(cacheFile)]); + }); + + it('preserves a write failure when descriptor close also fails and still removes the temp file', async () => { + const { getCacheDir, setCachedResponse } = await freshCache(); + const endpoint = '/api/v1/failed-cache-write-and-close'; + const body = { chain: 'ethereum' }; + setCachedResponse(endpoint, body, { generation: 1 }); + const cacheFile = onlyCacheFile(getCacheDir()); + const write = vi.spyOn(fs, 'writeFileSync').mockImplementationOnce(() => { + throw new Error('synthetic write failure'); + }); + const originalClose = fs.closeSync; + const close = vi.spyOn(fs, 'closeSync').mockImplementationOnce(fd => { + originalClose(fd); + throw new Error('synthetic close failure'); + }); + + expect(() => setCachedResponse(endpoint, body, { generation: 2 })).toThrow('synthetic write failure'); + write.mockRestore(); + close.mockRestore(); + + expect(JSON.parse(fs.readFileSync(cacheFile, 'utf8')).data.generation).toBe(1); + expect(fs.readdirSync(getCacheDir())).toEqual([path.basename(cacheFile)]); + }); +}); diff --git a/src/api.js b/src/api.js index e990a4d3..597a7b6a 100644 --- a/src/api.js +++ b/src/api.js @@ -14,6 +14,7 @@ import { getAnonymousId, TELEMETRY_DISABLED } from './telemetry.js'; import { readResponseMeta, stringHeader } from './response-meta.js'; import { trace, traceRequest, traceResponse, traceError, traceRetry, traceCacheHit } from './debug.js'; import { appendPaymentRecord, newPaymentId, sanitizeRequestUrl } from './x402-audit.js'; +import { openLocalFile } from './local-file.js'; /** * Key for the credit/rate-limit metadata attached to a successful response. @@ -384,37 +385,47 @@ export function getCachedResponse(endpoint, body, ttlSeconds = DEFAULT_CACHE_TTL return null; } + let fd; + let primaryError; + let result = null; try { - const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); + fd = openLocalFile(cacheFile, { root: CACHE_DIR }); + const cached = JSON.parse(fs.readFileSync(fd, 'utf8')); const age = (Date.now() - cached.timestamp) / 1000; - if (!Number.isFinite(cached.timestamp) || ttlSeconds <= 0 || age > ttlSeconds) { - // Delete entries with invalid timestamps, expired entries, or disabled entries. - fs.unlinkSync(cacheFile); - return null; + if (Number.isFinite(cached.timestamp) && ttlSeconds > 0 && age <= ttlSeconds) { + // Re-attach the cache marker without reshaping the payload. Object-spreading + // a top-level array turns it into { 0: …, 1: … }, so a cached array came back + // as an object and every Array.isArray() branch downstream (the table/CSV/ + // markdown formatters in cli.js, alertsGet) stopped recognising it — the first + // call rendered rows and the second rendered nothing. Arrays therefore get + // _meta hung off the array itself, exactly as the live request path does when + // it records retriedAttempts, and primitives are handed back untouched + // because a non-object cannot carry the marker at all. + const meta = { ...cached.data?._meta, fromCache: true, cacheAge: Math.round(age) }; + if (Array.isArray(cached.data)) { + const rows = cached.data.slice(); + rows._meta = meta; + result = rows; + } else if (cached.data === null || typeof cached.data !== 'object') { + result = cached.data; + } else { + result = { ...cached.data, _meta: meta }; + } } - - // Re-attach the cache marker without reshaping the payload. Object-spreading - // a top-level array turns it into { 0: …, 1: … }, so a cached array came back - // as an object and every Array.isArray() branch downstream (the table/CSV/ - // markdown formatters in cli.js, alertsGet) stopped recognising it — the first - // call rendered rows and the second rendered nothing. Arrays therefore get - // _meta hung off the array itself, exactly as the live request path does when - // it records retriedAttempts, and primitives are handed back untouched - // because a non-object cannot carry the marker at all. - const meta = { ...cached.data?._meta, fromCache: true, cacheAge: Math.round(age) }; - if (Array.isArray(cached.data)) { - const rows = cached.data.slice(); - rows._meta = meta; - return rows; + } catch (error) { + primaryError = error; + } + let closeError; + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch (error) { + closeError = error; } - if (cached.data === null || typeof cached.data !== 'object') return cached.data; - return { ...cached.data, _meta: meta }; - } catch (_e) { - // Invalid cache file, delete it - try { fs.unlinkSync(cacheFile); } catch { /* ignore */ } - return null; } + if (!primaryError && closeError) throw closeError; + return result; } /** @@ -434,7 +445,37 @@ export function setCachedResponse(endpoint, body, data, context = {}) { data }; - fs.writeFileSync(cacheFile, JSON.stringify(cached), { mode: 0o600 }); + const contents = JSON.stringify(cached); + const tempFile = `${cacheFile}.${process.pid}.${crypto.randomUUID()}.tmp`; + let fd; + let primaryError; + try { + fd = openLocalFile(tempFile, { + root: CACHE_DIR, + flags: fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + mode: 0o600, + }); + if (process.platform !== 'win32') fs.fchmodSync(fd, 0o600); + fs.writeFileSync(fd, contents); + fs.fsyncSync(fd); + if (process.platform !== 'win32') fs.fchmodSync(fd, 0o600); + fs.closeSync(fd); + fd = undefined; + fs.renameSync(tempFile, cacheFile); + } catch (error) { + primaryError = error; + } + let closeError; + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch (error) { + closeError = error; + } + } + try { fs.unlinkSync(tempFile); } catch { /* rename may already have committed */ } + if (primaryError) throw primaryError; + if (closeError) throw closeError; } /**