diff --git a/.gitignore b/.gitignore index 435be92..e9b2db6 100755 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ test-fixture/*/* !test-fixture/hols/glasgow.jpg !test-fixture/hols/landscape-540116_1920.jpg !test-fixture/hols/scotland-1761292_1920.jpg +!test-fixture/fancy-path +!test-fixture/fancy-path/* diff --git a/Makefile b/Makefile index a337574..5fae0f7 100755 --- a/Makefile +++ b/Makefile @@ -9,15 +9,24 @@ run: make build ./gossa test-fixture +run-extra: + make build + ./gossa -prefix="/fancy-path/" -symlinks=true test-fixture + ci: + -@cd test-fixture && ln -s ../docker . timeout 10 make run & - cp src/gossa_test.go . && sleep 5 && go test + sleep 11 && timeout 10 make run-extra & + cp src/gossa_test.go . && go test rm gossa_test.go watch: ls src/* gossa-ui/* | entr -rc make run -ci-watch: +watch-extra: + ls src/* gossa-ui/* | entr -rc make run-extra + +watch-ci: ls src/* gossa-ui/* | entr -rc make ci build-all: diff --git a/gossa-ui b/gossa-ui index 7f383a6..1a8e83c 160000 --- a/gossa-ui +++ b/gossa-ui @@ -1 +1 @@ -Subproject commit 7f383a67b8f35ecd19192bc34c76a94693276d1b +Subproject commit 1a8e83cc7a82ecdb3595c9dabbbd0374605c990d diff --git a/readme.md b/readme.md index 3057bd0..b92ecb7 100644 --- a/readme.md +++ b/readme.md @@ -8,7 +8,7 @@ gossa [![docker pulls](https://img.shields.io/docker/pulls/pldubouilh/gossa.svg?logo=docker)](https://hub.docker.com/r/pldubouilh/gossa) [![github downloads](https://img.shields.io/github/downloads/pldubouilh/gossa/total.svg?logo=github)](https://github.com/pldubouilh/gossa/releases) -a fast and simple webserver for your files, that's dependency-free and with only 210 lines of code, easy to review. +a fast and simple webserver for your files, that's dependency-free and with under 200 lines of code, easy to review. a [simple UI](https://github.com/pldubouilh/gossa-ui) comes as default, featuring : @@ -26,6 +26,8 @@ built blobs are available on the [release page](https://github.com/pldubouilh/go ### usage ```sh +% ./gossa --help + % ./gossa -h 192.168.100.33 ~/storage ``` diff --git a/src/gossa.go b/src/gossa.go index 568dfed..4fceb93 100755 --- a/src/gossa.go +++ b/src/gossa.go @@ -20,6 +20,8 @@ import ( var host = flag.String("h", "127.0.0.1", "host to listen to") var port = flag.String("p", "8001", "port to listen to") +var extraPath = flag.String("prefix", "/", "url prefix at which gossa can be reached, e.g. /gossa/ (slashes of importance)") +var symlinks = flag.Bool("symlinks", false, "follow symlinks \033[4mWARNING\033[0m: symlinks will by nature allow to escape the defined path (default: false)") var verb = flag.Bool("verb", true, "verbosity") var skipHidden = flag.Bool("k", true, "skip hidden files") var initPath = "." @@ -36,6 +38,7 @@ type rowTemplate struct { type pageTemplate struct { Title template.HTML + ExtraPath template.HTML RowsFiles []rowTemplate RowsFolders []rowTemplate } @@ -51,135 +54,122 @@ func check(e error) { } } -func logVerb(s ...interface{}) { - if *verb { +func exitPath(w http.ResponseWriter, s ...interface{}) { + if r := recover(); r != nil { + log.Println("error", s, r) + w.Write([]byte("error")) + } else if *verb { log.Println(s...) } } -func sizeToString(bytes int64) string { - units := [9]string{"B", "k", "M", "G", "T", "P", "E", "Z", "Y"} +func humanize(bytes int64) string { b := float64(bytes) u := 0 for { if b < 1024 { - return strconv.FormatFloat(b, 'f', 1, 64) + units[u] + return strconv.FormatFloat(b, 'f', 1, 64) + [9]string{"B", "k", "M", "G", "T", "P", "E", "Z", "Y"}[u] } b = b / 1024 u++ } } -func replyList(w http.ResponseWriter, path string) { +func replyList(w http.ResponseWriter, fullPath string, path string) { + _files, err := ioutil.ReadDir(fullPath) + check(err) + if !strings.HasSuffix(path, "/") { path += "/" } - _files, err := ioutil.ReadDir(initPath + path) - check(err) - + title := "/" + strings.TrimPrefix(path, *extraPath) p := pageTemplate{} - if path != "/" { + if path != *extraPath { p.RowsFolders = append(p.RowsFolders, rowTemplate{"../", "../", "", "folder"}) } + p.ExtraPath = template.HTML(html.EscapeString(*extraPath)) + p.Title = template.HTML(html.EscapeString(title)) for _, el := range _files { - name := el.Name() - href := url.PathEscape(name) - if *skipHidden && strings.HasPrefix(name, ".") { + if *skipHidden && strings.HasPrefix(el.Name(), ".") { continue } + el, _ = os.Stat(fullPath + "/" + el.Name()) + href := url.PathEscape(el.Name()) if el.IsDir() && strings.HasPrefix(href, "/") { href = strings.Replace(href, "/", "", 1) } if el.IsDir() { - p.RowsFolders = append(p.RowsFolders, rowTemplate{name + "/", template.HTML(href), "", "folder"}) + p.RowsFolders = append(p.RowsFolders, rowTemplate{el.Name() + "/", template.HTML(href), "", "folder"}) } else { - sl := strings.Split(name, ".") + sl := strings.Split(el.Name(), ".") ext := strings.ToLower(sl[len(sl)-1]) - p.RowsFiles = append(p.RowsFiles, rowTemplate{name, template.HTML(href), sizeToString(el.Size()), ext}) + p.RowsFiles = append(p.RowsFiles, rowTemplate{el.Name(), template.HTML(href), humanize(el.Size()), ext}) } } - p.Title = template.HTML(html.EscapeString(path)) page.Execute(w, p) } func doContent(w http.ResponseWriter, r *http.Request) { - path := html.UnescapeString(r.URL.Path) - fullPath, errPath := checkPath(path) - stat, errStat := os.Stat(fullPath) - - if errStat != nil || errPath != nil { - logVerb("Error", errStat, errPath) - w.Write([]byte("error")) + if !strings.HasPrefix(r.URL.Path, *extraPath) { + http.Redirect(w, r, *extraPath, 302) return } + path := html.UnescapeString(r.URL.Path) + defer exitPath(w, "get content", path) + fullPath := checkPath(path) + stat, errStat := os.Stat(fullPath) + check(errStat) + if stat.IsDir() { - logVerb("Get list", fullPath) - replyList(w, path) + replyList(w, fullPath, path) } else { - logVerb("Get file", fullPath) fs.ServeHTTP(w, r) } } func upload(w http.ResponseWriter, r *http.Request) { - unescaped, _ := url.PathUnescape(r.Header.Get("gossa-path")) - fullPath, err := checkPath(unescaped) - - logVerb("Up", err, fullPath) - if err != nil { - w.Write([]byte("error")) - return - } - + path, _ := url.PathUnescape(r.Header.Get("gossa-path")) + defer exitPath(w, "upload", path) reader, _ := r.MultipartReader() part, _ := reader.NextPart() - dst, _ := os.Create(fullPath) + dst, _ := os.Create(checkPath(path)) io.Copy(dst, part) - logVerb("Done upping", fullPath) w.Write([]byte("ok")) } func rpc(w http.ResponseWriter, r *http.Request) { var err error + var rpc rpcCall bodyBytes, _ := ioutil.ReadAll(r.Body) - bodyString := string(bodyBytes) - var payload rpcCall - json.Unmarshal([]byte(bodyString), &payload) - - for i := range payload.Args { - payload.Args[i], err = checkPath(payload.Args[i]) - if err != nil { - logVerb("Cant read path", err, payload) - w.Write([]byte("error")) - return - } - } - - if payload.Call == "mkdirp" { - err = os.MkdirAll(payload.Args[0], os.ModePerm) - } else if payload.Call == "mv" { - err = os.Rename(payload.Args[0], payload.Args[1]) - } else if payload.Call == "rm" { - err = os.RemoveAll(payload.Args[0]) + json.Unmarshal(bodyBytes, &rpc) + defer exitPath(w, "rpc", rpc) + + if rpc.Call == "mkdirp" { + err = os.MkdirAll(checkPath(rpc.Args[0]), os.ModePerm) + } else if rpc.Call == "mv" { + err = os.Rename(checkPath(rpc.Args[0]), checkPath(rpc.Args[1])) + } else if rpc.Call == "rm" { + err = os.RemoveAll(checkPath(rpc.Args[0])) } - logVerb("RPC", err, payload) + check(err) w.Write([]byte("ok")) } -func checkPath(p string) (string, error) { - p = filepath.Join(initPath, p) +func checkPath(p string) string { + p = filepath.Join(initPath, strings.TrimPrefix(p, *extraPath)) fp, err := filepath.Abs(p) + sl, _ := filepath.EvalSymlinks(fp) - if err != nil || !strings.HasPrefix(fp, initPath) { - return "", errors.New("error") + if err != nil || !strings.HasPrefix(fp, initPath) || len(sl) > 0 && !*symlinks && !strings.HasPrefix(sl, initPath) { + panic(errors.New("invalid path")) } - return fp, nil + return fp } func main() { @@ -194,14 +184,12 @@ func main() { hostString := *host + ":" + *port fmt.Println("Gossa startig on directory " + initPath) - fmt.Println("Listening on http://" + hostString) - - root := http.Dir(initPath) - fs = http.StripPrefix("/", http.FileServer(root)) + fmt.Println("Listening on http://" + hostString + *extraPath) - http.HandleFunc("/rpc", rpc) - http.HandleFunc("/post", upload) + http.HandleFunc(*extraPath+"rpc", rpc) + http.HandleFunc(*extraPath+"post", upload) http.HandleFunc("/", doContent) + fs = http.StripPrefix(*extraPath, http.FileServer(http.Dir(initPath))) err = http.ListenAndServe(hostString, nil) check(err) } diff --git a/src/gossa_test.go b/src/gossa_test.go index 0182fbd..8847794 100644 --- a/src/gossa_test.go +++ b/src/gossa_test.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" "testing" + "time" ) func dieMaybe(t *testing.T, err error) { @@ -29,10 +30,10 @@ func get(t *testing.T, url string) string { return trimSpaces(string(body)) } -func postDummyFile(t *testing.T, path string, payload string) string { +func postDummyFile(t *testing.T, url string, path string, payload string) string { // Generated by curl-to-Go: https://mholt.github.io/curl-to-go body := strings.NewReader("------WebKitFormBoundarycCRIderiXxJWEUcU\r\nContent-Disposition: form-data; name=\"\u1112\u1161 \u1112\u1161\"; filename=\"\u1112\u1161 \u1112\u1161\"\r\nContent-Type: application/octet-stream\r\n\r\n" + payload) - req, err := http.NewRequest("POST", "http://127.0.0.1:8001/post", body) + req, err := http.NewRequest("POST", url+"post", body) dieMaybe(t, err) req.Header.Set("Content-Type", "multipart/form-data; boundary=----WebKitFormBoundarycCRIderiXxJWEUcU") req.Header.Set("Gossa-Path", path) @@ -83,54 +84,56 @@ func fetchAndTestDefault(t *testing.T, url string) string { return bodyStr } -func TestGetFolder(t *testing.T) { +func doTest(t *testing.T, url string, symlinkEnabled bool) { payload := "" path := "" bodyStr := "" // ~~~~~~~~~~~~~~~~~ fmt.Println("\r\n~~~~~~~~~~ test fetching default path") - fetchAndTestDefault(t, "http://127.0.0.1:8001/") + fetchAndTestDefault(t, url) // ~~~~~~~~~~~~~~~~~ fmt.Println("\r\n~~~~~~~~~~ test fetching an invalid path - redirected to root") - fetchAndTestDefault(t, "http://127.0.0.1:8001/../../") - fetchAndTestDefault(t, "http://127.0.0.1:8001/hols/../../") + fetchAndTestDefault(t, url+"../../") + fetchAndTestDefault(t, url+"hols/../../") // ~~~~~~~~~~~~~~~~~ - fmt.Println("\r\n~~~~~~~~~~ test fetching a regular file") - bodyStr = get(t, "http://127.0.0.1:8001/subdir_with%20space/file_with%20space.html") - if !strings.Contains(bodyStr, `spacious!!`) { + fmt.Println("\r\n~~~~~~~~~~ test fetching regular files") + bodyStr = get(t, url+"subdir_with%20space/file_with%20space.html") + bodyStr2 := get(t, url+"fancy-path/a") + fmt.Println(bodyStr2) + if !strings.Contains(bodyStr, `spacious!!`) || !strings.Contains(bodyStr2, `fancy!`) { t.Fatal("fetching a regular file errored") } // ~~~~~~~~~~~~~~~~~ fmt.Println("\r\n~~~~~~~~~~ test fetching a invalid file") - bodyStr = get(t, "http://127.0.0.1:8001/../../../../../../../../../../etc/passwd") + bodyStr = get(t, url+"../../../../../../../../../../etc/passwd") if !strings.Contains(bodyStr, `error`) { t.Fatal("fetching a invalid file didnt errored") } // ~~~~~~~~~~~~~~~~~ fmt.Println("\r\n~~~~~~~~~~ test mkdir rpc") - bodyStr = postJSON(t, "http://127.0.0.1:8001/rpc", `{"call":"mkdirp","args":["/AAA"]}`) + bodyStr = postJSON(t, url+"rpc", `{"call":"mkdirp","args":["/AAA"]}`) if !strings.Contains(bodyStr, `ok`) { t.Fatal("mkdir rpc errored") } - bodyStr = fetchAndTestDefault(t, "http://127.0.0.1:8001/") + bodyStr = fetchAndTestDefault(t, url) if !strings.Contains(bodyStr, `href="AAA">AAA/`) { t.Fatal("mkdir rpc folder not created") } // ~~~~~~~~~~~~~~~~~ fmt.Println("\r\n~~~~~~~~~~ test invalid mkdir rpc") - bodyStr = postJSON(t, "http://127.0.0.1:8001/rpc", `{"call":"mkdirp","args":["../BBB"]}`) + bodyStr = postJSON(t, url+"rpc", `{"call":"mkdirp","args":["../BBB"]}`) if !strings.Contains(bodyStr, `error`) { t.Fatal("invalid mkdir rpc didnt errored #0") } - bodyStr = postJSON(t, "http://127.0.0.1:8001/rpc", `{"call":"mkdirp","args":["/../BBB"]}`) + bodyStr = postJSON(t, url+"rpc", `{"call":"mkdirp","args":["/../BBB"]}`) if !strings.Contains(bodyStr, `error`) { t.Fatal("invalid mkdir rpc didnt errored #1") } @@ -139,36 +142,36 @@ func TestGetFolder(t *testing.T) { fmt.Println("\r\n~~~~~~~~~~ test post file") path = "%2F%E1%84%92%E1%85%A1%20%E1%84%92%E1%85%A1" // "하 하" encoded payload = "123 하" - bodyStr = postDummyFile(t, path, payload) + bodyStr = postDummyFile(t, url, path, payload) if !strings.Contains(bodyStr, `ok`) { t.Fatal("post file errored") } - bodyStr = get(t, "http://127.0.0.1:8001/"+path) + bodyStr = get(t, url+path) if !strings.Contains(bodyStr, payload) { t.Fatal("post file errored reaching new file") } - bodyStr = fetchAndTestDefault(t, "http://127.0.0.1:8001/") + bodyStr = fetchAndTestDefault(t, url) if !strings.Contains(bodyStr, `href="%E1%84%92%E1%85%A1%20%E1%84%92%E1%85%A1">하 하`) { t.Fatal("post file errored checking new file row") } // ~~~~~~~~~~~~~~~~~ fmt.Println("\r\n~~~~~~~~~~ test post file incorrect path") - bodyStr = postDummyFile(t, "%2E%2E"+path, payload) + bodyStr = postDummyFile(t, url, "%2E%2E"+path, payload) if !strings.Contains(bodyStr, `err`) { t.Fatal("post file incorrect path didnt errored") } // ~~~~~~~~~~~~~~~~~ fmt.Println("\r\n~~~~~~~~~~ test mv rpc") - bodyStr = postJSON(t, "http://127.0.0.1:8001/rpc", `{"call":"mv","args":["/AAA", "/hols/AAA"]}`) + bodyStr = postJSON(t, url+"rpc", `{"call":"mv","args":["/AAA", "/hols/AAA"]}`) if !strings.Contains(bodyStr, `ok`) { t.Fatal("mv rpc errored") } - bodyStr = fetchAndTestDefault(t, "http://127.0.0.1:8001/") + bodyStr = fetchAndTestDefault(t, url) if strings.Contains(bodyStr, `href="AAA">AAA/ `) { t.Fatal("mv rpc folder not moved") } @@ -176,30 +179,71 @@ func TestGetFolder(t *testing.T) { // ~~~~~~~~~~~~~~~~~ fmt.Println("\r\n~~~~~~~~~~ test upload in new folder") payload = "abcdef1234" - bodyStr = postDummyFile(t, "%2Fhols%2FAAA%2Fabcdef", payload) + bodyStr = postDummyFile(t, url, "%2Fhols%2FAAA%2Fabcdef", payload) if strings.Contains(bodyStr, `err`) { t.Fatal("upload in new folder errored") } - bodyStr = get(t, "http://127.0.0.1:8001/hols/AAA/abcdef") + bodyStr = get(t, url+"hols/AAA/abcdef") if !strings.Contains(bodyStr, payload) { t.Fatal("upload in new folder error reaching new file") } + // ~~~~~~~~~~~~~~~~~ + fmt.Println("\r\n~~~~~~~~~~ test symlink, should succeed: ", symlinkEnabled) + bodyStr = get(t, url+"/docker/readme.md") + hasReadme := strings.Contains(bodyStr, `the master branch is automatically built and pushed`) + if !symlinkEnabled && hasReadme { + t.Fatal("error symlink reached where illegal") + } else if symlinkEnabled && !hasReadme { + t.Fatal("error symlink unreachable") + } + + if symlinkEnabled { + fmt.Println("\r\n~~~~~~~~~~ test symlink mkdir") + bodyStr = postJSON(t, url+"rpc", `{"call":"mkdirp","args":["/docker/testfolder"]}`) + if !strings.Contains(bodyStr, `ok`) { + t.Fatal("error symlink mkdir") + } + } + // ~~~~~~~~~~~~~~~~~ fmt.Println("\r\n~~~~~~~~~~ test rm rpc & cleanup") - bodyStr = postJSON(t, "http://127.0.0.1:8001/rpc", `{"call":"rm","args":["/hols/AAA"]}`) + bodyStr = postJSON(t, url+"rpc", `{"call":"rm","args":["/hols/AAA"]}`) if !strings.Contains(bodyStr, `ok`) { t.Fatal("cleanup errored #0") } - bodyStr = get(t, "http://127.0.0.1:8001/hols/AAA") + bodyStr = get(t, url+"hols/AAA") if !strings.Contains(bodyStr, `error`) { t.Fatal("cleanup errored #1") } - bodyStr = postJSON(t, "http://127.0.0.1:8001/rpc", `{"call":"rm","args":["/하 하"]}`) + bodyStr = postJSON(t, url+"rpc", `{"call":"rm","args":["/하 하"]}`) if !strings.Contains(bodyStr, `ok`) { t.Fatal("cleanup errored #2") } + + if symlinkEnabled { + bodyStr = postJSON(t, url+"rpc", `{"call":"rm","args":["/docker/testfolder"]}`) + if !strings.Contains(bodyStr, `ok`) { + t.Fatal("error symlink rm") + } + } +} + +func TestGetFolder(t *testing.T) { + time.Sleep(6 * time.Second) + fmt.Println("========== testing normal path ============") + url := "http://127.0.0.1:8001/" + doTest(t, url, false) + + fmt.Printf("\r\n=========\r\n") + time.Sleep(10 * time.Second) + + url = "http://127.0.0.1:8001/fancy-path/" + fmt.Println("========== testing at fancy path ============") + doTest(t, url, true) + + fmt.Printf("\r\n=========\r\n") } diff --git a/test-fixture/fancy-path/a b/test-fixture/fancy-path/a new file mode 100644 index 0000000..e04498f --- /dev/null +++ b/test-fixture/fancy-path/a @@ -0,0 +1 @@ +fancy!