Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/harden-response-cache-files.md
Original file line number Diff line number Diff line change
@@ -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.
201 changes: 201 additions & 0 deletions src/__tests__/api-cache.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 });
Expand Down Expand Up @@ -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)]);
});
});
93 changes: 67 additions & 26 deletions src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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;
}

/**
Expand Down
Loading