WitNote / internal /web /safepath_test.go
AUXteam's picture
Upload folder using huggingface_hub
6a7089a verified
package web
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// TestSafePath covers the original table-driven cases that ship with the project.
// These use hard-coded Unix-style paths which are resolved via filepath.Abs on every OS.
func TestSafePath(t *testing.T) {
tests := []struct {
name string
base string
path string
wantErr bool
}{
{"valid relative", "/tmp/state", "pdfs/out.pdf", false},
{"absolute inside rejected", "/tmp/state", "/tmp/state/pdfs/out.pdf", true},
{"traversal dotdot", "/tmp/state", "../etc/passwd", true},
{"traversal absolute", "/tmp/state", "/etc/passwd", true},
{"traversal hidden", "/tmp/state", "pdfs/../../etc/passwd", true},
{"absolute base rejected", "/tmp/state", "/tmp/state", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := SafePath(tt.base, tt.path)
if (err != nil) != tt.wantErr {
t.Errorf("SafePath(%q, %q) error = %v, wantErr %v", tt.base, tt.path, err, tt.wantErr)
}
})
}
}
// TestSafePath_TempDir uses real temp directories so paths are valid on every OS
// and never rely on hard-coded drive letters or mount points.
func TestSafePath_TempDir(t *testing.T) {
// Create a real base directory with a nested child.
base := t.TempDir()
child := filepath.Join(base, "sub", "dir")
if err := os.MkdirAll(child, 0o755); err != nil {
t.Fatalf("setup: %v", err)
}
t.Run("relative inside base", func(t *testing.T) {
got, err := SafePath(base, filepath.Join("sub", "dir"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != child {
t.Errorf("got %q, want %q", got, child)
}
})
t.Run("absolute inside base rejected", func(t *testing.T) {
_, err := SafePath(base, child)
if err == nil {
t.Error("expected error for absolute path, got nil")
}
})
t.Run("absolute base itself rejected", func(t *testing.T) {
_, err := SafePath(base, base)
if err == nil {
t.Error("expected error for absolute path, got nil")
}
})
t.Run("dotdot escapes base", func(t *testing.T) {
_, err := SafePath(base, filepath.Join("..", "escape"))
if err == nil {
t.Error("expected error for dot-dot traversal, got nil")
}
})
t.Run("deep dotdot escapes base", func(t *testing.T) {
_, err := SafePath(base, filepath.Join("sub", "..", "..", "escape"))
if err == nil {
t.Error("expected error for deep dot-dot traversal, got nil")
}
})
// Create a sibling directory to test cross-directory traversal.
sibling := filepath.Join(filepath.Dir(base), "sibling")
if err := os.MkdirAll(sibling, 0o755); err != nil {
t.Fatalf("setup sibling: %v", err)
}
defer func() { _ = os.RemoveAll(sibling) }()
t.Run("absolute sibling rejected", func(t *testing.T) {
_, err := SafePath(base, sibling)
if err == nil {
t.Errorf("expected error for sibling path %q, got nil", sibling)
}
})
}
// TestSafePath_RootRelative is the core regression test for the Windows bug.
// A leading separator without a drive letter (e.g. "/etc/passwd") must always
// be rejected because it resolves to a root-relative path on the current drive.
func TestSafePath_RootRelative(t *testing.T) {
base := t.TempDir()
attacks := []string{
"/etc/passwd",
"/Windows/System32/config/SAM",
}
for _, p := range attacks {
t.Run(p, func(t *testing.T) {
_, err := SafePath(base, p)
if err == nil {
t.Errorf("SafePath(%q, %q) should reject root-relative path", base, p)
}
})
}
}
// TestSafePath_Windows exercises Windows-only vectors that are meaningless on Unix.
func TestSafePath_Windows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows-specific test cases")
}
base := t.TempDir() // e.g. C:\Users\...\TestSafePath_Windows...
tests := []struct {
name string
path string
}{
{"backslash root", `\Windows\System32\config\SAM`},
{"other drive letter", `D:\secret\file.txt`},
{"UNC path", `\\server\share\secret.txt`},
{"slash root", `/Windows/System32`},
{"mixed separators escape", `..\..\..\Windows\System32`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := SafePath(base, tt.path)
if err == nil {
t.Errorf("SafePath(%q, %q) should reject path outside base", base, tt.path)
}
})
}
}
// TestSafePath_EmptyAndDot verifies edge cases that should resolve to the base itself.
func TestSafePath_EmptyAndDot(t *testing.T) {
base := t.TempDir()
for _, p := range []string{".", ""} {
t.Run("path="+p, func(t *testing.T) {
got, err := SafePath(base, p)
if err != nil {
t.Fatalf("unexpected error for %q: %v", p, err)
}
if got != base {
t.Errorf("got %q, want %q", got, base)
}
})
}
}
// TestSafePath_PrefixBoundary ensures that a path whose prefix textually
// matches the base but crosses a directory boundary is rejected.
// e.g. base="/tmp/state" must not allow "/tmp/stateEVIL/secret".
func TestSafePath_PrefixBoundary(t *testing.T) {
parent := t.TempDir()
base := filepath.Join(parent, "state")
evil := filepath.Join(parent, "stateEVIL", "secret")
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatalf("setup base: %v", err)
}
if err := os.MkdirAll(evil, 0o755); err != nil {
t.Fatalf("setup evil: %v", err)
}
_, err := SafePath(base, evil)
if err == nil {
t.Errorf("SafePath(%q, %q) should reject prefix-boundary attack", base, evil)
}
}
// TestSafePath_NullByte verifies that null bytes embedded in paths are not
// used to bypass the check (common technique: "safe\x00/../../../etc/passwd").
func TestSafePath_NullByte(t *testing.T) {
base := t.TempDir()
attacks := []string{
"safe\x00/../../../etc/passwd",
"\x00/etc/passwd",
"sub/\x00",
}
for _, p := range attacks {
t.Run("", func(t *testing.T) {
result, err := SafePath(base, p)
// Either an error OR the result must still be inside base.
if err == nil && !strings.HasPrefix(result, base) {
t.Errorf("SafePath(%q, %q) = %q — escaped base!", base, p, result)
}
})
}
}
// TestSafePath_RelativeNormalizesInside verifies that a relative path with
// dot-dot segments that ultimately lands inside the base is allowed.
func TestSafePath_RelativeNormalizesInside(t *testing.T) {
base := t.TempDir()
sub := filepath.Join(base, "a", "b")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatalf("setup: %v", err)
}
// "a/b/../b" normalizes to "a/b" — still inside base.
got, err := SafePath(base, filepath.Join("a", "b", "..", "b"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != sub {
t.Errorf("got %q, want %q", got, sub)
}
}
// TestSafePath_ReturnValueIsAbsolute ensures the returned path is always absolute.
func TestSafePath_ReturnValueIsAbsolute(t *testing.T) {
base := t.TempDir()
for _, p := range []string{".", "", "sub/file.txt"} {
t.Run("path="+p, func(t *testing.T) {
got, err := SafePath(base, p)
if err != nil {
t.Fatalf("unexpected error for %q: %v", p, err)
}
if !filepath.IsAbs(got) {
t.Errorf("SafePath(%q, %q) returned non-absolute path %q", base, p, got)
}
})
}
// Absolute user paths are rejected, so the return value test doesn't apply.
t.Run("absolute user path rejected", func(t *testing.T) {
_, err := SafePath(base, base)
if err == nil {
t.Error("expected error for absolute user path")
}
})
}