Skip to content

fix(redactors): assert embedded temp paths stay in their temp directory - #318

Open
rustic wants to merge 1 commit into
mainfrom
fix/embedded-path-containment
Open

fix(redactors): assert embedded temp paths stay in their temp directory#318
rustic wants to merge 1 commit into
mainfrom
fix/embedded-path-containment

Conversation

@rustic

@rustic rustic commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Addresses CodeQL alert 3820 (go/zipslip, high, 19 sinks). Base is main.

The alert is a false positive

The flow it reports is real — a zip entry name does reach the writes that materialize an embedded part — but an escape was already impossible:

  • The entry name never becomes a filename. inPath/outPath use the literals "embedded" and "redacted". Only the extension comes from the archive.
  • embedded.SafeExt is a character allowlist, not a filter: a dot plus 1–10 bytes from [a-z0-9], else the .bin fallback. .., separators, NUL and drive letters are unrepresentable in the result rather than stripped from it.
  • TestSafeExtNeverYieldsAPathComponent already asserts that against 21 hostile entry names (../../../../etc/passwd, ..\..\..\evil.png, NUL injection, strings.Repeat("../", 512), absolute and Windows drive paths).

All 19 sinks are downstream of that one construction — the image/office/legacyole/pdf/plaintext sinks are os.Open/os.Create/os.ReadFile on the path handed to them, not a path re-derived from an entry name. It's one flow, not 19 defects.

CodeQL misses it because go/zipslip models a cleaned-prefix containment test as a barrier and does not model a per-character allowlist as one.

So why change anything

The refactor. The safety of those two writes currently rests on a basename that happens to be a literal three lines above them. Someone deriving the basename from the entry name — to keep part names recognizable in a debug dump, say — reintroduces the traversal, and nothing local to the write says otherwise. A traversal here writes a document's unredacted bytes to an attacker-chosen path, which is worse than the leak this package exists to prevent.

Alert hygiene. 19 open high-severity alerts is a pile a reviewer learns to skip, and the next genuine path-traversal finding lands in it. Clearing the class is worth three lines.

I can't promise CodeQL retires the alert — that's its call, not something the commit controls. I've dismissed it as a false positive with this reasoning regardless, so the outcome doesn't depend on the query's barrier modelling.

What's here

withinDir cleans both sides before comparing, so ./.. elements resolve rather than compare literally, and appends the separator to the directory so a sibling sharing its name prefix isn't mistaken for a child — /tmp/ferret-embedded-1-evil vs /tmp/ferret-embedded-1 is what a naive HasPrefix gets wrong. Mutation-tested: dropping the separator fails the test.

Also corrects a comment in two places claiming SafeExt "returns one of a fixed set of .xyz literals". It doesn't — it returns any charset-validated [a-z0-9]{1,10} extension, or .bin. Same security property, but the wording overstated the mechanism, and that comment's whole job is telling the next reader why the sink is safe.

Tests

TestHostilePartNamesStayInTheTempDir drives RedactEmbedded with nine hostile entry names — relative and absolute traversal, Windows separators, NUL injection, 256 stacked ../ — against canary files planted in os.TempDir() and its parent, asserting they're byte-identical afterwards. Through the public entry point, so it covers the composition (allowlist + hardcoded basename + containment) rather than one link.

TestWithinDirRejectsEscapes covers the predicate directly. TestSafeExtStillGuardsTheExtension pins that the allowlist stays the primary control and the new check hasn't quietly become the only one.

go test ./... (66 packages), go vet ./..., make build — clean. No behavior change on any non-hostile input: the check cannot fire on today's code, which is the point.

CodeQL go/zipslip (alert 3820, high, 19 sinks) flags the flow from a zip entry name
to the writes that materialize an embedded part. The flow is real; the conclusion is
not. Two things already made an escape impossible:

  - the entry name never becomes a FILENAME. inPath and outPath use the literals
    "embedded" and "redacted"; only the extension comes from the archive.
  - embedded.SafeExt validates that extension against a CHARACTER allowlist -- a
    dot plus 1-10 bytes from [a-z0-9], else the ".bin" fallback -- so "..",
    separators, NUL and drive letters are unrepresentable in the result rather than
    stripped from it. TestSafeExtNeverYieldsAPathComponent already asserts that
    against 21 hostile entry names.

So this is not a fix for an exploitable bug. It adds the containment assertion at
the sink anyway, for two reasons.

The first is the refactor. The safety of those two writes currently rests on a
basename that happens to be a literal three lines above them. Someone deriving the
basename from the entry name -- to keep part names recognizable in a debug dump, say
-- reintroduces the traversal, and nothing local to the write says otherwise. A
traversal here writes a document's UNREDACTED bytes to an attacker-chosen path,
which is worse than the leak this package exists to prevent, so the check is cheap
relative to the failure it forecloses.

The second is that a scanner can see it. go/zipslip models a cleaned-prefix
containment test as a barrier and does not model a per-character allowlist as one,
which is why all 19 sinks -- every one of them downstream of this single
construction, not 19 distinct defects -- read as unsanitized. Clearing the class
matters beyond the noise: the next genuine path-traversal finding would land in a
pile of 19 that a reviewer has already learned to skip. Whether CodeQL actually
retires the alert is its call, not something this commit can promise; the alert is
dismissed with this reasoning either way.

withinDir cleans both sides before comparing, so a "." or ".." element resolves
rather than comparing literally, and appends the separator to the directory so a
sibling sharing its name prefix is not mistaken for a child --
/tmp/ferret-embedded-1-evil against /tmp/ferret-embedded-1 is the case a naive
HasPrefix gets wrong, and the test fails if the separator is dropped.

Also corrects a comment in both places that said SafeExt "returns one of a fixed set
of '.xyz' literals". It does not: it returns any charset-validated [a-z0-9]{1,10}
extension, or ".bin". The security property is identical and the wording overstated
the mechanism, which matters in a comment whose whole job is to tell the next reader
why the sink is safe.

TESTS

TestHostilePartNamesStayInTheTempDir drives RedactEmbedded with nine hostile entry
names -- relative and absolute traversal, Windows separators, NUL injection, 256
stacked "../" -- against canary files planted in os.TempDir() and its parent, and
asserts the canaries are byte-identical afterwards. It asserts through the public
entry point rather than through SafeExt alone, so it covers the composition of
allowlist, hardcoded basename and containment check rather than one link.

TestWithinDirRejectsEscapes covers the predicate directly, including the
sibling-prefix case and a traversal that resolves back inside.
TestSafeExtStillGuardsTheExtension pins that the allowlist remains the primary
control and the new check has not quietly become the only one.

go test ./... (66 packages), go vet ./... and make build clean.

Refs alert 3820
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Ferret Scan Results

No sensitive data detected in the changed files.


🤖 This comment was automatically generated by Ferret Scan

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant