Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce path Clean/Join helper functions #23495

Merged
merged 14 commits into from
Mar 21, 2023
Merged
6 changes: 3 additions & 3 deletions models/git/lfs_lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func init() {

// BeforeInsert is invoked from XORM before inserting an object of this type.
func (l *LFSLock) BeforeInsert() {
l.Path = util.CleanPath(l.Path)
l.Path = util.SafePathRel(l.Path)
}

// CreateLFSLock creates a new lock.
Expand All @@ -49,7 +49,7 @@ func CreateLFSLock(ctx context.Context, repo *repo_model.Repository, lock *LFSLo
return nil, err
}

lock.Path = util.CleanPath(lock.Path)
lock.Path = util.SafePathRel(lock.Path)
lock.RepoID = repo.ID

l, err := GetLFSLock(dbCtx, repo, lock.Path)
Expand All @@ -69,7 +69,7 @@ func CreateLFSLock(ctx context.Context, repo *repo_model.Repository, lock *LFSLo

// GetLFSLock returns release by given path.
func GetLFSLock(ctx context.Context, repo *repo_model.Repository, path string) (*LFSLock, error) {
path = util.CleanPath(path)
path = util.SafePathRel(path)
rel := &LFSLock{RepoID: repo.ID}
has, err := db.GetEngine(ctx).Where("lower(path) = ?", strings.ToLower(path)).Get(rel)
if err != nil {
Expand Down
10 changes: 5 additions & 5 deletions modules/options/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,27 @@ import (

// Locale reads the content of a specific locale from static/bindata or custom path.
func Locale(name string) ([]byte, error) {
return fileFromDir(path.Join("locale", util.CleanPath(name)))
return fileFromDir(path.Join("locale", util.SafePathRel(name)))
}

// Readme reads the content of a specific readme from static/bindata or custom path.
func Readme(name string) ([]byte, error) {
return fileFromDir(path.Join("readme", util.CleanPath(name)))
return fileFromDir(path.Join("readme", util.SafePathRel(name)))
}

// Gitignore reads the content of a gitignore locale from static/bindata or custom path.
func Gitignore(name string) ([]byte, error) {
return fileFromDir(path.Join("gitignore", util.CleanPath(name)))
return fileFromDir(path.Join("gitignore", util.SafePathRel(name)))
}

// License reads the content of a specific license from static/bindata or custom path.
func License(name string) ([]byte, error) {
return fileFromDir(path.Join("license", util.CleanPath(name)))
return fileFromDir(path.Join("license", util.SafePathRel(name)))
}

// Labels reads the content of a specific labels from static/bindata or custom path.
func Labels(name string) ([]byte, error) {
return fileFromDir(path.Join("label", util.CleanPath(name)))
return fileFromDir(path.Join("label", util.SafePathRel(name)))
}

// WalkLocales reads the content of a specific locale
Expand Down
26 changes: 8 additions & 18 deletions modules/public/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,29 +45,19 @@ func AssetsHandlerFunc(opts *Options) http.HandlerFunc {
return
}

file := req.URL.Path
file = file[len(opts.Prefix):]
if len(file) == 0 {
resp.WriteHeader(http.StatusNotFound)
return
}
if strings.Contains(file, "\\") {
resp.WriteHeader(http.StatusBadRequest)
return
}
file = "/" + file

var written bool
var corsSent bool
if opts.CorsHandler != nil {
written = true
opts.CorsHandler(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
written = false
corsSent = true
})).ServeHTTP(resp, req)
}
if written {
// If CORS is not sent, the response must have been written by other handlers
if !corsSent {
return
}

file := req.URL.Path[len(opts.Prefix):]

// custom files
if opts.handle(resp, req, http.Dir(custPath), file) {
return
Expand Down Expand Up @@ -102,8 +92,8 @@ func setWellKnownContentType(w http.ResponseWriter, file string) {
}

func (opts *Options) handle(w http.ResponseWriter, req *http.Request, fs http.FileSystem, file string) bool {
// use clean to keep the file is a valid path with no . or ..
f, err := fs.Open(util.CleanPath(file))
// actually, fs (http.FileSystem) is designed to be a safe interface, relative paths won't bypass its parent directory, it's also fine to do a clean here
f, err := fs.Open(util.SafePathRelX(file))
if err != nil {
if os.IsNotExist(err) {
return false
Expand Down
17 changes: 10 additions & 7 deletions modules/storage/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ package storage

import (
"context"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"

"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
Expand Down Expand Up @@ -41,13 +41,19 @@ func NewLocalStorage(ctx context.Context, cfg interface{}) (ObjectStorage, error
}
config := configInterface.(LocalStorageConfig)

if !filepath.IsAbs(config.Path) {
return nil, fmt.Errorf("LocalStorageConfig.Path should have been prepared by setting/storage.go and should be an absolute path, but not: %q", config.Path)
}
log.Info("Creating new Local Storage at %s", config.Path)
if err := os.MkdirAll(config.Path, os.ModePerm); err != nil {
return nil, err
}

if config.TemporaryPath == "" {
config.TemporaryPath = config.Path + "/tmp"
config.TemporaryPath = filepath.Join(config.Path, "tmp")
}
if !filepath.IsAbs(config.TemporaryPath) {
return nil, fmt.Errorf("LocalStorageConfig.TemporaryPath should be an absolute path, but not: %q", config.TemporaryPath)
}

return &LocalStorage{
Expand All @@ -58,7 +64,7 @@ func NewLocalStorage(ctx context.Context, cfg interface{}) (ObjectStorage, error
}

func (l *LocalStorage) buildLocalPath(p string) string {
return filepath.Join(l.dir, util.CleanPath(strings.ReplaceAll(p, "\\", "/")))
return util.SafeFilePathAbs(l.dir, p)
}

// Open a file
Expand Down Expand Up @@ -128,10 +134,7 @@ func (l *LocalStorage) URL(path, name string) (*url.URL, error) {

// IterateObjects iterates across the objects in the local storage
func (l *LocalStorage) IterateObjects(prefix string, fn func(path string, obj Object) error) error {
dir := l.dir
if prefix != "" {
dir = filepath.Join(l.dir, util.CleanPath(prefix))
}
dir := l.buildLocalPath(prefix)
return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
Expand Down
20 changes: 10 additions & 10 deletions modules/storage/local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,29 @@ func TestBuildLocalPath(t *testing.T) {
expected string
}{
{
"a",
"/a",
"0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
"a/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
"/a/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
},
{
"a",
"/a",
"../0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
"a/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
"/a/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
},
{
"a",
"/a",
"0\\a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
"a/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
"/a/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
},
{
"b",
"/b",
"a/../0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
"b/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
"/b/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
},
{
"b",
"/b",
"a\\..\\0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
"b/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
"/b/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14",
},
}

Expand Down
2 changes: 1 addition & 1 deletion modules/storage/minio.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ func NewMinioStorage(ctx context.Context, cfg interface{}) (ObjectStorage, error
}

func (m *MinioStorage) buildMinioPath(p string) string {
return strings.TrimPrefix(path.Join(m.basePath, util.CleanPath(strings.ReplaceAll(p, "\\", "/"))), "/")
return util.SafePathRelX(m.basePath, p)
}

// Open open a file
Expand Down
90 changes: 79 additions & 11 deletions modules/util/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,89 @@ import (
"strings"
)

// CleanPath ensure to clean the path
func CleanPath(p string) string {
if strings.HasPrefix(p, "/") {
return path.Clean(p)
// SafePathRel joins the path elements into a single path, each element is cleaned by path.Clean separately.
// It only returns the following values (like path.Join), any redundant part (empty, relative dots, slashes) is removed.
//
// empty => ``
// `` => ``
// `..` => `.`
// `dir` => `dir`
// `/dir/` => `dir`
// `foo\..\bar` => `foo\..\bar`
// {`foo`, ``, `bar`} => `foo/bar`
// {`foo`, `..`, `bar`} => `foo/bar`
func SafePathRel(elem ...string) string {
elems := make([]string, len(elem))
for i, e := range elem {
if e == "" {
continue
}
elems[i] = path.Clean("/" + e)
}
p := path.Join(elems...)
if p == "" {
return ""
} else if p == "/" {
return "."
} else {
return p[1:]
}
}

// SafePathRelX joins the path elements into a single path like SafePathRel,
// and covert all backslashes to slashes. (X means "extended", also means the combination of `\` and `/`).
// It returns similar results as SafePathRel except:
//
// `foo\..\bar` => `bar` (because it's processed as `foo/../bar`)
//
// All backslashes are handled as slashes, the result only contains slashes.
func SafePathRelX(elem ...string) string {
elems := make([]string, len(elem))
for i, e := range elem {
if e == "" {
continue
}
elems[i] = path.Clean("/" + strings.ReplaceAll(e, "\\", "/"))
}
return path.Clean("/" + p)[1:]
return SafePathRel(elems...)
}

// EnsureAbsolutePath ensure that a path is absolute, making it
// relative to absoluteBase if necessary
func EnsureAbsolutePath(path, absoluteBase string) string {
if filepath.IsAbs(path) {
return path
const pathSeparator = string(os.PathSeparator)

// SafeFilePathAbs joins the path elements into a single file path, each element is cleaned by filepath.Clean separately.
// All slashes/backslashes are converted to path separators before cleaning, the result only contains path separators.
// The first element must be an absolute path, caller should prepare the base path.
// Like SafePathRel, any redundant part (empty, relative dots, slashes) is removed.
//
// {`/foo`, ``, `bar`} => `/foo/bar`
// {`/foo`, `..`, `bar`} => `/foo/bar`
func SafeFilePathAbs(elem ...string) string {
elems := make([]string, len(elem))

// POISX filesystem can have `\` in file names. Windows: `\` and `/` are both used for path separators
// to keep the behavior consistent, we do not allow `\` in file names, replace all `\` with `/`
if isOSWindows() {
elems[0] = filepath.Clean(elem[0])
} else {
elems[0] = filepath.Clean(strings.ReplaceAll(elem[0], "\\", pathSeparator))
}
if !filepath.IsAbs(elems[0]) {
// This shouldn't happen. If there is really necessary to pass in relative path, return the full path with filepath.Abs() instead
panic("FilePathJoinAbs: result is not absolute, do not guess a relative path based on current working directory")
}

for i := 1; i < len(elem); i++ {
if elem[i] == "" {
continue
}
if isOSWindows() {
elems[i] = filepath.Clean(pathSeparator + elem[i])
} else {
elems[i] = filepath.Clean(pathSeparator + strings.ReplaceAll(elem[i], "\\", pathSeparator))
}
}
return filepath.Join(absoluteBase, path)
// the elems[0] must be an absolute path, just join them together
return filepath.Join(elems...)
}

// IsDir returns true if given path is a directory,
Expand Down
70 changes: 64 additions & 6 deletions modules/util/path_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,71 @@ func TestMisc_IsReadmeFileName(t *testing.T) {
}

func TestCleanPath(t *testing.T) {
cases := map[string]string{
"../../test": "test",
"/test": "/test",
"/../test": "/test",
cases := []struct {
elems []string
expected string
}{
{[]string{}, ``},
{[]string{``}, ``},
{[]string{`..`}, `.`},
{[]string{`a`}, `a`},
{[]string{`/a/`}, `a`},
{[]string{`../a/`, `../b`, `c/..`, `d`}, `a/b/d`},
{[]string{`a\..\b`}, `a\..\b`},
{[]string{`a`, ``, `b`}, `a/b`},
{[]string{`a`, `..`, `b`}, `a/b`},
}
for _, c := range cases {
assert.Equal(t, c.expected, SafePathRel(c.elems...), "case: %v", c.elems)
}

cases = []struct {
elems []string
expected string
}{
{[]string{}, ``},
{[]string{``}, ``},
{[]string{`..`}, `.`},
{[]string{`a`}, `a`},
{[]string{`/a/`}, `a`},
{[]string{`../a/`, `../b`, `c/..`, `d`}, `a/b/d`},
{[]string{`a\..\b`}, `b`},
{[]string{`a`, ``, `b`}, `a/b`},
{[]string{`a`, `..`, `b`}, `a/b`},
}
for _, c := range cases {
assert.Equal(t, c.expected, SafePathRelX(c.elems...), "case: %v", c.elems)
}

for k, v := range cases {
assert.Equal(t, v, CleanPath(k))
// for POSIX only, but the result is similar on Windows, because the first element must be an absolute path
if isOSWindows() {
cases = []struct {
elems []string
expected string
}{
{[]string{`C:\..`}, `C:\`},
{[]string{`C:\a`}, `C:\a`},
{[]string{`C:\a/`}, `C:\a`},
{[]string{`C:\..\a\`, `../b`, `c\..`, `d`}, `C:\a\b\d`},
{[]string{`C:\a/..\b`}, `C:\b`},
{[]string{`C:\a`, ``, `b`}, `C:\a\b`},
{[]string{`C:\a`, `..`, `b`}, `C:\a\b`},
}
} else {
cases = []struct {
elems []string
expected string
}{
{[]string{`/..`}, `/`},
{[]string{`/a`}, `/a`},
{[]string{`/a/`}, `/a`},
{[]string{`/../a/`, `../b`, `c/..`, `d`}, `/a/b/d`},
{[]string{`/a\..\b`}, `/b`},
{[]string{`/a`, ``, `b`}, `/a/b`},
{[]string{`/a`, `..`, `b`}, `/a/b`},
}
}
for _, c := range cases {
assert.Equal(t, c.expected, SafeFilePathAbs(c.elems...), "case: %v", c.elems)
}
}
Loading