Skip to content
Merged
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/safe-local-file-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nansen-cli": patch
---

Reject symlinks, hard links, and replaced files when reading saved authentication and trading quotes. Validate authentication lock descriptors before use. Claim bridge quotes before signing and block reuse if a post-broadcast execution marker cannot be saved. Authentication reads now require user-owned paths without group or other write permissions on POSIX; repair permissions before retrying an unsafe saved-key read.
20 changes: 19 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,24 @@ jobs:
- name: Run tests
run: npm test

local-file-windows:
runs-on: windows-latest
permissions:
contents: read
strategy:
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npx vitest run src/__tests__/local-file-security.test.js src/__tests__/local-file-platform.test.js

native-optional-install:
name: "Native auth on ${{ matrix.image }}: ${{ matrix.outcome }}"
runs-on: ubuntu-latest
Expand Down Expand Up @@ -112,7 +130,7 @@ jobs:
printf '%s\n' "- $NANSEN_IMAGE: $NANSEN_AUTH_OUTCOME. Confirmed with published 2.0.0 and the PR tarball." >> "$GITHUB_STEP_SUMMARY"

publish:
needs: [lint, test, native-optional-install]
needs: [lint, test, local-file-windows, native-optional-install]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
outputs:
Expand Down
29 changes: 29 additions & 0 deletions docs/local-file-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Managed local file reads

Authentication and trading storage must contain regular files. The CLI refuses symlinked files, hard links, and symlinked directories below its storage root. Trading and cache storage may use an intentionally symlinked root; its target identity is checked again after opening. Authentication requires a link-free root as well. Authentication also rejects files and directories owned by another user or writable by group or other users on POSIX platforms.

`local-file.js` opens with `O_NOFOLLOW` and `O_NONBLOCK` where the platform provides them. It checks the opened descriptor's type, link count, identity, and applicable permissions before reading. It compares the descriptor with the path and checks the containing directories again. A rejected descriptor closes immediately. An unlocked authentication config read retries once after an inode change caused by an atomic update, repeating every check before reading; continued replacement still fails closed. Authentication journals keep their existing 4 KiB limit, measured on the opened descriptor.

These checks do not sandbox a process already running as the user's OS identity. Such a process can edit a regular file in place, change HOME, or manipulate ancestor directories. Directory comparisons detect ordinary replacements but do not provide descriptor-relative traversal like `openat`. Home ancestors may legitimately be symlinks, as with `/var` on macOS. On Windows, POSIX ownership and mode checks do not apply and `O_NOFOLLOW` may be absent; the path and descriptor comparisons still run.

## API-733 paths

| Path | Source and validation |
| --- | --- |
| Authentication config | Fixed `config.json` below the absolute HOME/USERPROFILE directory. Development config is a package-local fallback. A present but unsafe user config fails without selecting the development config. An explicitly supplied environment API key retains precedence. |
| Authentication journals | Fixed `auth-operations` directory, UUID filenames, journal schema validation, and descriptor checks before JSON parsing. |
| Authentication locks | Fixed lock names or UUID journal lock names. Descriptor checks run before native locking. The secure-store worker accepts an absolute directory from its parent and independently validates its lock. |
| Cache inspection | Fixed cache namespaces. Response entries must have 64 hex digits and `.json`. Descriptor checks run before timestamp extraction. Unsafe entries are skipped. Statistics never return cached payloads. |
| Trading and bridge quotes | Quote IDs and transaction hashes form a single filename component. Separators, colons, NUL, and traversal are rejected. Reads, execution-claim release, marker reads, and cleanup reads use descriptor checks. |

Explicit file import options are user-authorized reads and retain their documented behavior. API-733 concerns managed state, where following a link could load unrelated data as credentials or transaction input. Authentication locks and quote-marker writes also validate their descriptors before use. Other writes remain outside this hardening.

Bridge execution shares the swap quote claim. Before signing, it renames the quote to `.executing.json`, flushes the directory on POSIX, and validates the claimed data against the reviewed quote. A definite pre-broadcast rejection releases the claim. A successful or uncertain broadcast requires a flushed execution marker before release. If the marker cannot be read, written, or flushed, execution stops and the claim stays in place. Check bridge status before requesting a new quote. Dry runs and declined confirmation do not claim or consume a quote.

## Verification

`src/__tests__/local-file-security.test.js` exercises real temporary files and production entry points. It checks symlinks, hard links, directory redirection, path traversal, a file replacement during open, journal limits, lock substitution, and quote claims. It asserts that rejected targets never reach `readFileSync`. The secure-store test starts the real guard process and confirms that a linked lock never reaches readiness.

