Skip to content

Fix stored XSS via uploaded filename#10

Open
thistehneisen wants to merge 1 commit into
cert-lv:masterfrom
thistehneisen:fix/filename-validation
Open

Fix stored XSS via uploaded filename#10
thistehneisen wants to merge 1 commit into
cert-lv:masterfrom
thistehneisen:fix/filename-validation

Conversation

@thistehneisen

Copy link
Copy Markdown

Fix stored XSS via uploaded filename

Reported by: OffSeq — Nils Putniņš <npu@offseq.com>
Affected version: v2.6.1 (a2a794ef6916d16da3fcc2f9f8d86225482e7263)

What this PR fixes

The original filename submitted to /upload (multipart Content-Disposition
filename=) flows verbatim through MongoDB and back to every viewer over
WebSocket ("uploaded", "upload-processed", "upload-lists" messages,
plus the notification text built server-side at upload.go:527).

The Web GUI renders these via innerHTML in
assets/js/notifications.js:73,159 and assets/js/user-actions.js:175,211,
which executes any HTML/JS embedded in the filename.

A filename like

<img src=x onerror=alert(1)>.txt

is persisted to MongoDB (users.uploads.in) and executes in every user
that opens their profile / receives the notification (including admins).

The fix

  • path/filepath.Base(header.Filename) strips any path components from
    the user-supplied basename.
  • The remaining string is matched against a strict allow-list
    (^[A-Za-z0-9 ._-]{1,200}$). Anything else is rejected up front with
    HTTP 200 + a descriptive error body, exactly like the other failure
    paths in uploadHandler.

This kills the XSS vector at the source — no front-end change is
needed because nothing dangerous reaches the rendered string anymore.
It also incidentally hardens against path traversal (defence-in-depth
on top of mime/multipart since Go 1.17) and NUL / control-character
injection.

Single file changed, +29 lines.

Repro (pre-patch behaviour)

read USER PASS JAR < /tmp/poc/last_creds
# Hold a WS open so the user is in online[]
python3 pocs/_ws_keepalive.py "wss://127.0.0.1:8443/ws" \
    "$(awk '/sessionID/ {print $7}' "$JAR")" 10 >/dev/null 2>&1 &

printf 'x\n' > /tmp/p.txt
curl -sk -b "$JAR" "https://127.0.0.1:8443/upload" \
  -F "source=demo" -F "format=json" -F "field=name" \
  -F "startTime=2024-01-01T00:00:00.000Z" \
  -F "endTime=2030-01-01T00:00:00.000Z" \
  -F "file=@/tmp/p.txt;filename=<img src=x onerror=alert(1)>.txt"

mongosh --quiet graphoscope --eval \
   "printjson(db.users.findOne({username:'$USER'}, {uploads:1}))"
# -> uploads.in contains  "...-<img src=x onerror=alert(1)>.txt"

On the Web GUI, opening the profile page renders the payload in the
download list via innerHTMLalert(1) fires.

Post-patch behaviour

Same request returns HTTP 200 with body:

Invalid filename: only letters, digits, space, '.', '_', '-' allowed (max 200 chars)

…and nothing is written to upload/queue/, MongoDB, or any other state.
Verified locally; standard filenames (indicators.txt, IOCs - 2026-05.csv)
still upload normally end-to-end.

Trade-off note

The 200-char / [A-Za-z0-9 ._-] allow-list is intentionally strict. If
operators need to permit additional characters (parentheses, commas,
unicode), the regex is a single line to relax. We chose the conservative
default because the audit found multiple .innerHTML / .html() sinks
across the front-end; a strict source-side filter is the most defensible
position until those sinks are individually audited.

Out of scope (separate work)

  • Replacing innerHTML/.html() with textContent/.text() (or
    DOM construction) everywhere user-controllable strings are rendered
    — defence-in-depth on top of this PR.
  • HTML-escaping the filename when the server builds notification
    messages that mix HTML + user data (upload.go:527, etc.).

Nils Putniņš / npu[at]offseq[dot]com / OffSeq
https://offseq.com / https://radar.offseq.com

`header.Filename` from /upload flows into the front-end via WebSocket
("uploaded" / "upload-processed" / "upload-lists" messages) and is
rendered with `innerHTML` in `assets/js/notifications.js` and
`assets/js/user-actions.js`. A filename like

    <img src=x onerror=alert(1)>.txt

is persisted to MongoDB and executes JavaScript in every user that
receives it (including admins, since the broadcast / notifications
path includes them).

This patch applies `filepath.Base()` and validates the basename
against `^[A-Za-z0-9 ._-]{1,200}$`. Uploads with unsafe characters are
rejected with HTTP 200 + an explanatory message at the same response
surface as the existing error paths.

This also incidentally hardens against path-traversal (defence in
depth — Go ≥1.17 `mime/multipart` already applies filepath.Base, but
relying on stdlib for security-critical input is fragile) and
NUL-byte / control-character injection.

Verified:
  * unsafe name (<img ...>) → rejected with the new message; nothing
    written to upload/queue/ or persisted in MongoDB users.uploads.in
  * safe name (normal-name.txt) → "ok", processed end-to-end, downloads
    link appears in the profile UI
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant