From aab4f12fc4813234511fa4dd7d6734e6ab97f4bf Mon Sep 17 00:00:00 2001 From: egutierrez Date: Wed, 27 May 2026 11:04:20 +0200 Subject: [PATCH] fix(0128): XSS scheme allowlist + drop dead fileID review findings: - MessageBody: only http(s) and relative paths allowed for links; data:image/* allowed for inline images. Rejects javascript:, data:text/html, vbscript: which would execute via . Unsafe matches fall back to plain text. - files.go: remove unused fileID var generated then discarded. --- backend/files.go | 5 ---- frontend/src/components/MessageBody.tsx | 32 +++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/backend/files.go b/backend/files.go index 015adef..fbc069e 100644 --- a/backend/files.go +++ b/backend/files.go @@ -197,7 +197,6 @@ func handleUploadCardFile(db *DB, workdir string) http.HandlerFunc { } fname := safeFilename(header.Filename) - fileID := newID() storedPath := filepath.Join(dir, randomFilePrefix()+"__"+fname) out, err := os.Create(storedPath) @@ -237,16 +236,12 @@ func handleUploadCardFile(db *DB, workdir string) http.HandlerFunc { actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) - // Use the random-prefixed path on disk but a stable file id in the DB. cf, err := db.CreateCardFile(cardID, actor, fname, mimeType, storedPath, source, written) if err != nil { os.Remove(storedPath) serverError(w, err) return } - // We generated newID() in CreateCardFile; align the on-disk filename with that id - // is not required since stored_path is what we serve from. - _ = fileID infra.HTTPJSONResponse(w, http.StatusCreated, cf) } diff --git a/frontend/src/components/MessageBody.tsx b/frontend/src/components/MessageBody.tsx index a2154f8..d82a750 100644 --- a/frontend/src/components/MessageBody.tsx +++ b/frontend/src/components/MessageBody.tsx @@ -14,6 +14,24 @@ type Token = ImgToken | LinkToken | TextToken; const TOKEN_RE = /(!\[([^\]\n]*)\]\(([^)\s]+)\))|(\[([^\]\n]+)\]\(([^)\s]+)\))/g; +// Allow only safe URL schemes. Reject javascript:, data:text/html, vbscript:, etc. +// Accepts: absolute http(s), protocol-relative //, and same-origin paths (/...). +function safeURL(url: string): string | null { + const u = url.trim(); + if (u.startsWith("/")) return u; + if (/^https?:\/\//i.test(u)) return u; + return null; +} + +// data: scheme is allowed only when the MIME prefix is image/. +function safeImageURL(url: string): string | null { + const safe = safeURL(url); + if (safe) return safe; + const u = url.trim(); + if (/^data:image\/[a-z0-9.+-]+(;[a-z0-9-]+=[^,]+)*;base64,/i.test(u)) return u; + return null; +} + function tokenize(input: string): Token[] { const out: Token[] = []; let last = 0; @@ -24,9 +42,19 @@ function tokenize(input: string): Token[] { out.push({ kind: "text", value: input.slice(last, m.index) }); } if (m[1]) { - out.push({ kind: "img", alt: m[2] || "", url: m[3] }); + const url = safeImageURL(m[3]); + if (url) { + out.push({ kind: "img", alt: m[2] || "", url }); + } else { + out.push({ kind: "text", value: m[0] }); + } } else if (m[4]) { - out.push({ kind: "link", label: m[5], url: m[6] }); + const url = safeURL(m[6]); + if (url) { + out.push({ kind: "link", label: m[5], url }); + } else { + out.push({ kind: "text", value: m[0] }); + } } last = TOKEN_RE.lastIndex; }