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 <a href>.
  Unsafe matches fall back to plain text.
- files.go: remove unused fileID var generated then discarded.
This commit is contained in:
2026-05-27 11:04:20 +02:00
parent e86c93cb73
commit aab4f12fc4
2 changed files with 30 additions and 7 deletions
-5
View File
@@ -197,7 +197,6 @@ func handleUploadCardFile(db *DB, workdir string) http.HandlerFunc {
} }
fname := safeFilename(header.Filename) fname := safeFilename(header.Filename)
fileID := newID()
storedPath := filepath.Join(dir, randomFilePrefix()+"__"+fname) storedPath := filepath.Join(dir, randomFilePrefix()+"__"+fname)
out, err := os.Create(storedPath) out, err := os.Create(storedPath)
@@ -237,16 +236,12 @@ func handleUploadCardFile(db *DB, workdir string) http.HandlerFunc {
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) 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) cf, err := db.CreateCardFile(cardID, actor, fname, mimeType, storedPath, source, written)
if err != nil { if err != nil {
os.Remove(storedPath) os.Remove(storedPath)
serverError(w, err) serverError(w, err)
return 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) infra.HTTPJSONResponse(w, http.StatusCreated, cf)
} }
+30 -2
View File
@@ -14,6 +14,24 @@ type Token = ImgToken | LinkToken | TextToken;
const TOKEN_RE = /(!\[([^\]\n]*)\]\(([^)\s]+)\))|(\[([^\]\n]+)\]\(([^)\s]+)\))/g; 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[] { function tokenize(input: string): Token[] {
const out: Token[] = []; const out: Token[] = [];
let last = 0; let last = 0;
@@ -24,9 +42,19 @@ function tokenize(input: string): Token[] {
out.push({ kind: "text", value: input.slice(last, m.index) }); out.push({ kind: "text", value: input.slice(last, m.index) });
} }
if (m[1]) { 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]) { } 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; last = TOKEN_RE.lastIndex;
} }