A source map usually carries the original, unminified files. This turns them back into a directory tree — and refuses any entry that tries to leave it.
—
{
"version": 3,
"sources": [
"src/app.js",
"../../../../.ssh/authorized_keys"
],
"sourcesContent": ["…", "…"]
}
A source map is something you fetched from a server you do not control, and its sources array is used to build the filenames it writes. That makes path traversal the entire security surface of a tool like this — and it is not theoretical. Before the guard existed, a Vite/Rollup-style map with plain relative sources wrote a file to its grandparent directory. Webpack's webpack:/// prefix normalises away, which is exactly why casual testing missed it.
$ npx @mrkt_frwd/remap main.js.map --out ./src REMAP main.js.map ──────────────────────────────────── ok src/components/Header.jsx ok src/utils/math.js -- ../../PWNED.txt refused — path escapes the output directory 2 file(s) written to ./src, 1 refused
Stripping the .. and writing it somewhere inside the root would also be safe, and it would be a guess about what the map meant. A map that climbs out of its own output directory is either malformed or hostile; deciding which is the caller's business, not this tool's. So it says which entry, and why, and carries on with the rest.
| the entry | what a naive join does | remap |
|---|---|---|
| ../../PWNED.txt | writes to the grandparent of your output directory | refused and named |
| webpack:///../../x.js | prefix normalises away, then escapes — the form that hid the bug | refused and named |
| webpack://../../x.js | the two-slash variant, which also escaped | refused and named |
| /etc/passwd | leading slash makes join treat it as absolute | made relative to the root |
| C:\Windows\x.js | drive letter is absolute on some platforms | drive letter stripped, kept inside |
| referenced, not embedded | silently produces nothing, so a partial tree looks complete | reported as skipped, with the reason |
The last row is not a security case and it matters just as much. A reconstruction that quietly omits what the map did not carry is a tree you will trust more than you should.
const { unpackBundle } = require('@mrkt_frwd/remap');
const { written, skipped } = await unpackBundle('main.js.map', './src');
// written: string[] paths, relative to ./src
// skipped: { source, reason }[] refusals and missing content, never silent
A refusal is information, not a crash — the command exits non-zero only when nothing came out at all. You get the files that were safe to write and a list of what was not, which is the shape that lets an agent carry on and report honestly.
Your own builds, your own bundles, and code you are permitted to inspect. A source map being reachable is not by itself permission to redistribute what is inside it. This is stated on the package, in the README and here, because a tool that reconstructs someone's source should say it once in every place someone meets it.