VYPR
Medium severity6.9GHSA Advisory· Published Jun 15, 2026· Updated Jun 15, 2026

node-tar applies PAX size override to intermediary GNU long-name/long-link headers, causing tar parser interpretation differential (file smuggling)

CVE-2026-53655

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

1

Patches

1
21a822027658

do not apply PAX header fields to meta entries

https://github.com/isaacs/node-tartonghuarootJun 1, 2026Fixed in 7.5.16via llm-release-walk
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

2

News mentions

0

No linked articles in our index yet.