diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | README | 62 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | main.go | 453 | ||||
| -rw-r--r-- | main_test.go | 246 |
5 files changed, 765 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c89afc --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/xf @@ -0,0 +1,62 @@ +xf +== +xf (xfer) is a single-file HTTP file transfer server. Every directory under +the served root renders as one web page that lists its entries with download +links and provides a form to upload one or more files into that directory. + + +Build +----- +Build a binary with the Go toolchain: + go build -o xf . +Everything, including the web page, is embedded in main.go. There are no +third-party dependencies. + + +Run +--- +Serve the current directory on loopback port 8080: + ./xf +Serve a different directory or bind a different address: + ./xf -dir /srv/files -addr 192.0.2.10:9000 +Open the printed address in a browser. Uploads are streamed to a temporary +file in the target directory. All files in a request are staged before any are +renamed into place. Existing regular files are overwritten atomically and keep +their mode; new files are created mode 0600. + + +Options +------- + -addr listen address (default "127.0.0.1:8080") + -dir directory to serve (default ".") + -max-upload maximum bytes in one upload request (default 1073741824) + + +Debug +----- +The access log goes to standard output, one line per request: + 2026/07/02 10:14:38 get "/hello.txt" -> 200 (6 bytes) from 127.0.0.1 + 2026/07/02 10:14:39 upload "/sub/up.txt" (9 bytes) from 127.0.0.1 +Startup and error diagnostics go to standard error. To exercise the server +without a browser: + curl -F files=@somefile http://127.0.0.1:8080/ + curl -O http://127.0.0.1:8080/somefile + + +Security +-------- +xf has no authentication. It serves every file under the directory, including +hidden files, to anyone who can reach the address, and it accepts uploads from +anyone. The default listener is loopback; binding another address deliberately +exposes the server to that network. + +Filesystem access is confined to the served root. Relative symbolic links may +point elsewhere inside the root, but absolute links and links that escape it +are rejected. Upload requests are limited to 1 GiB by default and at most 1000 +multipart parts. The server does not limit upload duration or concurrent +requests. Run it only on systems and networks you trust. + +An interrupted process can leave a mode-0600 .xf-upload-* staging file. +These reserved names are neither listed nor served and can be removed when no +xf process is using the directory. A rare rename failure while committing a +multi-file request can leave an initial subset committed. @@ -0,0 +1,3 @@ +module xf + +go 1.25 @@ -0,0 +1,453 @@ +// Command xf (xfer) is a single-file HTTP file transfer server. Every +// directory under the served root renders as one embedded web page that lists +// its entries and accepts uploads into that directory; every file is served +// for download. +// +// Usage: +// +// xf [-addr address] [-dir directory] [-max-upload bytes] +package main + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "flag" + "fmt" + "html/template" + "io" + "log" + "net" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "sort" + "strings" + "syscall" + "time" +) + +const ( + defaultMaxUpload = 1 << 30 + maxUploadParts = 1000 + tempPrefix = ".xf-upload-" + + // readHeaderTimeout bounds a slow client without bounding the time it + // takes to stream a large upload body. + readHeaderTimeout = 10 * time.Second +) + +var page = template.Must(template.New("index").Parse(indexHTML)) + +// access logs downloads and uploads to standard output, kept separate from the +// startup and error diagnostics the default logger writes to standard error. +var access = log.New(os.Stdout, "", log.LstdFlags) + +type server struct { + root *os.Root + maxUpload int64 +} + +type stagedFile struct { + name string + temp string + size int64 +} + +// countedBody counts the bytes read from the request body. multipart can turn +// a MaxBytesReader error into a parse error, so the count, not the error, +// decides whether an upload was refused for its size. +type countedBody struct { + io.ReadCloser + bytes int64 +} + +func (body *countedBody) Read(p []byte) (int, error) { + n, err := body.ReadCloser.Read(p) + body.bytes += int64(n) + return n, err +} + +type entry struct { + Name string + Href string + Size string + IsDir bool +} + +type pageData struct { + Dir string // URL path of the listed directory + Parent bool // whether to link to the parent directory + Entries []entry +} + +func main() { + addr := flag.String("addr", "127.0.0.1:8080", "listen address") + dir := flag.String("dir", ".", "directory to serve") + maxUpload := flag.Int64("max-upload", defaultMaxUpload, + "maximum bytes in one upload request") + flag.Parse() + if *maxUpload <= 0 { + log.Fatal("xf: -max-upload must be greater than zero") + } + + rootName, err := filepath.Abs(*dir) + if err != nil { + log.Fatalf("xf: %v", err) + } + root, err := os.OpenRoot(rootName) + if err != nil { + log.Fatalf("xf: %v", err) + } + + s := &server{root: root, maxUpload: *maxUpload} + + mux := http.NewServeMux() + mux.HandleFunc("GET /", s.handleGet) + mux.HandleFunc("POST /", s.handleUpload) + + srv := &http.Server{ + Addr: *addr, + Handler: mux, + ReadHeaderTimeout: readHeaderTimeout, + } + + log.Printf("xf: serving %s on %s", rootName, *addr) + log.Fatalf("xf: %v", srv.ListenAndServe()) +} + +// resolve maps a request path to a path relative to the server root. +func (s *server) resolve(urlPath string) string { + name := strings.TrimPrefix(path.Clean("/"+urlPath), "/") + if name == "" { + return "." + } + return filepath.FromSlash(name) +} + +func (s *server) handleGet(w http.ResponseWriter, r *http.Request) { + rec := &recorder{ResponseWriter: w, status: http.StatusOK} + s.serve(rec, r) + access.Printf("get %q -> %d (%d bytes) from %s", + r.URL.Path, rec.status, rec.bytes, clientIP(r)) +} + +func (s *server) serve(w http.ResponseWriter, r *http.Request) { + name := s.resolve(r.URL.Path) + if isTempName(filepath.Base(name)) { + http.NotFound(w, r) + return + } + // O_NONBLOCK so that opening a FIFO cannot hang the handler; non-regular + // files are refused below. + f, err := s.root.OpenFile(name, os.O_RDONLY|syscall.O_NONBLOCK, 0) + switch { + case os.IsNotExist(err): + http.NotFound(w, r) + return + case os.IsPermission(err): + http.Error(w, "forbidden", http.StatusForbidden) + return + case err != nil: + // A path the root refuses, such as an escaping symlink or a + // component that is not a directory, is not a server fault. + log.Printf("xf: open %q: %v", r.URL.Path, err) + http.NotFound(w, r) + return + } + defer f.Close() + fi, err := f.Stat() + if err != nil { + log.Printf("xf: stat %q: %v", r.URL.Path, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if fi.IsDir() { + // Directory pages need a trailing slash so relative links work. + if !strings.HasSuffix(r.URL.Path, "/") { + http.Redirect(w, r, path.Clean(r.URL.Path)+"/", http.StatusMovedPermanently) + return + } + s.list(w, f, r.URL.Path) + return + } + if !fi.Mode().IsRegular() { + http.Error(w, "unsupported file type", http.StatusForbidden) + return + } + http.ServeContent(w, r, fi.Name(), fi.ModTime(), f) +} + +func (s *server) list(w http.ResponseWriter, dir *os.File, urlPath string) { + dirents, err := dir.ReadDir(-1) + if err != nil { + log.Printf("xf: list %q: %v", urlPath, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + sort.Slice(dirents, func(i, j int) bool { + if dirents[i].IsDir() != dirents[j].IsDir() { + return dirents[i].IsDir() + } + return dirents[i].Name() < dirents[j].Name() + }) + + entries := make([]entry, 0, len(dirents)) + for _, de := range dirents { + if isTempName(de.Name()) { + continue + } + e := entry{ + Name: de.Name(), + Href: (&url.URL{Path: de.Name()}).String(), + IsDir: de.IsDir(), + } + if de.IsDir() { + e.Href += "/" + e.Size = "-" + } else if fi, err := de.Info(); err == nil { + e.Size = humanSize(fi.Size()) + } else { + e.Size = "?" + } + entries = append(entries, e) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + data := pageData{Dir: urlPath, Parent: urlPath != "/", Entries: entries} + if err := page.Execute(w, data); err != nil { + log.Printf("xf: render: %v", err) + } +} + +// handleUpload stages every file in the request before renaming any into +// place, so a refused or interrupted upload leaves the directory untouched. +func (s *server) handleUpload(w http.ResponseWriter, r *http.Request) { + dir, err := s.root.OpenRoot(s.resolve(r.URL.Path)) + if err != nil { + http.Error(w, "not a directory", http.StatusNotFound) + return + } + defer dir.Close() + if r.ContentLength > s.maxUpload { + http.Error(w, "upload too large", http.StatusRequestEntityTooLarge) + return + } + body := &countedBody{ReadCloser: r.Body} + r.Body = http.MaxBytesReader(w, body, s.maxUpload) + mr, err := r.MultipartReader() + if err != nil { + http.Error(w, "bad upload: "+err.Error(), http.StatusBadRequest) + return + } + var files []stagedFile + defer func() { + for _, file := range files { + dir.Remove(file.temp) + } + }() + names := make(map[string]bool) + parts := 0 + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + writeUploadError(w, err, http.StatusBadRequest, body.bytes > s.maxUpload) + return + } + parts++ + if parts > maxUploadParts { + http.Error(w, "too many upload parts", http.StatusBadRequest) + return + } + if part.FileName() == "" { + continue // not a file part + } + // The client supplied name is reduced to its base component so an + // upload can never escape the directory. + name := filepath.Base(part.FileName()) + if name == "/" || name == "." || name == ".." || isTempName(name) { + http.Error(w, fmt.Sprintf("invalid filename: %q", part.FileName()), + http.StatusBadRequest) + return + } + if names[name] { + http.Error(w, fmt.Sprintf("duplicate filename: %q", name), + http.StatusBadRequest) + return + } + names[name] = true + file, err := stage(dir, name, part) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, io.ErrUnexpectedEOF) { + status = http.StatusBadRequest + } + writeUploadError(w, err, status, body.bytes > s.maxUpload) + return + } + files = append(files, file) + } + if len(files) == 0 { + http.Error(w, "no files in upload", http.StatusBadRequest) + return + } + for _, file := range files { + if err := dir.Rename(file.temp, file.name); err != nil { + log.Printf("xf: commit upload %q: %v", file.name, err) + http.Error(w, "upload failed", http.StatusInternalServerError) + return + } + access.Printf("upload %q (%d bytes) from %s", + path.Join(r.URL.Path, file.name), file.size, clientIP(r)) + } + http.Redirect(w, r, r.URL.Path, http.StatusSeeOther) +} + +// stage streams one upload into a temporary file in its target directory, so +// that the commit is an atomic rename. +func stage(dir *os.Root, name string, src io.Reader) (stagedFile, error) { + mode := os.FileMode(0o600) + fi, err := dir.Lstat(name) + switch { + case os.IsNotExist(err): + case err != nil: + return stagedFile{}, err + case !fi.Mode().IsRegular(): + return stagedFile{}, fmt.Errorf("refusing to replace non-regular file %q", name) + default: + mode = fi.Mode().Perm() + } + + var random [16]byte + if _, err := rand.Read(random[:]); err != nil { + return stagedFile{}, err + } + tmpName := tempPrefix + hex.EncodeToString(random[:]) + tmp, err := dir.OpenFile(tmpName, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return stagedFile{}, err + } + + n, err := io.Copy(tmp, src) + if err == nil { + err = tmp.Chmod(mode) + } + if cerr := tmp.Close(); err == nil { + err = cerr + } + if err != nil { + dir.Remove(tmpName) + return stagedFile{}, err + } + return stagedFile{name: name, temp: tmpName, size: n}, nil +} + +func isTempName(name string) bool { + if len(name) != len(tempPrefix)+32 || !strings.HasPrefix(name, tempPrefix) { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(name, tempPrefix)) + return err == nil +} + +func writeUploadError(w http.ResponseWriter, err error, status int, tooLarge bool) { + switch { + case tooLarge: + http.Error(w, "upload too large", http.StatusRequestEntityTooLarge) + case status == http.StatusInternalServerError: + log.Printf("xf: upload: %v", err) + http.Error(w, "upload failed", status) + default: + http.Error(w, "bad upload: "+err.Error(), status) + } +} + +// recorder captures the status and size of a response for the access log. +type recorder struct { + http.ResponseWriter + status int + bytes int64 +} + +func (rec *recorder) WriteHeader(code int) { + rec.status = code + rec.ResponseWriter.WriteHeader(code) +} + +func (rec *recorder) Write(p []byte) (int, error) { + n, err := rec.ResponseWriter.Write(p) + rec.bytes += int64(n) + return n, err +} + +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func humanSize(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for x := n / unit; x >= unit; x /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} + +const indexHTML = `<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>xf</title> +<style> +body { font-family: ui-monospace, monospace; max-width: 60rem; margin: 2rem auto; padding: 0 1rem; color: #111; } +h1 { font-size: 1rem; font-weight: normal; color: #555; word-break: break-all; } +form { margin: 1.5rem 0; padding: 1rem; border: 1px solid #ccc; } +table { width: 100%; border-collapse: collapse; } +td { text-align: left; padding: 0.3rem 0.5rem; border-bottom: 1px solid #eee; } +td.size { text-align: right; white-space: nowrap; color: #555; } +a { text-decoration: none; } +a:hover { text-decoration: underline; } +.empty { color: #999; } +</style> +</head> +<body> +<h1>xf: {{.Dir}}</h1> +<form method="post" enctype="multipart/form-data"> +<input type="file" name="files" multiple required> +<button type="submit">upload</button> +</form> +<table> +<tbody> +{{if .Parent}} +<tr> +<td><a href="../">../</a></td> +<td class="size">-</td> +</tr> +{{end}} +{{range .Entries}} +<tr> +<td><a href="{{.Href}}">{{.Name}}{{if .IsDir}}/{{end}}</a></td> +<td class="size">{{.Size}}</td> +</tr> +{{else}} +<tr><td class="empty" colspan="2">empty</td></tr> +{{end}} +</tbody> +</table> +</body> +</html> +` diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..b767695 --- /dev/null +++ b/main_test.go @@ -0,0 +1,246 @@ +package main + +import ( + "bytes" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +type testUpload struct { + name string + body string +} + +func TestRootConfinement(t *testing.T) { + rootDir := t.TempDir() + outsideDir := t.TempDir() + if err := os.WriteFile(filepath.Join(outsideDir, "secret"), []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outsideDir, filepath.Join(rootDir, "outside")); err != nil { + t.Fatal(err) + } + + s := newTestServer(t, rootDir, 1<<20) + rec := httptest.NewRecorder() + s.handleGet(rec, httptest.NewRequest(http.MethodGet, "/outside/secret", nil)) + if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "secret") { + t.Fatalf("external symlink served: status %d, body %q", rec.Code, rec.Body.String()) + } + + req := newUploadRequest(t, "/outside/", []testUpload{{"written", "data"}}) + rec = httptest.NewRecorder() + s.handleUpload(rec, req) + if rec.Code == http.StatusSeeOther { + t.Fatalf("upload through external symlink succeeded") + } + if _, err := os.Stat(filepath.Join(outsideDir, "written")); !os.IsNotExist(err) { + t.Fatalf("upload escaped root: %v", err) + } +} + +func TestInternalSymlink(t *testing.T) { + rootDir := t.TempDir() + if err := os.Mkdir(filepath.Join(rootDir, "data"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootDir, "data", "file"), []byte("content"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink("data", filepath.Join(rootDir, "internal")); err != nil { + t.Fatal(err) + } + + s := newTestServer(t, rootDir, 1<<20) + rec := httptest.NewRecorder() + s.handleGet(rec, httptest.NewRequest(http.MethodGet, "/internal/file", nil)) + if rec.Code != http.StatusOK || rec.Body.String() != "content" { + t.Fatalf("internal symlink failed: status %d, body %q", rec.Code, rec.Body.String()) + } +} + +func TestSpecialFileIsRejected(t *testing.T) { + rootDir := t.TempDir() + if err := syscall.Mkfifo(filepath.Join(rootDir, "fifo"), 0o600); err != nil { + t.Fatal(err) + } + + s := newTestServer(t, rootDir, 1<<20) + rec := httptest.NewRecorder() + s.handleGet(rec, httptest.NewRequest(http.MethodGet, "/fifo", nil)) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) + } +} + +func TestUploadLimitLeavesFilesUntouched(t *testing.T) { + rootDir := t.TempDir() + target := filepath.Join(rootDir, "file") + if err := os.WriteFile(target, []byte("old"), 0o640); err != nil { + t.Fatal(err) + } + if err := os.Chmod(target, 0o640); err != nil { + t.Fatal(err) + } + + s := newTestServer(t, rootDir, 128) + req := newUploadRequest(t, "/", []testUpload{{"file", strings.Repeat("x", 256)}}) + rec := httptest.NewRecorder() + s.handleUpload(rec, req) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge) + } + assertFile(t, target, "old", 0o640) + assertNoTemps(t, rootDir) + + req = newUploadRequest(t, "/", []testUpload{{"file", strings.Repeat("x", 256)}}) + req.ContentLength = -1 + rec = httptest.NewRecorder() + s.handleUpload(rec, req) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("chunked status = %d, want %d: %s", + rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String()) + } + assertFile(t, target, "old", 0o640) + assertNoTemps(t, rootDir) +} + +func TestMalformedUploadLeavesFilesUntouched(t *testing.T) { + rootDir := t.TempDir() + target := filepath.Join(rootDir, "first") + if err := os.WriteFile(target, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + + req := newUploadRequest(t, "/", []testUpload{ + {"first", "replacement"}, + {"second", "new"}, + }) + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + req.Body = io.NopCloser(bytes.NewReader(body[:len(body)-10])) + req.ContentLength = int64(len(body) - 10) + + s := newTestServer(t, rootDir, 1<<20) + rec := httptest.NewRecorder() + s.handleUpload(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + assertFile(t, target, "old", 0o600) + if _, err := os.Stat(filepath.Join(rootDir, "second")); !os.IsNotExist(err) { + t.Fatalf("second file committed: %v", err) + } + assertNoTemps(t, rootDir) +} + +func TestUploadModesAndTemporaryFiles(t *testing.T) { + rootDir := t.TempDir() + existing := filepath.Join(rootDir, "existing") + if err := os.WriteFile(existing, []byte("old"), 0o640); err != nil { + t.Fatal(err) + } + if err := os.Chmod(existing, 0o640); err != nil { + t.Fatal(err) + } + + s := newTestServer(t, rootDir, 1<<20) + req := newUploadRequest(t, "/", []testUpload{ + {"existing", "replacement"}, + {"new", "content"}, + }) + rec := httptest.NewRecorder() + s.handleUpload(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusSeeOther, rec.Body.String()) + } + assertFile(t, existing, "replacement", 0o640) + assertFile(t, filepath.Join(rootDir, "new"), "content", 0o600) + assertNoTemps(t, rootDir) + + tempName := tempPrefix + strings.Repeat("0", 32) + if err := os.WriteFile(filepath.Join(rootDir, tempName), []byte("partial"), 0o600); err != nil { + t.Fatal(err) + } + rec = httptest.NewRecorder() + s.handleGet(rec, httptest.NewRequest(http.MethodGet, "/"+tempName, nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("temporary file status = %d, want %d", rec.Code, http.StatusNotFound) + } + rec = httptest.NewRecorder() + s.handleGet(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if strings.Contains(rec.Body.String(), tempName) || strings.Contains(rec.Body.String(), rootDir) { + t.Fatalf("listing exposed private path or temporary file: %q", rec.Body.String()) + } +} + +func newTestServer(t *testing.T, dir string, maxUpload int64) *server { + t.Helper() + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { root.Close() }) + return &server{root: root, maxUpload: maxUpload} +} + +func newUploadRequest(t *testing.T, target string, files []testUpload) *http.Request { + t.Helper() + var body bytes.Buffer + w := multipart.NewWriter(&body) + for _, file := range files { + part, err := w.CreateFormFile("files", file.name) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(part, file.body); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body.Bytes())) + req.Header.Set("Content-Type", w.FormDataContentType()) + return req +} + +func assertFile(t *testing.T, name, content string, mode os.FileMode) { + t.Helper() + data, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + if string(data) != content { + t.Fatalf("%s contains %q, want %q", name, data, content) + } + fi, err := os.Stat(name) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != mode { + t.Fatalf("%s mode = %o, want %o", name, fi.Mode().Perm(), mode) + } +} + +func assertNoTemps(t *testing.T, dir string) { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if isTempName(entry.Name()) { + t.Fatalf("temporary file remains: %s", entry.Name()) + } + } +} |