Repository navigation
Conversation
Protect wallet create, default, delete, and Privy local persistence with a bounded inter-process lock so concurrent commands cannot bypass password and default-wallet invariants.\n\nFixes nansen-ai#762
| } | ||
|
|
||
| function writePrivateJson(filePath, value) { | ||
| const fd = fs.openSync(filePath, 'wx', 0o600); |
There was a problem hiding this comment.
Potential file inclusion attack via reading file - high severity
If an attacker can control the input leading into the ReadFile function, they might be able to read sensitive files and launch further attacks with that information.
Show fix
Remediation: Ignore this issue only after you've verified or sanitized the input going into this function. This issue is only relevant in the backend, not in the frontend!
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
There was a problem hiding this comment.
@AikidoSec ignore: writePrivateJson only receives internal lock metadata paths constructed from the fixed ~/.nansen/wallets/.wallet-mutation.lock directory plus process IDs and cryptographically random tokens. No user-controlled path reaches this helper; creation is exclusive (wx) inside the private wallet directory.
There was a problem hiding this comment.
✅ Based on your feedback, we ignored this issue because of the following reason:
writePrivateJsononly receives internal lock metadata paths constructed from the fixed~/.nansen/wallets/.wallet-mutation.lockdirectory plus process IDs and cryptographically random tokens. No user-controlled path reaches this helper; creation is exclusive (wx) inside the private wallet directory.
| throw new Error(`Wallet mutation lock is invalid: ${label} must be a regular file. Stop other wallet commands, remove ${path.dirname(filePath)}, and retry.`); | ||
| } | ||
| try { | ||
| return JSON.parse(fs.readFileSync(filePath, 'utf8')); |
There was a problem hiding this comment.
Potential file inclusion attack via reading file - medium severity
If an attacker can control the input leading into the ReadFile function, they might be able to read sensitive files and launch further attacks with that information.
Show fix
Remediation: Ignore this issue only after you've verified or sanitized the input going into this function. This issue is only relevant in the backend, not in the frontend!
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
There was a problem hiding this comment.
@AikidoSec ignore: readLockJson is only called for fixed owner.json / recovery.json files under the internally constructed wallet mutation lock directory. The parent path is not user input, and the function rejects symlinks and non-regular files before reading.
There was a problem hiding this comment.
✅ Based on your feedback, we ignored this issue because of the following reason:
readLockJsonis only called for fixedowner.json/recovery.jsonfiles under the internally constructed wallet mutation lock directory. The parent path is not user input, and the function rejects symlinks and non-regular files before reading.
| if (stat.isSymbolicLink() || !stat.isDirectory()) { | ||
| throw new Error(`Wallet mutation lock is invalid: ${lockPath} must be a real directory. Stop other wallet commands, remove it, and retry.`); | ||
| } | ||
| return validateLockRecord(readLockJson(path.join(lockPath, 'owner.json'), 'owner metadata'), 'owner metadata'); |
There was a problem hiding this comment.
Potential file inclusion attack via reading file - medium severity
If an attacker can control the input leading into the ReadFile function, they might be able to read sensitive files and launch further attacks with that information.
Show fix
Remediation: Ignore this issue only after you've verified or sanitized the input going into this function. This issue is only relevant in the backend, not in the frontend!
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
There was a problem hiding this comment.
@AikidoSec ignore: this path is path.join(lockPath, "owner.json"), where lockPath is the fixed .wallet-mutation.lock entry inside the private wallet directory. inspectWalletMutationLock first rejects symlinks and non-directories, and readLockJson rejects symlinks/non-files.
There was a problem hiding this comment.
✅ Based on your feedback, we ignored this issue because of the following reason:
this path is
path.join(lockPath, "owner.json"), wherelockPathis the fixed.wallet-mutation.lockentry inside the private wallet directory.inspectWalletMutationLockfirst rejects symlinks and non-directories, andreadLockJsonrejects symlinks/non-files.
| function recoverDeadWalletMutationLock(lockPath, owner) { | ||
| const recoveryPath = path.join(lockPath, 'recovery.json'); | ||
| const recoveryToken = crypto.randomBytes(16).toString('hex'); | ||
| const recoveryCandidatePath = path.join(lockPath, `.recovery-${process.pid}-${recoveryToken}.candidate`); |
There was a problem hiding this comment.
Potential file inclusion attack via reading file - medium severity
If an attacker can control the input leading into the ReadFile function, they might be able to read sensitive files and launch further attacks with that information.
Show fix
Remediation: Ignore this issue only after you've verified or sanitized the input going into this function. This issue is only relevant in the backend, not in the frontend!
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
There was a problem hiding this comment.
@AikidoSec ignore: the recovery candidate filename is generated internally from the fixed wallet lock directory, validated numeric process.pid, and a 128-bit random hex token. It contains no request, CLI, wallet-name, or other user-controlled path segment.
There was a problem hiding this comment.
✅ Based on your feedback, we ignored this issue because of the following reason:
the recovery candidate filename is generated internally from the fixed wallet lock directory, validated numeric
process.pid, and a 128-bit random hex token. It contains no request, CLI, wallet-name, or other user-controlled path segment.
nansen-pr-reviewer summaryThe change adds cross-process serialization and substantially improves stale-lock recovery, including Linux PID-start identity checks and Privy persistence cleanup. However, non-Linux platforms still fall back to PID-only liveness, and lock records without identity remain accepted there, so PID reuse can repeatedly strand wallet mutations. Findings by Severity
Deterministic check: neutral — 3 earlier finding(s) still open (below the blocking bar) Risk: 4/5 (High) — raised by: complexity: 1432 added lines Token usage: 41,399 input, 1,910 output, 19,430 cache read | Usage Guide Cooldown: for the next 10 minutes (counting from when this review finished), new pushes to this PR will not trigger another review — the next push after the window expires will. Need a fresh review sooner? Comment |
Record Linux process start identity in lock metadata so stale locks remain recoverable after PID reuse, with conservative fallback on other platforms.
| if (err.code !== 'EPERM') throw err; | ||
| } | ||
| if (!owner.identity) return true; | ||
| const currentIdentity = linuxProcessIdentity(pid); |
There was a problem hiding this comment.
🟡 - Medium | PID reuse remains unsafe on non-Linux platforms
The lock owner identity is only populated by linuxProcessIdentity; on macOS and Windows it is always null, and processIsAlive therefore treats any process still occupying the recorded PID as the original owner. If the original owner crashes and the PID is reused, stale locks will be considered live until the 15-second timeout on every mutation, recreating the previously reported stranded-lock behavior for those platforms. Use a platform-appropriate process-start identity or lease, or otherwise avoid accepting PID-only liveness on platforms without identity support.
| return currentIdentity !== null && currentIdentity === owner.identity; | ||
| } | ||
|
|
||
| function readLockJson(filePath, label) { |
There was a problem hiding this comment.
🟡 - Medium | medium: PID-only fallback still permits PID reuse for locks without identity
The lock format accepts an absent identity, and processIsAlive treats such records as live whenever the PID exists. This leaves legacy locks or lock records created when identity collection was unavailable vulnerable to PID reuse: a new process occupying the old PID will prevent recovery until the timeout, and repeated wallet mutations can remain blocked. Lock records without a verifiable process-start identity should be treated as stale or handled with an explicit compatibility/lease strategy rather than accepted as live.
| function sleepSync(ms) { | ||
| Atomics.wait(LOCK_SLEEP_CELL, 0, 0, ms); | ||
| } | ||
|
|
There was a problem hiding this comment.
🟡 - Medium | medium: PID reuse remains unsafe on non-Linux platforms
linuxProcessIdentity explicitly returns null on macOS and Windows, while processIsAlive accepts any process still occupying the recorded PID as the original owner. If a wallet command crashes and the PID is later reused, the stale lock remains considered live and each mutation waits until the 15-second timeout. A platform-appropriate process-start identity or a lease mechanism is needed before treating the owner as live on these platforms.
Preserve exclusive recovery claims across owner handoffs, bound filesystem retries, distinguish lock directory generations, and retain remote Privy wallets when local rollback is incomplete.
| return currentIdentity !== null && currentIdentity === owner.identity; | ||
| } | ||
|
|
||
| function readLockJson(filePath, label) { |
There was a problem hiding this comment.
🟡 - Medium | PID-only lock liveness remains unsafe on non-Linux platforms
On macOS and Windows, linuxProcessIdentity always returns null, while processIsAlive treats a PID that currently exists as the original owner when owner.identity is absent. If the original process crashes and its PID is later reused, the stale lock will be considered live until the wait timeout, and subsequent wallet mutations can repeat that delay. Use a platform-appropriate process-start identity or a lease, and do not accept PID-only liveness on platforms without verifiable identity.
| throw new Error('Wallet mutation lock cannot establish the current Linux process identity; refusing unsafe PID-only locking.'); | ||
| } | ||
| return identity; | ||
| } |
There was a problem hiding this comment.
🟡 - Medium | Lock records without identity are still accepted as live where identity is unavailable
validateLockRecord explicitly permits identity to be absent on non-Linux platforms, and processIsAlive then accepts any process occupying the recorded PID. This leaves legacy or identity-less lock records vulnerable to PID reuse and preserves the stranded-lock behavior. Such records should be rejected or recovered through an explicit compatibility/lease mechanism rather than treated as live.
| throw new Error(`Wallet mutation lock is invalid: ${lockPath} must be a real directory. Stop other wallet commands, remove it, and retry.`); | ||
| } | ||
| try { | ||
| const owner = validateLockRecord(readLockJson(path.join(lockPath, 'owner.json'), 'owner metadata'), 'owner metadata'); |
There was a problem hiding this comment.
Potential file inclusion attack via reading file - medium severity
If an attacker can control the input leading into the ReadFile function, they might be able to read sensitive files and launch further attacks with that information.
Show fix
Remediation: Ignore this issue only after you've verified or sanitized the input going into this function. This issue is only relevant in the backend, not in the frontend!
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
| if (owner.token !== token) { | ||
| throw new Error('Wallet mutation lock ownership changed before release; refusing to remove another process lock.'); | ||
| } |
There was a problem hiding this comment.
A timing attack might allow hackers to bruteforce passwords - medium severity
An insecure way to compare passwords to user input might allow hackers to bruteforce passwords.
Show fix
Remediation: Use crypto.timingSafeEqual, a timing-safe string comparison function built into NodeJS
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
|
Cross-platform lock-identity follow-up is ready: Kewe63#1. It targets this PR’s head branch and addresses the remaining non-Linux PID-reuse and identity-less-record findings. Direct push to |
Summary
Fixes #762.
Concurrent wallet commands previously protected individual JSON writes but not the complete read-check-write state transition. Two first encrypted-wallet creates could both validate against an empty config, both report success, and leave one wallet inaccessible after the last
passwordHashwrite won.This PR:
No live API, RPC, production credential, funded wallet, or blockchain operation is used by the tests.
Testing
RED against the old production behavior:
The failures showed that different-password first creates and duplicate-name creates both incorrectly reported two successes.
Final focused and impacted suite:
Stale-recovery contention stress:
Full local suite before the final PID-identity follow-up:
The only failures were pre-existing feedback fixture timeouts reproduced identically on pristine base commit
19b7cfcb562bac2beb85775833fb8b87edfd0037and addressed separately. All 116 other test files passed. The final lock-remediation follow-up passed the complete 163-test impacted suite; GitHub CI validates the final head.Compatibility and risk
Checklist
npm run lintpassesconsole.logor hardcoded secretssrc/schema.jsonunchanged — no command or option changesREADME.mdunchanged — no top-level command or category changes