Remark42: Cross-Site Scripting (XSS) on /api/v1/img via content-type spoofing
Date: 2026-06
Affected: go/github.com/umputun/remark42
Attack vector: xss
Category: vulnerability-disclosure
Severity: High
CVE: CVE-2026-48788
CWE: CWE-436
CWE-79
CVSS: 8.2 (CVSS:3.0/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:N)

### Summary The remark42 image proxy fetches an arbitrary remote URL and re-serves the response from remark42's own origin. The download path decides whether the fetched resource is an image by looking only at the `Content-Type` header the remote server claims — it never inspects the actual bytes. The serving path then derives the response `Content-Type` by sniffing those bytes with `http.DetectContentType`. An attacker hosts a URL that sets `Content-Type` to `image/png` but returns an HTML/JavaScript body: * the download check sees `image/png` → accepts it; * the serve path sniffs the body → emits `Content-Type: text/html`; * the browser renders attacker HTML/JS as a document in remark42's origin. ### Details #### Downloader `backend/app/rest/proxy/image.go` — `downloadImage()`, lines 189-206: ```go contentType := resp.Header.Get("Content-Type") if !strings.HasPrefix(contentType, "image/") { return nil, fmt.Errorf("invalid content type %s", contentType) } maxSize := 5 * 1024 * 1024 // 5MB default if p.ImageService != nil && p.ImageService.MaxSize > 0 { maxSize = p.ImageService.MaxSize } lr := io.LimitReader(resp.Body, int64(maxSize)+1) imgData, err := io.ReadAll(lr) if err != nil { return nil, fmt.Errorf("unable to read image body: %w", err) } if len(imgData) > maxSize { return nil, fmt.Errorf("image is too large") } return imgData, nil // <-- bytes never validated, returned as-is ``` Send `Content-Type: image/png` and the check passes regardl


Mitigations:

Architectural layers (MAESTRO):