fix(http): canonicalize paths before checking access rules (#6045)

This commit is contained in:
Henrique Dias 2026-07-26 10:00:18 +02:00 committed by GitHub
parent 41e2b1bbba
commit e6d70cf24c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 403 additions and 31 deletions

View file

@ -25,17 +25,29 @@ func MatchHidden(path string) bool {
return path != "" && strings.HasPrefix(filepath.Base(path), ".")
}
// Matches matches a path against a rule.
func (r *Rule) Matches(path string) bool {
// Matches matches a path against a rule. When fold is true the comparison is
// case-insensitive: on a case-insensitive filesystem two paths differing only
// in case name the same file, so a rule written for one spelling has to cover
// the others or it can be trivially evaded.
//
// Regex rules are never folded. An admin-authored pattern means what it says,
// and lowering its input would silently change it; use (?i) for those.
func (r *Rule) Matches(path string, fold bool) bool {
if r.Regex {
return r.Regexp.MatchString(path)
}
if path == r.Path {
rulePath := r.Path
if fold {
path = strings.ToLower(path)
rulePath = strings.ToLower(rulePath)
}
if path == rulePath {
return true
}
prefix := r.Path
prefix := rulePath
if prefix != "/" && !strings.HasSuffix(prefix, "/") {
prefix += "/"
}

View file

@ -9,30 +9,69 @@ func TestRuleMatches(t *testing.T) {
name string
rulePath string
testPath string
fold bool
want bool
}{
{"exact match", "/uploads", "/uploads", true},
{"child path", "/uploads", "/uploads/file.txt", true},
{"sibling prefix", "/uploads", "/uploads_backup/secret.txt", false},
{"root rule", "/", "/anything", true},
{"trailing slash rule", "/uploads/", "/uploads/file.txt", true},
{"trailing slash no sibling", "/uploads/", "/uploads_backup/file.txt", false},
{"nested child", "/data/shared", "/data/shared/docs/file.txt", true},
{"nested sibling", "/data/shared", "/data/shared_private/file.txt", false},
{"exact match", "/uploads", "/uploads", false, true},
{"child path", "/uploads", "/uploads/file.txt", false, true},
{"sibling prefix", "/uploads", "/uploads_backup/secret.txt", false, false},
{"root rule", "/", "/anything", false, true},
{"trailing slash rule", "/uploads/", "/uploads/file.txt", false, true},
{"trailing slash no sibling", "/uploads/", "/uploads_backup/file.txt", false, false},
{"nested child", "/data/shared", "/data/shared/docs/file.txt", false, true},
{"nested sibling", "/data/shared", "/data/shared_private/file.txt", false, false},
// On a case-insensitive filesystem /Secret.txt and /secret.TXT name the
// same file, so a rule for one must cover the other. See
// GHSA-fgm5-pw99-w2p7.
{"case variant not folded", "/Secret.txt", "/secret.TXT", false, false},
{"case variant folded", "/Secret.txt", "/secret.TXT", true, true},
{"case variant child folded", "/Private", "/private/notes.txt", true, true},
{"exact match folded", "/uploads", "/uploads", true, true},
// Folding must not widen a rule past its own path segment.
{"sibling prefix folded", "/uploads", "/UPLOADS_backup/secret.txt", true, false},
{"nested sibling folded", "/data/shared", "/data/SHARED_private/x", true, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
r := &Rule{Path: tc.rulePath}
got := r.Matches(tc.testPath)
got := r.Matches(tc.testPath, tc.fold)
if got != tc.want {
t.Errorf("Rule{Path: %q}.Matches(%q) = %v; want %v", tc.rulePath, tc.testPath, got, tc.want)
t.Errorf("Rule{Path: %q}.Matches(%q, fold=%v) = %v; want %v",
tc.rulePath, tc.testPath, tc.fold, got, tc.want)
}
})
}
}
// A regex rule is authored by an administrator and means exactly what it says,
// so folding must not reach it: the admin opts in with (?i) instead.
func TestRuleMatchesRegexIgnoresFold(t *testing.T) {
t.Parallel()
cases := []struct {
raw string
path string
want bool
}{
{`^/Secret\.txt$`, "/Secret.txt", true},
{`^/Secret\.txt$`, "/secret.TXT", false},
{`(?i)^/Secret\.txt$`, "/secret.TXT", true},
}
for _, tc := range cases {
for _, fold := range []bool{false, true} {
r := &Rule{Regex: true, Regexp: &Regexp{Raw: tc.raw}}
if got := r.Matches(tc.path, fold); got != tc.want {
t.Errorf("Rule{Regexp: %q}.Matches(%q, fold=%v) = %v; want %v",
tc.raw, tc.path, fold, got, tc.want)
}
}
}
}
func TestMatchHidden(t *testing.T) {
cases := map[string]bool{
"/": false,