Validate portable workspace identifiers
This commit is contained in:
34
internal/pathsafe/opaque_segment.go
Normal file
34
internal/pathsafe/opaque_segment.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package pathsafe
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOpaqueSegmentRequired = errors.New("opaque identifier is required")
|
||||
ErrOpaqueSegmentInvalid = errors.New("opaque identifier must contain only ASCII letters, digits, '.', '_', or '-'")
|
||||
)
|
||||
|
||||
// ValidateOpaqueSegment validates an identity token that occupies exactly one
|
||||
// portable local-path and object-key segment. It deliberately does not trim or
|
||||
// normalize input: callers must reject rather than silently rewrite identity.
|
||||
func ValidateOpaqueSegment(value string) error {
|
||||
if value == "" {
|
||||
return ErrOpaqueSegmentRequired
|
||||
}
|
||||
if value == "." || value == ".." {
|
||||
return fmt.Errorf("%w: dot segments are not allowed", ErrOpaqueSegmentInvalid)
|
||||
}
|
||||
for index := 0; index < len(value); index++ {
|
||||
character := value[index]
|
||||
if (character >= 'a' && character <= 'z') ||
|
||||
(character >= 'A' && character <= 'Z') ||
|
||||
(character >= '0' && character <= '9') ||
|
||||
character == '.' || character == '_' || character == '-' {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%w: invalid byte at offset %d", ErrOpaqueSegmentInvalid, index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
61
internal/pathsafe/opaque_segment_test.go
Normal file
61
internal/pathsafe/opaque_segment_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package pathsafe
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateOpaqueSegment(t *testing.T) {
|
||||
valid := []string{"campaign", "2026-05-03", "run_01", "a.b-c_D9"}
|
||||
for _, value := range valid {
|
||||
t.Run("valid/"+value, func(t *testing.T) {
|
||||
if err := ValidateOpaqueSegment(value); err != nil {
|
||||
t.Fatalf("ValidateOpaqueSegment(%q) error = %v", value, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
invalid := []string{"", ".", "..", "campaign/name", `campaign\name`, "/campaign", `C:\campaign`, "campaign name", "café", "campaign\x00name", "campaign\nname"}
|
||||
for _, value := range invalid {
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
if err := ValidateOpaqueSegment(value); err == nil {
|
||||
t.Fatalf("ValidateOpaqueSegment(%q) error = nil, want rejection", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzValidateOpaqueSegment(f *testing.F) {
|
||||
for _, seed := range []string{"campaign", "2026-05-03", "..", `C:\campaign`, "café", "a/b", "a\x00b"} {
|
||||
f.Add(seed)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, value string) {
|
||||
if err := ValidateOpaqueSegment(value); err == nil {
|
||||
if value == "" || value == "." || value == ".." {
|
||||
t.Fatalf("accepted reserved segment %q", value)
|
||||
}
|
||||
for index := 0; index < len(value); index++ {
|
||||
character := value[index]
|
||||
if !((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
|
||||
(character >= '0' && character <= '9') || character == '.' || character == '_' || character == '-') {
|
||||
t.Fatalf("accepted invalid byte %q in %q", character, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzNormalizeRelativeDestination(f *testing.F) {
|
||||
for _, seed := range []string{"reports/result.json", `reports\result.json`, "../escape", `C:\escape`, "/absolute", "a/./b", "a/../b"} {
|
||||
f.Add(seed)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, value string) {
|
||||
normalized, err := NormalizeRelativeDestination(value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if normalized == "" || normalized == "." || normalized == ".." || normalized[0] == '/' {
|
||||
t.Fatalf("NormalizeRelativeDestination(%q) = %q, want confined relative path", value, normalized)
|
||||
}
|
||||
if len(normalized) >= 3 && normalized[:3] == "../" {
|
||||
t.Fatalf("NormalizeRelativeDestination(%q) escaped with %q", value, normalized)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user