The existing auth recovery, process lifetime, trading, bridge, and cache suites cover normal operation. Bridge regression tests inject marker read, write, and flush failures after a mocked broadcast and verify that a second attempt never signs. They also check concurrent execution and preservation of pre-broadcast retries. No real credential store, funded wallet, or production API is used for the attack fixtures.

Windows CI runs these filesystem fixtures and a real CLI saved-key research request followed by quote creation and a dry run on Node 22 and 24, using only a loopback mock server.
2 changes: 1 addition & 1 deletion src/__tests__/auth-store-lifetime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ async function until(test, timeout = 5000) {
function harness() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'auth-lifetime-')); roots.push(root);
fs.mkdirSync(path.join(root, 'entries'));
for (const file of ['auth-state.js', 'auth-device.js', 'auth-store.js', 'auth-store-worker.js', 'auth-credentials.js']) fs.copyFileSync(path.join(src, file), path.join(root, file));
for (const file of ['auth-state.js', 'auth-device.js', 'auth-store.js', 'auth-store-worker.js', 'auth-credentials.js', 'local-file.js']) fs.copyFileSync(path.join(src, file), path.join(root, file));
fs.writeFileSync(path.join(root, 'auth-store-worker.js'), `import ${JSON.stringify(guard)};\n` + fs.readFileSync(path.join(root, 'auth-store-worker.js'), 'utf8'));
fs.writeFileSync(path.join(root, 'package.json'), '{"type":"module"}');
fs.symlinkSync(path.join(src, '../node_modules'), path.join(root, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir');
Expand Down
55 changes: 54 additions & 1 deletion src/__tests__/bridge-broadcast-failclosed.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import os from 'os';
import path from 'path';

import { exportWallet, getWalletConfig, showWallet } from '../wallet.js';
import { buildBridgeCommands } from '../bridge.js';
import { buildBridgeCommands, loadBridgeQuote } from '../bridge.js';

const ADDR = '0x' + 'ab'.repeat(20);
// Real Base -> Hyperliquid deposit router/selector — the preflight rejects
Expand Down Expand Up @@ -128,10 +128,63 @@ describe('bridge EVM broadcast fail-closed', () => {
});

afterEach(() => {
vi.restoreAllMocks();
process.env.HOME = prevHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
});

it.each(['read', 'write', 'flush'])('keeps the quote claimed when the post-broadcast marker %s fails', async failure => {
const quoteId = `bridge-marker-${failure}`;
writeQuote(quoteId);
const claimed = path.join(quotesDir, `${quoteId}.executing.json`);
const extraLink = path.join(tmpHome, 'linked-quote.json');
const sync = fs.fsyncSync.bind(fs);
evmRpcCall.mockImplementation(async (_chain, method) => {
if (method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' };
if (method === 'eth_sendRawTransaction') {
if (failure === 'read') fs.linkSync(claimed, extraLink);
if (failure === 'write') vi.spyOn(fs, 'writeSync').mockImplementation(() => { throw new Error('fixture write failure'); });
if (failure === 'flush') vi.spyOn(fs, 'fsyncSync').mockImplementation(fd => {
if (fs.fstatSync(fd).isFile()) throw new Error('fixture flush failure');
return sync(fd);
});
return '0xbroadcast';
}
return '0x0';
});
const cmds = buildBridgeCommands({ log: () => {} });
await expect(cmds.execute([], api, {}, { quote: quoteId, wallet: 'w' }))
.rejects.toMatchObject({ code: 'QUOTE_STATE_UNSAFE' });
vi.restoreAllMocks();
if (fs.existsSync(extraLink)) fs.unlinkSync(extraLink);
expect(fs.existsSync(claimed)).toBe(true);
expect(fs.existsSync(path.join(quotesDir, `${quoteId}.json`))).toBe(false);
expect(() => loadBridgeQuote(quoteId)).toThrow('claimed');
signEvmTransaction.mockClear();
await expect(cmds.execute([], api, {}, { quote: quoteId, wallet: 'w' })).rejects.toThrow('claimed');
expect(signEvmTransaction).not.toHaveBeenCalled();
expect(evmRpcCall.mock.calls.filter(([, method]) => method === 'eth_sendRawTransaction')).toHaveLength(1);
});

it('refuses a simultaneous execute while the first broadcast is awaiting its receipt', async () => {
const quoteId = 'bridge-concurrent'; writeQuote(quoteId);
evmRpcCall.mockImplementation(async (_chain, method) => method === 'eth_getBlockByNumber' ? { baseFeePerGas: '0x1' } : '0xbroadcast');
let entered, finish;
const waiting = new Promise(resolve => { entered = resolve; });
waitForReceipt.mockImplementationOnce(() => {
entered();
return new Promise(resolve => { finish = () => resolve({ status: '0x1' }); });
});
const cmds = buildBridgeCommands({ log: () => {} });
const first = cmds.execute([], api, {}, { quote: quoteId, wallet: 'w' });
await waiting;
try {
await expect(cmds.execute([], api, {}, { quote: quoteId, wallet: 'w' })).rejects.toThrow('claimed');
expect(signEvmTransaction).toHaveBeenCalledTimes(1);
} finally { finish(); await first; }
expect(() => loadBridgeQuote(quoteId)).toThrow('already executed');
});

it('marks the quote spent on an AMBIGUOUS send failure (non-JSON 502) and refuses a re-execute', async () => {
stubSend(Object.assign(new Error('RPC endpoint returned non-JSON response (HTTP 502)'), { code: 'RPC_HTTP_ERROR', status: 502 }));
const cmds = buildBridgeCommands({ log: () => {} });
Expand Down
66 changes: 66 additions & 0 deletions src/__tests__/local-file-platform.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import http from 'node:http';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { expect, it, vi } from 'vitest';
import { createWallet } from '../wallet.js';

it('uses a saved API key and reads a CLI-generated quote through a dry run', async () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-platform-'));
const requests = [];
const quote = { success: true, quotes: [{
aggregator: 'lifi', inputMint: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
outputMint: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
inAmount: '1000000000000000000', outAmount: '3000000000',
transaction: { to: '0x1231deb6f5749ef6ce6943a275a1d3e7486f4eae', data: '0x12345678',
value: '1000000000000000000', gas: '100000', maxFeePerGas: '1000000', maxPriorityFeePerGas: '1000000' },
}] };
const server = http.createServer(async (req, res) => {
let raw = ''; for await (const chunk of req) raw += chunk;
const body = raw ? JSON.parse(raw) : {};
requests.push({ url: req.url, key: req.headers.apikey, method: body.method });
let response = { data: [] };
if (req.url.startsWith('/quote')) response = quote;
else if (req.url.includes('/sanctions/screen')) response = { results: body.addresses.map(address => ({ address, sanctioned: false })) };
else if (body.method) {
const results = { eth_getCode: '0x6080604052', eth_call: '0x', eth_estimateGas: '0x186a0',
eth_getBalance: '0x100000000000000000000', eth_getTransactionCount: '0x5', eth_getBlockByNumber: { baseFeePerGas: '0x1' } };
response = { jsonrpc: '2.0', id: body.id || 1, result: results[body.method] ?? null };
}
res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(response));
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const env = { ...process.env, HOME: home, USERPROFILE: home, NANSEN_BASE_URL: origin,
NANSEN_TRADING_API_URL: origin, NANSEN_BASE_RPC: origin, NANSEN_BASE_SIM_RPC: origin,
NANSEN_NO_TELEMETRY: '1', DO_NOT_TRACK: '1', NO_UPDATE_NOTIFIER: '1' };
delete env.NANSEN_API_KEY;
async function cli(args) {
const child = spawn(process.execPath, [fileURLToPath(new URL('../index.js', import.meta.url)), ...args], { env });
let out = ''; child.stdout.on('data', chunk => { out += chunk; }); child.stderr.on('data', chunk => { out += chunk; });
const timer = setTimeout(() => child.kill(), 20000);
try {
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject); });
expect(code, out).toBe(0); return out;
} finally { clearTimeout(timer); }
}
try {
vi.stubEnv('HOME', home);
createWallet('smoke', 'fixture-password');
fs.writeFileSync(path.join(home, '.nansen', 'config.json'), JSON.stringify({ apiKey: 'fixture-saved-key' }), { mode: 0o600 });
await cli(['research', 'token', 'screener', '--chain', 'base']);
expect(requests.some(req => req.key === 'fixture-saved-key')).toBe(true);
const output = await cli(['trade', 'quote', '--chain', 'base', '--from', 'ETH', '--to', 'USDC', '--amount', '1000000000000000000', '--wallet', 'smoke']);
const id = output.match(/Quote ID:\s+(\S+)/)?.[1]; expect(id).toBeTruthy();
const plan = await cli(['trade', 'execute', '--quote', id, '--dry-run']);
expect(plan).toContain('Trade plan');
expect(fs.existsSync(path.join(home, '.nansen', 'quotes', `${id}.json`))).toBe(true);
expect(requests.some(req => req.url.startsWith('/execute') || req.method === 'eth_sendRawTransaction')).toBe(false);
} finally {
vi.unstubAllEnvs(); server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
fs.rmSync(home, { recursive: true, force: true });
}
});
Loading
Loading