Skip to content

fix(security): reject path traversal in proxied request paths - #418

Merged
stuartwdouglas merged 5 commits into
mainfrom
fix-reject-path-traversal
Oct 29, 2026
Merged

stuartwdouglas merged 5 commits into
mainfrom
fix-reject-path-traversal

Conversation

@stuartwdouglas

@stuartwdouglas stuartwdouglas commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

  • Reject dot segments, backslashes, and NUL bytes in decoded request paths before OPA.
  • Add defense-in-depth path validation in github-releases, git, hermit, host, and artifactory strategies.
  • Escape GitHub URL segments and build Hermit redirect URLs structurally.
  • Reject archive save include paths that escape the base directory.
  • Extract cached tar archives with validated entry paths, links, and parent directories.
  • Add request-path and archive traversal regression tests.

Go's ServeMux only cleans the escaped request path, so encoded dot
segments (e.g. "..%2F", "%2E%2E") survive routing and appear decoded in
r.PathValue and r.URL.Path. Several strategies spliced these values into
upstream URLs, some of which carry credentials.

Reject dot segments, backslashes and NUL bytes globally before OPA, and
validate path values in the github-releases, git, hermit, host and
artifactory strategies as defence in depth. Escape GitHub URL segments
and build the hermit redirect URL structurally.

Co-authored-by: Pi <noreply@pi.dev>
@stuartwdouglas
stuartwdouglas requested a review from a team as a code owner October 29, 2026 00:31
@stuartwdouglas
stuartwdouglas requested review from inez and removed request for a team October 29, 2026 00:31
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-29T02:22:23.346923Z 592d595 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e8917b1f9e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread internal/strategy/github_releases.go Outdated
Comment on lines +94 to +95
// escapePathSegments escapes each slash-separated segment of path so decoded
// characters such as "?" and "#" can't alter the structure of the URL.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the short helper comment

This comment describes an obvious six-line private helper rather than a non-obvious block of at least 20 lines, contrary to the repository's explicit commenting convention; remove it and let the function name and implementation convey the behavior.

AGENTS.md reference: AGENTS.md:L19-L22

Useful? React with 👍 / 👎.

@stuartwdouglas stuartwdouglas Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Removed in 6c07eac.

stuartwdouglas and others added 2 commits October 29, 2026 11:43
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0bf817ed5f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread client/archive.go
return errors.Errorf("not a directory: %s", baseDir)
}
for _, path := range includePaths {
if strings.Contains(path, "\\") || !filepath.IsLocal(path) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject include paths through symlinked parents

When an include such as link/secret has baseDir/link pointing outside baseDir, filepath.IsLocal accepts it and the following os.Stat resolves the symlink; the exact GNU tar invocation used below then archives the outside file under link/secret (verified with tar -cpf ... -C base -- link/secret). Validate each parent without following a path outside the base before passing the include to tar.

Useful? React with 👍 / 👎.

Comment thread client/archive.go
if err != nil {
return err
}
if err := rejectSymlink(linkTarget); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject symlinked parents of hard-link sources

When the extraction destination already contains a symlinked directory, a hard-link header such as Linkname: "escape/secret" passes this final-component Lstat: its parent symlink is followed, and os.Link below links the outside file into the extracted tree. Apply the same parent-component symlink checks to linkTarget that are applied to the destination path before creating the hard link.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in 592d595: hard-link sources now get the same parent-symlink check as destinations, and symlink targets may only use .. as leading components, which also closes chained-symlink escapes (e.g. a/b -> .., s -> a/b, u -> s/..) that passed the per-entry lexical check.

Comment thread client/archive.go Outdated
}
*dirs = append(*dirs, dirMode{path: target, mode: mode})
case tar.TypeReg, 0:
return extractRegularFile(directory, rel, target, mode, r)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore archived modification times

When restoring a snapshot used as a build tree, only the header mode is passed to the extractor, so every regular file receives its extraction time and hdr.ModTime is discarded; directory timestamps are likewise lost. This can change decisions made by mtime-based build tools such as make, so apply the archived timestamps after writing files and after extracting directory contents.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in 592d595: regular files and directories get their archived mtimes (directories after their contents are written).

Comment thread cmd/cachewd/main.go
return nil, errors.Errorf("initialise OPA middleware: %w", err)
}

// Reject before OPA so policies can't be bypassed with encoded dot segments.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove comments on the short validation blocks

This comment annotates a one-line middleware wrapper rather than a non-obvious block of at least 20 lines; the same issue remains on the newly added validation blocks in internal/strategy/git/git.go, github_releases.go, and hermit.go. Remove these short-block comments as required by the repository convention.

AGENTS.md reference: AGENTS.md:L19-L22

Useful? React with 👍 / 👎.

The Go tar reader stops at the end-of-archive marker, leaving record
padding unread. Closing the pipe while zstd was still writing it made
Extract fail intermittently with "zstd failed: signal: broken pipe".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 218cfed6cb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread client/archive.go
mode := tarMode(hdr)
switch hdr.Typeflag {
case tar.TypeDir:
if err := mkdirAllWithin(directory, rel, mode|0o700); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow directory entries to replace existing files

When restoring over an existing checkout where a path changed from a regular file or symlink into a directory, mkdirAllWithin rejects the final component as a non-directory instead of replacing it. GNU tar previously handled this transition, and GitRestoreCmd.extract deliberately restores directly over existing checkouts, so switching between commits with such a type change now makes the entire restore fail; remove the conflicting final component before creating the archived directory while retaining the parent-symlink checks.

Useful? React with 👍 / 👎.

Comment thread client/archive.go
Comment on lines +189 to +190
if err := rejectSymlink(target); err != nil {
return err

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow regular files to replace existing symlinks

When an existing checkout contains a symlink at a path that is a regular file in the restored snapshot, this explicit rejection aborts extraction. The following removeExistingNonDir already uses Lstat and unlinks the destination without following it, so the symlink can be replaced safely; retaining this check breaks restores across ordinary Git file-type changes that the previous tar extractor supported.

Useful? React with 👍 / 👎.

Comment thread client/archive.go
Comment on lines +178 to +180
func tarMode(hdr *tar.Header) fs.FileMode {
return fs.FileMode(uint32(hdr.Mode & int64(fs.ModePerm)))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve special permission bits during extraction

For archives containing setuid, setgid, or sticky files/directories, masking the tar mode with fs.ModePerm drops those bits, even though Extract and the snapshot wrapper promise to preserve file permissions. This changes shared setgid directories and sticky directories on every restore; translate the tar header's special Unix mode bits into fs.ModeSetuid, fs.ModeSetgid, and fs.ModeSticky before applying the mode.

Useful? React with 👍 / 👎.

…imes

Hard-link sources only rejected a symlink in their final component, so
a symlinked parent (pre-existing in the destination, or built from a
chain of archived symlinks such as "a/b -> ..", "s -> a/b",
"u -> s/..") let an archive link files from outside the destination.
Check every parent of the link source, and reject symlink targets that
use ".." after a name, since that resolves relative to wherever the
name points rather than lexically.

Also restore archived modification times on files and directories,
which tar -xpf did before the Go extractor replaced it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stuartwdouglas
stuartwdouglas merged commit b21a9fb into main Oct 29, 2026
7 checks passed
@stuartwdouglas
stuartwdouglas deleted the fix-reject-path-traversal branch October 29, 2026 04:14
luispadron added a commit that referenced this pull request Nov 4, 2026
* origin/main:
  fix(client): resume broken parallel get chunks from their offset (#423)
  fix(git): limit size of request bodies buffered in memory (#422)
  fix: don't truncate downloads when the cache write fails (#420)
  fix(security): reject path traversal in proxied request paths (#418)
  fix: security/sanitize cached git mirrors (#417)
  fix(security): harden server-side LFS snapshots (#416)

# Conflicts:
#	internal/strategy/git/snapshot.go
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