Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

unpack-order-guard

Decide whether an archive is safe to extract before anything is written, by replaying its entry list against a simulated destination tree.

import { planExtraction, UnpackOrderError } from 'unpack-order-guard';

try {
  const plan = planExtraction(entries, {
    destination: '/var/tmp/unpack-7f2a',   // absolute and already resolved
    caseSensitivity: 'insensitive',        // the default, and the conservative side
    symlinks: 'reject',                    // 'internal' or 'any' to loosen it
    hardlinks: 'reject',
  });

  for (const step of plan.steps) {
    // step.resolvedPath is where this entry actually lands, symlinks followed
    console.log(step.index, step.type, step.resolvedPath);
  }
} catch (error) {
  if (error instanceof UnpackOrderError) {
    console.error(error.code, error.message, error.detail.causeIndex);
  }
}

Nothing here touches the filesystem. It takes an entry list, a destination path, and a description of what is already in the destination, and it either returns a plan or throws. inspectExtraction is the same thing with a discriminated union instead of a throw.

The per entry check passes for every entry of this archive

The check almost everybody writes runs once per member: take the name, normalise it, join it onto the destination, confirm the result is still inside the destination, extract. Run it over these two entries.

0  symlink  "link" -> "/"
1  file     "link/etc/cron.d/backdoor"

Entry 0 names something inside the destination. So does entry 1. Both pass, individually, every time, and the extraction writes to /etc/cron.d/backdoor.

There is no entry to blame. Entry 0 is a symlink with a name in the destination. Entry 1 is a file with a name in the destination. What makes the archive dangerous is that entry 0 runs first, and a per entry check cannot see that because at the moment it validates entry 1 it is looking at a tree that does not yet contain the link. Containment is a property of the sequence.

So this module keeps a tree and replays the sequence against it. Each entry is resolved component by component through the tree as it stands at that point, following any symlink earlier entries created, exactly the way the kernel would. When entry 1 arrives, link is in the tree, resolution follows it to the root, and the write is refused with the index of the entry that put it there:

entry 1 ("link/etc/cron.d/backdoor") resolves to /etc/cron.d/backdoor, outside the
destination /var/tmp/dest. Resolution followed the symlink /var/tmp/dest/link
(pointing at "/"), which entry 0 created earlier in this same archive, and
resolution left the destination at /. ...

The refusal covers the whole archive, not the offending entry. Dropping entry 1 leaves an archive that plants a symlink to the root in a directory you are about to hand to something else, and dropping entry 0 leaves a member whose name is a lie about where it goes. Repairing an archive is a decision about what the publisher meant, and a library has no standing to make it.

The prefix test is wrong before symlinks even appear

Two independent bugs live in resolved.startsWith(destination).

A sibling whose name extends the destination's name passes. /var/tmp/dest-backup/x starts with /var/tmp/dest, and it is not in /var/tmp/dest. Appending a separator to the destination fixes that one spelling and leaves the rest.

Lexical normalisation answers a different question than the filesystem does. A normaliser that pops a component on .. rewrites a/../../x to x and reports it contained, because popping past the top of a relative path silently clamps. Real resolution walks to the destination's parent. The same normaliser rewrites link/../x to x when link is a symlink to /, where the real answer is /x. Every one of those rewrites has already assumed that the component to its left is a plain directory, which is the fact in question.

Containment here is decided by walking parent pointers up the simulated tree, never by comparing path strings. .. is applied to the node resolution actually reached, not to the text of the path. A path that steps outside the destination at any point is refused even if the remaining components would walk back in, because past the destination boundary the tree belongs to somebody else and any further verdict would be a guess about directories this module has never seen.

Case and Unicode fold two names into one

0  symlink  "Link" -> ".."
1  file     "link/x"

On APFS, HFS+ and NTFS those are one name. A simulator whose tree is a Map keyed on the raw entry name has no link, so it creates a directory and reports the write as contained, and the extraction puts the file next to the destination instead of inside it.

Children are keyed by a fold key: Unicode form first, then case, both configurable. Fold before case, not after, because a precomposed and a decomposed spelling of the same letter can lowercase to different strings. The same key catches two members that differ only in case (A.txt and a.txt), which is not an escape but is still refused: the archive lists two files, the destination holds one, and no caller can tell which one won.

Pass caseSensitivity: 'sensitive' and both of those archives plan cleanly, which is correct on ext4 and wrong on a Mac. The default is the folding one because a false refusal is visible and a missed collision is not.

Order dependence that has nothing to do with symlinks

Every one of these moves the resolution target after earlier entries were validated against it, so each is refused with an explanation rather than resolved by a rule:

  • A symlink replacing a directory. Entries already validated underneath the directory now resolve through a link. Extractors disagree about this too: some unlink first, some fail with EEXIST or EISDIR.
  • A hard link. Two names, one inode. A later member that rewrites either name changes what both names read back under an extractor that truncates, and only one under an extractor that unlinks first. The archive does not say which it means, so an archive that hard links a file and then rewrites it is refused.
  • A hard link target. Resolved relative to the extraction root, the way tar reads a link name, not relative to the link's own directory. A target reached through a symlink, or pointing at a symlink, or naming a member that appears later in the archive, is refused.
  • A path that runs through a file. a as a file and then a/b as a member gives ENOTDIR partway through or silently deletes what a held.
  • A directory entry arriving after members nested inside it. This one is legal and common, so it is reported as a note rather than refused: the extractor applies that entry's mode to a directory it already had to create, and whether the mode ends up restricting the members already written depends on whether it defers the chmod.

Symlink resolution is bounded. Re-entering a link already expanded on the same path is refused as a cycle, and a chain longer than maxSymlinkHops (40 by default) is refused rather than followed.

After the last entry, every planned write is resolved once more against the finished tree and compared to the path the step records. If all the refusals above are doing their job, the two always agree, so a mismatch means one of the invariants has a hole in it. That is a bug in this module, and reporting it is better than returning a plan whose steps write somewhere other than where they say.

Known limitations

The simulation is only as good as the description you give it. Nodes already in the destination come from the existing option. Anything you leave out does not exist as far as the plan is concerned, and a symlink you forgot to declare is a symlink the plan will not resolve through.

A plan is not a lock. Between planning and extracting, another process can create a symlink in the destination and every guarantee here evaporates. This is a static check, not a replacement for extracting into a fresh directory that nothing else can write to, and not a replacement for O_NOFOLLOW in the extractor itself.

Case folding uses toLowerCase, not the real filesystem table. APFS and NTFS fold according to their own Unicode tables, which change between versions and disagree with each other on edge cases such as the Turkish dotless i. Unicode folding is NFC only; a destination that stores NFD (HFS+) compares equal under NFC folding for the cases that matter here, but the module does not model the storage form.

POSIX paths only. Destinations and entry paths must use forward slashes. A backslash anywhere is refused rather than interpreted, because it is a separator on one platform and a filename character on the other. Windows specific hazards (reserved device names, trailing dots and spaces, alternate data streams) are not modelled at all.

Only four entry types. Files, directories, symlinks and hard links. Character devices, block devices, fifos and sockets are refused as unknown types rather than modelled, on the grounds that an untrusted archive has no business creating them.

Refusing a cycle on the second visit to the same link is stricter than the kernel, which permits a path to legitimately cross one symlink twice. That is a deliberate false positive in the safe direction.

Nothing is repaired. There is no sanitising mode, no strip prefix, no "skip the bad members and extract the rest". Deciding which member of a hostile archive to keep is not a decision this can make for you.

Test

npm install
npm test   # 103 tests: order dependent escapes, prefix and normalisation bugs, folding, hard links, malformed input

License

MIT

About

Simulates an archive's entry list against the destination tree and refuses any plan whose containment depends on entry order

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages