Skip to content

Extraction and path traversal

This module exists because of one job go/artifacts declined: opening the archive.

That refusal was correct. A resolver that fetches and verifies has a clean boundary — bytes in, verified path out — and tar extraction is the single most reliably mishandled operation in that neighbourhood. Adding it would have put the estate's traversal risk inside the module every tool depends on.

The problem

A tar entry carries its own path, and that path is attacker-controlled in the general case. An entry named ../../.ssh/authorized_keys joined onto an output directory writes outside it. Extractors have shipped this defect for thirty years, and the usual fix is a filter: reject names containing .., reject absolute paths, canonicalise and compare prefixes.

Filters work until one does not. They accumulate cases — symlinks, hardlinks, absolute paths, unicode separators, platform-specific path rules — and each is a chance to have missed one.

What is done instead

The entry's path is never used to build an output path.

The destination is decided before the archive is opened: the caller's cache directory, plus a name this module chose. An entry is matched by comparing the base name of its path against the one library being looked for. Nothing else about the entry's name is read.

A hostile name can therefore cause a miss — the entry is not recognised as the library and extraction reports ErrNotInArchive. It cannot cause a write to an unintended location, because there is no code path in which the name reaches a write.

That is the difference between a filter and a construction. A filter asks "is this name safe?" and can be wrong. This asks "is this the file I want?" and the answer changes nothing about where the bytes go.

Non-regular entries are skipped

Only regular files are considered. This is not a hypothetical: the real archives contain symlinks beside the versioned object — libonnxruntime.so and libonnxruntime.so.1 both point at it — so encountering one is the ordinary case.

Were symlinks followed, an entry named like the library and pointing at /etc/passwd would produce a "library" whose contents came from somewhere else entirely.

This is not a defence against a hostile channel

Worth being precise, because a security note that overclaims gets trusted for things it does not cover.

By the time extraction runs, the archive's signature has been checked against a manifest naming the artefact and version, and its digest and length verified. The bytes are what the publisher signed. A hostile archive is not the threat model here.

The threat model is a careless extractor — which is a more likely problem than a compromised publisher, and one that a future edit to this file could reintroduce without any signature being broken.

That is why the guarantee is structural rather than a check somebody remembered to write, and why the test for it asserts the library landed inside the cache and that the entry's own path was not written. That test fails against a naive extractor, which is the only way to know it tests anything.

Bounds

The decompressed size is bounded at 512 MiB. The compressed length was already verified, but decompressed size is a different quantity, and a bound that is never hit costs nothing.