Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions internal/auth/codebuddy/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"path/filepath"

"github.com/kooshapari/CLIProxyAPI/v7/internal/pathsafe"
"github.com/kooshapari/CLIProxyAPI/v7/pkg/llmproxy/misc"
)

Expand Down Expand Up @@ -46,11 +47,21 @@ type CodeBuddyTokenStorage struct {
func (s *CodeBuddyTokenStorage) SaveTokenToFile(authFilePath string) error {
misc.LogSavingCredentials(authFilePath)
s.Type = "codebuddy"
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {

// Defend against go/path-injection (CodeQL alerts #782/#783): constrain
// the credential file to its parent directory using pathsafe.SafeContain
// so any traversal segment in authFilePath is rejected before it reaches
// MkdirAll/OpenFile.
parentDir := filepath.Dir(authFilePath)
safePath, err := pathsafe.SafeContain(parentDir, authFilePath)
Comment on lines +55 to +56
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-high high

This implementation of SafeContain does not provide effective protection against path injection if authFilePath is untrusted. By deriving parentDir directly from the untrusted input, any absolute path (e.g., /etc/passwd) will be considered "contained" within its own parent directory (e.g., /etc).

To properly defend against path injection, the base directory must be a trusted, fixed root (such as a specific configuration or data directory). If the intention is to allow the user to specify a path but ensure it stays within a safe area, you should use a hardcoded or pre-validated base directory.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Circular SafeContain check provides no security protection

High Severity

The base directory for SafeContain is derived from the untrusted authFilePath itself via filepath.Dir(authFilePath). This makes the containment check tautological — any path without explicit .. segments will always be "contained" within its own parent directory. For example, an attacker-controlled path like /etc/cron.d/evil passes because SafeContain("/etc/cron.d", "/etc/cron.d/evil") trivially succeeds. A meaningful path-injection defense needs a fixed, trusted base directory, not one derived from the attacker's input.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 82e38bc. Configure here.

Comment on lines +55 to +56
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The containment check is ineffective because the base directory is derived from the same untrusted authFilePath being validated. An attacker can pass an absolute path like /etc/passwd; filepath.Dir becomes /etc, so SafeContain will accept it and the code will still write outside any intended auth directory. Validate against a trusted base (for example configured auth dir) and then join/contain the user-controlled file component against that trusted base. [security]

Severity Level: Critical 🚨
- ❌ CodeBuddy token storage can write to attacker-chosen paths.
- ❌ Potential overwrite of sensitive files when running with privileges.
- ⚠️ Path guard ineffective; base derived from untrusted path itself.
Steps of Reproduction ✅
1. In this repository, create a small test helper (for example
`internal/auth/codebuddy/token_injection_example_test.go`) that constructs a
`CodeBuddyTokenStorage` value and calls `SaveTokenToFile` with an absolute path such as
`/etc/passwd`:

   ```go

   s := &CodeBuddyTokenStorage{}

   _ = s.SaveTokenToFile("/etc/passwd")
  1. The call enters SaveTokenToFile in internal/auth/codebuddy/token.go:47. At line 55,
    parentDir := filepath.Dir(authFilePath) evaluates to /etc for the /etc/passwd input,
    and at line 56, safePath, err := pathsafe.SafeContain(parentDir, authFilePath) is
    invoked with base /etc and path /etc/passwd.

  2. Because the base directory passed to SafeContain is derived from the same untrusted
    authFilePath, the containment check only verifies that /etc/passwd is under /etc and
    therefore succeeds: err is nil and safePath is /etc/passwd, so the error path at
    lines 57–59 is not taken.

  3. Execution proceeds to the filesystem operations using this attacker-controlled absolute
    path: at line 60 os.MkdirAll(filepath.Dir(safePath), 0700) targets /etc, and at line
    64 os.OpenFile(safePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) opens /etc/passwd.
    This demonstrates that the current guard does not constrain writes to any trusted auth
    directory; any caller able to influence authFilePath can direct token writes to
    arbitrary locations.

</details>

[Fix in Cursor](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt=This%20is%20a%20comment%20left%20during%20a%20code%20review.%0A%0A%2A%2APath%3A%2A%2A%20internal%2Fauth%2Fcodebuddy%2Ftoken.go%0A%2A%2ALine%3A%2A%2A%2055%3A56%0A%2A%2AComment%3A%2A%2A%0A%09%2ASecurity%3A%20The%20containment%20check%20is%20ineffective%20because%20the%20base%20directory%20is%20derived%20from%20the%20same%20untrusted%20%60authFilePath%60%20being%20validated.%20An%20attacker%20can%20pass%20an%20absolute%20path%20like%20%60%2Fetc%2Fpasswd%60%3B%20%60filepath.Dir%60%20becomes%20%60%2Fetc%60%2C%20so%20%60SafeContain%60%20will%20accept%20it%20and%20the%20code%20will%20still%20write%20outside%20any%20intended%20auth%20directory.%20Validate%20against%20a%20trusted%20base%20%28for%20example%20configured%20auth%20dir%29%20and%20then%20join%2Fcontain%20the%20user-controlled%20file%20component%20against%20that%20trusted%20base.%0A%0AValidate%20the%20correctness%20of%20the%20flagged%20issue.%20If%20correct%2C%20How%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20implement%20it%20and%20please%20make%20it%20concise.%0AOnce%20fix%20is%20implemented%2C%20also%20check%20other%20comments%20on%20the%20same%20PR%2C%20and%20ask%20user%20if%20the%20user%20wants%20to%20fix%20the%20rest%20of%20the%20comments%20as%20well.%20if%20said%20yes%2C%20then%20fetch%20all%20the%20comments%20validate%20the%20correctness%20and%20implement%20a%20minimal%20fix%0A) | [Fix in VSCode Claude](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt=This%20is%20a%20comment%20left%20during%20a%20code%20review.%0A%0A%2A%2APath%3A%2A%2A%20internal%2Fauth%2Fcodebuddy%2Ftoken.go%0A%2A%2ALine%3A%2A%2A%2055%3A56%0A%2A%2AComment%3A%2A%2A%0A%09%2ASecurity%3A%20The%20containment%20check%20is%20ineffective%20because%20the%20base%20directory%20is%20derived%20from%20the%20same%20untrusted%20%60authFilePath%60%20being%20validated.%20An%20attacker%20can%20pass%20an%20absolute%20path%20like%20%60%2Fetc%2Fpasswd%60%3B%20%60filepath.Dir%60%20becomes%20%60%2Fetc%60%2C%20so%20%60SafeContain%60%20will%20accept%20it%20and%20the%20code%20will%20still%20write%20outside%20any%20intended%20auth%20directory.%20Validate%20against%20a%20trusted%20base%20%28for%20example%20configured%20auth%20dir%29%20and%20then%20join%2Fcontain%20the%20user-controlled%20file%20component%20against%20that%20trusted%20base.%0A%0AValidate%20the%20correctness%20of%20the%20flagged%20issue.%20If%20correct%2C%20How%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20implement%20it%20and%20please%20make%20it%20concise.%0AOnce%20fix%20is%20implemented%2C%20also%20check%20other%20comments%20on%20the%20same%20PR%2C%20and%20ask%20user%20if%20the%20user%20wants%20to%20fix%20the%20rest%20of%20the%20comments%20as%20well.%20if%20said%20yes%2C%20then%20fetch%20all%20the%20comments%20validate%20the%20correctness%20and%20implement%20a%20minimal%20fix%0A)

*(Use Cmd/Ctrl + Click for best experience)*
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>

```mdx
This is a comment left during a code review.

**Path:** internal/auth/codebuddy/token.go
**Line:** 55:56
**Comment:**
	*Security: The containment check is ineffective because the base directory is derived from the same untrusted `authFilePath` being validated. An attacker can pass an absolute path like `/etc/passwd`; `filepath.Dir` becomes `/etc`, so `SafeContain` will accept it and the code will still write outside any intended auth directory. Validate against a trusted base (for example configured auth dir) and then join/contain the user-controlled file component against that trusted base.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

if err != nil {
return fmt.Errorf("invalid token file path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}

f, err := os.OpenFile(authFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
f, err := os.OpenFile(safePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create token file: %w", err)
}
Expand Down
102 changes: 102 additions & 0 deletions internal/pathsafe/pathsafe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Package pathsafe provides defenses against path-injection (CWE-22 /
// CodeQL go/path-injection) by enforcing that user-controlled inputs cannot
// escape an intended base directory.
//
// The canonical pattern is filepath.Clean + filepath.Abs prefix-check; see
// repos/docs/governance/cliproxyapi-security-triage-2026-04.md for the
// triage rationale.
package pathsafe

import (
"errors"
"fmt"
"path/filepath"
"strings"
)

// ErrEscape is returned when the resolved path escapes the base directory.
var ErrEscape = errors.New("pathsafe: path escapes base directory")

// ErrTraversal is returned when input contains an explicit traversal segment.
var ErrTraversal = errors.New("pathsafe: path contains traversal component")

// ErrEmpty is returned when base or input are empty after trimming.
var ErrEmpty = errors.New("pathsafe: empty path component")

// SafeJoin joins userInput onto base and verifies the absolute result is
// contained within the absolute base directory. It rejects empty input,
// explicit `..` traversal segments, and paths whose absolute form does not
// have base as a prefix.
//
// userInput may be a bare file name or a relative subpath; absolute paths
// supplied as userInput are rejected as a defensive measure (callers that
// truly want to allow absolute paths should validate them through
// SafeContain instead).
func SafeJoin(base, userInput string) (string, error) {
base = strings.TrimSpace(base)
userInput = strings.TrimSpace(userInput)
if base == "" || userInput == "" {
return "", ErrEmpty
}
if filepath.IsAbs(userInput) {
return "", ErrEscape
}
if hasTraversal(userInput) {
return "", ErrTraversal
}

cleaned := filepath.Clean(filepath.Join(base, userInput))
absBase, err := filepath.Abs(base)
if err != nil {
return "", fmt.Errorf("pathsafe: resolve base: %w", err)
}
absCleaned, err := filepath.Abs(cleaned)
if err != nil {
return "", fmt.Errorf("pathsafe: resolve target: %w", err)
}
if absCleaned != absBase &&
!strings.HasPrefix(absCleaned, absBase+string(filepath.Separator)) {
return "", ErrEscape
Comment on lines +48 to +59
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: SafeJoin has the same symlink-escape issue: joining and checking absolute lexical prefixes is not enough to enforce filesystem containment when symlinks are present. A joined path inside base can still resolve outside at open time; resolve symlinks before returning/validating. [security]

Severity Level: Major ⚠️
- ⚠️ SafeJoin permits symlink escape from intended base directory.
- ⚠️ Future code using SafeJoin may mis-enforce containment.
- ⚠️ Path-injection defenses incomplete for symlink scenarios.
Steps of Reproduction ✅
1. Consider a caller using `pathsafe.SafeJoin` to gate user-controlled relative paths
before file operations, e.g. future code mirroring existing patterns like
`misc.ResolveSafeFilePathInDir` but using `SafeJoin(base, userInput)` from
`internal/pathsafe/pathsafe.go:35-62`.

2. The caller passes a trusted base directory and a relative sub-path that stays lexically
under that base but enters a symlink, for example `base="/srv/app/auth"` and
`userInput="link/creds.json"` where `/srv/app/auth/link` is a symlink to `/etc/app-auth`.

3. `SafeJoin` executes the code at `internal/pathsafe/pathsafe.go:48-59`: it computes
`cleaned := filepath.Clean(filepath.Join(base, userInput))`, then `absBase :=
filepath.Abs(base)` and `absCleaned := filepath.Abs(cleaned)`, and finally checks
`absCleaned != absBase && !strings.HasPrefix(absCleaned,
absBase+string(filepath.Separator))`.

4. Because `absCleaned` lexically starts with `absBase+"/"` (e.g.,
`"/srv/app/auth/link/creds.json"`), the prefix check at lines 57–59 passes, `SafeJoin`
returns the joined path, and any subsequent `os.OpenFile` or `os.MkdirAll` performed by
the caller operates on `/etc/app-auth/creds.json` due to symlink resolution. This shows
that `SafeJoin` does not defend against symlink-based escapes even though it is documented
as ensuring user-controlled inputs cannot escape `base`; currently there are no production
callers beyond tests, so this is a latent issue that would surface once `SafeJoin` is used
as a general path guard.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** internal/pathsafe/pathsafe.go
**Line:** 48:59
**Comment:**
	*Security: `SafeJoin` has the same symlink-escape issue: joining and checking absolute lexical prefixes is not enough to enforce filesystem containment when symlinks are present. A joined path inside `base` can still resolve outside at open time; resolve symlinks before returning/validating.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}
Comment on lines +57 to +60
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The prefix check logic has a bug when the base directory is the filesystem root (e.g., / on Unix or C:\ on Windows).

On Unix, if absBase is /, then absBase + string(filepath.Separator) becomes //. Since filepath.Clean (called via filepath.Abs) collapses multiple slashes, a target path like /etc/passwd will not start with //, causing a false positive ErrEscape.

A more robust approach is to ensure the base ends with a separator only if it doesn't already, or use filepath.Rel to check for .. segments.

if absCleaned == absBase {
		return absCleaned, nil
	}
	prefix := absBase
	if !strings.HasSuffix(prefix, string(filepath.Separator)) {
		prefix += string(filepath.Separator)
	}
	if !strings.HasPrefix(absCleaned, prefix) {
		return "", ErrEscape
	}

return absCleaned, nil
}

// SafeContain validates that an already-constructed full path lies inside
// the supplied base directory. Use this when the caller assembled the path
// themselves (for example, from configuration) but the final path still
// crosses a trust boundary into a filesystem syscall.
func SafeContain(base, fullPath string) (string, error) {
base = strings.TrimSpace(base)
fullPath = strings.TrimSpace(fullPath)
if base == "" || fullPath == "" {
return "", ErrEmpty
}
if hasTraversal(fullPath) {
return "", ErrTraversal
}
absBase, err := filepath.Abs(base)
if err != nil {
return "", fmt.Errorf("pathsafe: resolve base: %w", err)
}
absFull, err := filepath.Abs(filepath.Clean(fullPath))
if err != nil {
return "", fmt.Errorf("pathsafe: resolve target: %w", err)
}
if absFull != absBase &&
!strings.HasPrefix(absFull, absBase+string(filepath.Separator)) {
return "", ErrEscape
Comment on lines +77 to +87
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: This check uses filepath.Abs + prefix matching but does not resolve symlinks, so a path under base can traverse outside via a symlink and still pass validation. Resolve symlinks (filepath.EvalSymlinks) on the compared paths (or on base and target parent) before containment checks to prevent symlink escape. [security]

Severity Level: Major ⚠️
- ⚠️ SafeContain allows symlink-based escape from logical base.
- ⚠️ Future filesystem guards using SafeContain weaker than intended.
- ⚠️ CodeBuddy token path guard does not handle symlinks.
Steps of Reproduction ✅
1. Run the CodeBuddy login flow via the CLI, which calls `manager.Login(ctx, "codebuddy",
cfg, authOpts)` in `sdk/auth/manager.go:47` from `pkg/llmproxy/cmd/codebuddy_login.go:30`
(found via Grep).

2. Inside `CodeBuddyAuthenticator.Login` (`sdk/auth/codebuddy.go:37-94`), a
`*coreauth.Auth` is created with `Storage: storage` where `storage` is
`*CodeBuddyTokenStorage` from `internal/auth/codebuddy/codebuddy_auth.go:42-51`.

3. The manager's `store.Save(ctx, record)` call (`sdk/auth/manager.go:71`) delegates to
`FileTokenStore.Save` in `sdk/auth/filestore.go:41-80`, which computes a filesystem `path`
under the configured auth directory and then calls `auth.Storage.SaveTokenToFile(path)` at
`sdk/auth/filestore.go:78`.

4. `CodeBuddyTokenStorage.SaveTokenToFile` (`internal/auth/codebuddy/token.go:47-75`)
computes `parentDir := filepath.Dir(authFilePath)` and calls
`pathsafe.SafeContain(parentDir, authFilePath)` (`internal/pathsafe/pathsafe.go:68-89`).
`SafeContain` resolves both `base` and `fullPath` with `filepath.Abs` and then performs a
string prefix check at lines 77–87 without resolving symlinks. In any future call where
`base` is a trusted directory and `fullPath` includes a symlink inside `base` that points
outside (e.g., `base="/srv/app/auth"`, `fullPath="/srv/app/auth/link/creds.json"` with
`link -> /etc/app-auth`), `SafeContain` will return success, and subsequent `os.OpenFile`
on the returned path will operate on `/etc/app-auth/creds.json` instead of remaining
within the real `base` directory. This demonstrates that the containment check can be
bypassed via symlink traversal; however, in the current CodeBuddy flow the base directory
itself is user-configured and there is no higher-privilege context, so this is primarily a
defense-in-depth issue for future uses of `SafeContain`.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** internal/pathsafe/pathsafe.go
**Line:** 77:87
**Comment:**
	*Security: This check uses `filepath.Abs` + prefix matching but does not resolve symlinks, so a path under `base` can traverse outside via a symlink and still pass validation. Resolve symlinks (`filepath.EvalSymlinks`) on the compared paths (or on base and target parent) before containment checks to prevent symlink escape.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}
Comment on lines +85 to +88
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This prefix check suffers from the same root directory bug as SafeJoin. Additionally, the logic for prefix validation and path resolution is duplicated between SafeJoin and SafeContain. Consider refactoring this into a private helper function to ensure consistency and maintainability.

Suggested change
if absFull != absBase &&
!strings.HasPrefix(absFull, absBase+string(filepath.Separator)) {
return "", ErrEscape
}
if absFull == absBase {
return absFull, nil
}
prefix := absBase
if !strings.HasSuffix(prefix, string(filepath.Separator)) {
prefix += string(filepath.Separator)
}
if !strings.HasPrefix(absFull, prefix) {
return "", ErrEscape
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Prefix check fails when base is filesystem root

Low Severity

When base resolves to the filesystem root (/ on Unix, C:\ on Windows), appending filepath.Separator produces // or C:\\, so strings.HasPrefix never matches any child path. Both SafeJoin and SafeContain incorrectly return ErrEscape for every path under root. For the token.go usage, this means an authFilePath like /token.json would be rejected because filepath.Dir yields /.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 82e38bc. Configure here.

return absFull, nil
}

// hasTraversal returns true when path contains an explicit `..` segment
// after normalising backslashes to forward slashes (defensive on Windows).
func hasTraversal(path string) bool {
normalized := strings.ReplaceAll(path, "\\", "/")
for _, segment := range strings.Split(normalized, "/") {
if segment == ".." {
return true
}
}
return false
}
67 changes: 67 additions & 0 deletions internal/pathsafe/pathsafe_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package pathsafe

import (
"path/filepath"
"testing"
)

func TestSafeJoinHappyPath(t *testing.T) {
base := t.TempDir()
got, err := SafeJoin(base, "file.json")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := filepath.Join(base, "file.json")
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}

func TestSafeJoinSubdir(t *testing.T) {
base := t.TempDir()
got, err := SafeJoin(base, "sub/file.json")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != filepath.Join(base, "sub", "file.json") {
t.Fatalf("subdir join wrong: %q", got)
}
}

func TestSafeJoinRejectsTraversal(t *testing.T) {
base := t.TempDir()
cases := []string{"../etc/passwd", "sub/../../escape", "..\\windows", "/abs/path"}
for _, c := range cases {
if _, err := SafeJoin(base, c); err == nil {
t.Errorf("expected error for %q", c)
}
}
}

func TestSafeJoinRejectsEmpty(t *testing.T) {
if _, err := SafeJoin("", "x"); err == nil {
t.Error("expected error for empty base")
}
if _, err := SafeJoin("/tmp", ""); err == nil {
t.Error("expected error for empty input")
}
}

func TestSafeContainAccepts(t *testing.T) {
base := t.TempDir()
full := filepath.Join(base, "x", "y.json")
got, err := SafeContain(base, full)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == "" {
t.Fatal("expected non-empty result")
}
}

func TestSafeContainRejectsOutside(t *testing.T) {
base := t.TempDir()
if _, err := SafeContain(base, "/etc/passwd"); err == nil {
t.Error("expected error for path outside base")
}
}
Loading