fix(redactors): assert embedded temp paths stay in their temp directory - #318
Open
rustic wants to merge 1 commit into
Open
fix(redactors): assert embedded temp paths stay in their temp directory#318rustic wants to merge 1 commit into
rustic wants to merge 1 commit into
Conversation
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
Contributor
🔍 Ferret Scan Results✅ No sensitive data detected in the changed files. 🤖 This comment was automatically generated by Ferret Scan |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses CodeQL alert 3820 (
go/zipslip, high, 19 sinks). Base ismain.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:
inPath/outPathuse the literals"embedded"and"redacted". Only the extension comes from the archive.embedded.SafeExtis a character allowlist, not a filter: a dot plus 1–10 bytes from[a-z0-9], else the.binfallback..., separators, NUL and drive letters are unrepresentable in the result rather than stripped from it.TestSafeExtNeverYieldsAPathComponentalready 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.ReadFileon 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/zipslipmodels 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
withinDircleans 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-evilvs/tmp/ferret-embedded-1is what a naiveHasPrefixgets 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.xyzliterals". 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
TestHostilePartNamesStayInTheTempDirdrivesRedactEmbeddedwith nine hostile entry names — relative and absolute traversal, Windows separators, NUL injection, 256 stacked../— against canary files planted inos.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.TestWithinDirRejectsEscapescovers the predicate directly.TestSafeExtStillGuardsTheExtensionpins 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.