Skip to content

Commit d4fd545

Browse files
hughdbrownclaude
andcommitted
Harden tokenPath() against path prefix and symlink attacks
- Fix incomplete path prefix check: Replace strings.HasPrefix with hasPathPrefix() that properly handles directory separators, preventing attacks where tokensDir-evil would pass the check for tokensDir - Add hasPathPrefix() helper for safe directory prefix checking without symlink resolution, keeping isPathWithinDir() for resolved symlink checks - Document TOCTOU limitation in symlink check with explanation of the low practical risk due to required attacker capabilities - Improve test to verify exact hash-based fallback path format Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8b673f7 commit d4fd545

2 files changed

Lines changed: 26 additions & 7 deletions

File tree

internal/oauth/oauth.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,13 +308,19 @@ func (m *Manager) tokenPath(email string) string {
308308
cleanPath := filepath.Clean(path)
309309
cleanTokensDir := filepath.Clean(m.tokensDir)
310310

311-
// Verify the path is still within tokensDir
312-
if !strings.HasPrefix(cleanPath, cleanTokensDir) {
311+
// Verify the path is still within tokensDir (using proper directory check
312+
// to avoid prefix attacks like tokensDir-evil matching tokensDir)
313+
if !hasPathPrefix(cleanPath, cleanTokensDir) {
313314
// If path escapes tokensDir, use a hash-based fallback
314315
return filepath.Join(m.tokensDir, fmt.Sprintf("%x.json", sha256.Sum256([]byte(email))))
315316
}
316317

317-
// Check if path is a symlink that could escape tokensDir
318+
// Check if path is a symlink that could escape tokensDir.
319+
// Note: There is an inherent TOCTOU (time-of-check to time-of-use) race between
320+
// this check and when the token is actually written. An attacker could create a
321+
// symlink after this check passes but before the write occurs. However, exploiting
322+
// this would require the attacker to have write access to the tokens directory and
323+
// precise timing, making it difficult to exploit in practice.
318324
if info, err := os.Lstat(cleanPath); err == nil && info.Mode()&os.ModeSymlink != 0 {
319325
// Path exists and is a symlink - resolve it and verify it stays within tokensDir
320326
resolved, err := filepath.EvalSymlinks(cleanPath)
@@ -327,7 +333,18 @@ func (m *Manager) tokenPath(email string) string {
327333
return cleanPath
328334
}
329335

336+
// hasPathPrefix checks if path starts with dir using proper separator handling.
337+
// This prevents prefix attacks like tokensDir-evil matching tokensDir.
338+
// Does not resolve symlinks - use isPathWithinDir when symlink resolution is needed.
339+
func hasPathPrefix(path, dir string) bool {
340+
cleanPath := filepath.Clean(path)
341+
cleanDir := filepath.Clean(dir)
342+
return strings.HasPrefix(cleanPath, cleanDir+string(filepath.Separator)) ||
343+
cleanPath == cleanDir
344+
}
345+
330346
// isPathWithinDir checks if path is within dir, resolving symlinks in dir.
347+
// Use this when checking resolved symlink targets.
331348
func isPathWithinDir(path, dir string) bool {
332349
// Resolve symlinks in dir to get the real base directory
333350
resolvedDir, err := filepath.EvalSymlinks(dir)

internal/oauth/oauth_test.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package oauth
22

33
import (
4+
"crypto/sha256"
45
"encoding/json"
6+
"fmt"
57
"net/http"
68
"net/http/httptest"
79
"os"
@@ -290,10 +292,10 @@ func TestTokenPath_SymlinkEscape(t *testing.T) {
290292
t.Errorf("tokenPath returned symlink path %q, should use hash-based fallback to prevent escape", gotPath)
291293
}
292294

293-
// Verify the returned path, when resolved, stays within tokensDir
294-
// (the hash-based fallback should be used)
295-
if !strings.HasPrefix(gotPath, tokensDir) {
296-
t.Errorf("tokenPath %q is not within tokensDir %q", gotPath, tokensDir)
295+
// Verify the returned path is exactly the expected hash-based fallback
296+
expectedPath := filepath.Join(tokensDir, fmt.Sprintf("%x.json", sha256.Sum256([]byte("evil"))))
297+
if gotPath != expectedPath {
298+
t.Errorf("tokenPath = %q, want hash-based fallback %q", gotPath, expectedPath)
297299
}
298300
}
299301

0 commit comments

Comments
 (0)