node-tar applies PAX size override to intermediary GNU long-name/long-link headers, causing tar parser interpretation differential (file smuggling)
Description
node-tar incorrectly applies PAX extended header size to intermediary headers, causing parser interpretation differential (CWE-436) that allows file smuggling across tar implementations.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
node-tar incorrectly applies PAX extended header size to intermediary headers, causing parser interpretation differential (CWE-436) that allows file smuggling across tar implementations.
Vulnerability
node-tar (the JavaScript tar library used by npm) applies a PAX extended header's size= record (and other PAX overrides) to the next header entry of any type, including intermediary metadata headers such as a GNU long-name (L) or long-link (K) entry. Per POSIX pax, a PAX extended header (x) describes the *next file entry*, not the intermediary extension headers that may sit between the x header and the file it annotates. Because node-tar lets the PAX size override the byte length of an intervening L/K/x header, an attacker can desynchronize node-tar's stream cursor relative to every other mainstream tar implementation (GNU tar, libarchive/bsdtar, Python tarfile, and the now-fixed tar-rs / astral-tokio-tar). This is an interpretation conflict (CWE-436). All versions of node-tar up to the advisory date are affected [1][2].
Exploitation
An attacker crafts a tar archive containing a PAX extended header (x) that sets a size= value, followed by an intermediary GNU long-name (L) or long-link (K) header, and then the actual file entry. Because node-tar applies the PAX size to the L/K header instead of the file entry, the parser misreads the byte boundaries of the archive. No authentication or special privileges are required; the attacker only needs to supply the crafted archive to a system that uses node-tar for listing or extraction (e.g., via npm package installation or a security scanner). The same archive is parsed correctly by other tar tools, creating a divergence in the set of members each tool sees [1][2].
Impact
A single crafted archive yields a different set of members under node-tar than under GNU tar, libarchive, Python tarfile, and other reference implementations. This interpretation differential allows an attacker to hide a malicious file from a scanner that uses node-tar while the file is visible to an extractor using a different library (or vice versa). The attack defeats security tooling whose scanner and extractor disagree on archive contents (e.g., a malware/secret scanner that lists entries with one library while a downstream step extracts with another). node-tar is one of the most widely deployed JavaScript tar libraries—it backs npm's own package-tarball handling and is a transitive dependency of a very large fraction of the npm ecosystem—so the blast radius for "files that extract differently depending on the tool" is broad [1][2].
Mitigation
As of the advisory publication date (2026-06-15), no patched version of node-tar has been released. The advisory notes that the same root cause was fixed upstream in the Rust tar-rs / astral-tokio-tar ecosystem, but node-tar carries the equivalent defect without a guard. Users should monitor the node-tar repository (https://github.com/isaacs/node-tar) for a security release. No workaround is available; the only mitigation is to avoid using node-tar to parse untrusted tar archives until a fix is applied [1][2].
AI Insight generated on Jun 15, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
121a822027658do not apply PAX header fields to meta entries
3 files changed · +116 −14
src/header.ts+50 −14 modified@@ -81,22 +81,46 @@ export class Header implements HeaderData { throw new Error('need 512 bytes for header') } - this.path = ex?.path ?? decString(buf, off, 100) - this.mode = ex?.mode ?? gex?.mode ?? decNumber(buf, off + 100, 8) - this.uid = ex?.uid ?? gex?.uid ?? decNumber(buf, off + 108, 8) - this.gid = ex?.gid ?? gex?.gid ?? decNumber(buf, off + 116, 8) - this.size = ex?.size ?? gex?.size ?? decNumber(buf, off + 124, 12) - this.mtime = ex?.mtime ?? gex?.mtime ?? decDate(buf, off + 136, 12) + // Decode the typeflag (independent of any pending PAX/GNU extended header) + // up front so we can tell whether THIS block is itself an intermediary + // extension header (PAX `x`/`g`, GNU long-name `L`, GNU long-link `K`). + // Per POSIX pax, a PAX extended header describes the *next file entry*, not + // the extension headers that may sit between it and that file. Applying the + // pending PAX overrides (notably `size`) to an intervening `L`/`K`/`x`/`g` + // header desynchronizes the stream relative to other tar implementations + // and enables tar interpretation-conflict / file-smuggling attacks. + const t = decString(buf, off + 156, 1) + const isNormalFS = types.normalFsTypes.has(t as EntryTypeCode) + const exForFields = isNormalFS ? ex : undefined + const gexForFields = isNormalFS ? gex : undefined + + this.path = exForFields?.path ?? decString(buf, off, 100) + this.mode = + exForFields?.mode ?? + gexForFields?.mode ?? + decNumber(buf, off + 100, 8) + this.uid = + exForFields?.uid ?? gexForFields?.uid ?? decNumber(buf, off + 108, 8) + this.gid = + exForFields?.gid ?? gexForFields?.gid ?? decNumber(buf, off + 116, 8) + this.size = + exForFields?.size ?? + gexForFields?.size ?? + decNumber(buf, off + 124, 12) + this.mtime = + exForFields?.mtime ?? + gexForFields?.mtime ?? + decDate(buf, off + 136, 12) this.cksum = decNumber(buf, off + 148, 12) // if we have extended or global extended headers, apply them now // See https://github.com/npm/node-tar/pull/187 - // Apply global before local, so it overrides - if (gex) this.#slurp(gex, true) - if (ex) this.#slurp(ex) + // Apply global before local, so it overrides. Never slurp the pending + // extended-header fields onto an intermediary extension header. + if (gexForFields) this.#slurp(gexForFields, true) + if (exForFields) this.#slurp(exForFields) // old tar versions marked dirs as a file with a trailing / - const t = decString(buf, off + 156, 1) if (types.isCode(t)) { this.#type = t || '0' } @@ -118,12 +142,24 @@ export class Header implements HeaderData { buf.subarray(off + 257, off + 265).toString() === 'ustar\u000000' ) { /* c8 ignore start */ - this.uname = ex?.uname ?? gex?.uname ?? decString(buf, off + 265, 32) - this.gname = ex?.gname ?? gex?.gname ?? decString(buf, off + 297, 32) + this.uname = + exForFields?.uname ?? + gexForFields?.uname ?? + decString(buf, off + 265, 32) + this.gname = + exForFields?.gname ?? + gexForFields?.gname ?? + decString(buf, off + 297, 32) this.devmaj = - ex?.devmaj ?? gex?.devmaj ?? decNumber(buf, off + 329, 8) ?? 0 + exForFields?.devmaj ?? + gexForFields?.devmaj ?? + decNumber(buf, off + 329, 8) ?? + 0 this.devmin = - ex?.devmin ?? gex?.devmin ?? decNumber(buf, off + 337, 8) ?? 0 + exForFields?.devmin ?? + gexForFields?.devmin ?? + decNumber(buf, off + 337, 8) ?? + 0 /* c8 ignore stop */ if (buf[off + 475] !== 0) { // definitely a prefix, definitely >130 chars.
src/types.ts+21 −0 modified@@ -51,6 +51,27 @@ export type EntryTypeName = | 'OldExtendedHeader' | 'Unsupported' +/** + * types that are a normal file system entry, not metadata. + * + * These can be the subject of extended/globalExtended headers, long path + * names, long linkpath names, etc. + * + * Any other types are meta, and cannot be targetted by extended PAX headers. + */ +export const normalFsTypes = new Set<EntryTypeCode>([ + '0', + '', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + 'D', +]) + // map types from key to human-friendly name export const name = new Map<EntryTypeCode, EntryTypeName>([ ['0', 'File'],
test/header.js+45 −0 modified@@ -1,5 +1,6 @@ import t from 'tap' import { Header } from '../dist/esm/header.js' +import { name } from '../dist/esm/types.js' t.test('ustar format', t => { const buf = Buffer.from( @@ -677,3 +678,47 @@ t.test('tarmageddon, ensure that Header prioritizes Pax size', async t => { const hPax = new Header(h.block, 0, { size: 123 }) t.equal(hPax.size, 123, 'if size is set in pax, takes priority') }) + +t.test('do not apply ex/gex to meta entries', t => { + const normalEntryTypes = new Set([ + 'File', + 'OldFile', + 'Link', + 'SymbolicLink', + 'CharacterDevice', + 'BlockDevice', + 'Directory', + 'FIFO', + 'ContiguousFile', + 'GNUDumpDir', + ]) + + for (const type of name.values()) { + const data = new Header({ + path: 'x', + type, + size: 1, + }) + data.encode() + const h = new Header(data.block, 0, { + size: 100, + dev: 5678, + ino: 9876, + }) + if (normalEntryTypes.has(type)) { + if (h.type === 'Directory') { + t.equal(h.size, 0, 'Directories always size=0, no matter what') + } else { + t.equal(h.size, 100, `expect ${type} to respect extended header`) + } + t.equal(h.dev, 5678, `expect ${type} to respect extended header`) + t.equal(h.ino, 9876, `expect ${type} to respect extended header`) + } else { + t.equal(h.size, 1, `expect ${type} to ignore extended header`) + t.equal(h.dev, undefined, `expect ${type} to ignore extended header`) + t.equal(h.ino, undefined, `expect ${type} to ignore extended header`) + } + } + + return t.end() +})
Vulnerability mechanics
Root cause
"Missing guard in Header.decode: PAX extended header size overrides are applied unconditionally to the next header regardless of whether it is an intermediary metadata header (GNU long-name, long-link, or another PAX header) rather than only to a real file entry."
Attack vector
An attacker crafts a tar archive with a PAX extended header (`x`) containing a `size=` record, followed by a GNU long-name (`L`) header. Because node-tar applies the PAX `size` override to the `L` header itself rather than only to the subsequent file entry, the parser consumes the wrong number of bytes for the metadata payload and desynchronizes its stream cursor. This causes node-tar to report zero members or raise `TAR_ENTRY_INVALID`, while GNU tar, libarchive/bsdtar, and Python tarfile all correctly extract the intended file member — creating an interpretation differential [CWE-436] [ref_id=1]. No authentication is required; the victim only needs to parse an attacker-supplied archive with node-tar.
Affected code
The vulnerability is in `src/header.ts` (compiled to `dist/esm/header.js:49` and `dist/commonjs/header.js:85` in tar@7.5.15) where `this.size = ex?.size ?? gex?.size ?? decNumber(buf, off + 124, 12)` applies PAX `size` overrides unconditionally. In `src/parse.ts`, the `[CONSUMEHEADER]` constructs headers with the current `EX`/`GEX` applied, and `this[EX]` is cleared only in the non-meta (real file) branch, leaving it intact for intermediary metadata headers like GNU long-name (`L`) or long-link (`K`) entries.
What the fix does
The fix must prevent PAX `size` (and other PAX overrides) from being applied when the header being decoded is itself an extension header — specifically GNU long-name (`L`), GNU long-link (`K`), PAX local extended (`x`), or PAX global extended (`g`). In `src/parse.ts`, `this[EX]` (and `this[GEX]` for `size`) should be cleared or ignored when the header type is one of those extension types. Equivalently, in `Header.decode`, the `ex?.size ?? gex?.size` override should be gated on the decoded type not being an extension header. This mirrors the upstream Rust fix that guards `pax_size` with `is_gnu_longname || is_gnu_longlink || is_pax_local_extensions || is_pax_global_extensions` [ref_id=1]. No patch has been published yet; the advisory states a fix PR is being prepared against a private fork.
Preconditions
- inputVictim parses an attacker-supplied tar archive using node-tar (tar.list, tar.extract, tar.Parse, or tar.Unpack)
- authNo authentication or special options required; default code path is affected
Generated on Jun 15, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.