feat(server): improve request handling for SVGs

This commit is contained in:
Elian Doran
2026-04-05 11:28:28 +03:00
parent 176de87b6b
commit 5c46209ddc
3 changed files with 46 additions and 27 deletions

View File

@@ -1,12 +1,14 @@
"use strict";
import imageService from "../../services/image.js";
import becca from "../../becca/becca.js";
import fs from "fs";
import type { Request, Response } from "express";
import fs from "fs";
import becca from "../../becca/becca.js";
import type BNote from "../../becca/entities/bnote.js";
import type BRevision from "../../becca/entities/brevision.js";
import imageService from "../../services/image.js";
import { RESOURCE_DIR } from "../../services/resource_dir.js";
import { sanitizeSvg } from "../../services/utils.js";
function returnImageFromNote(req: Request, res: Response) {
const image = becca.getNote(req.params.noteId);
@@ -37,28 +39,33 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
} else {
res.set("Content-Type", image.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(image.getContent());
if (image.mime === "image/svg+xml") {
sendSanitizedSvg(res, image.getContent());
} else {
res.send(image.getContent());
}
}
}
export function renderSvgAttachment(image: BNote | BRevision, res: Response, attachmentName: string) {
let svg: string | Buffer = `<svg xmlns="http://www.w3.org/2000/svg"></svg>`;
let svgContent: string | Buffer = `<svg xmlns="http://www.w3.org/2000/svg"></svg>`;
const attachment = image.getAttachmentByTitle(attachmentName);
if (attachment) {
svg = attachment.getContent();
svgContent = attachment.getContent();
} else {
// backwards compatibility, before attachments, the SVG was stored in the main note content as a separate key
const contentSvg = image.getJsonContentSafely()?.svg;
if (contentSvg) {
svg = contentSvg;
svgContent = contentSvg;
}
}
res.set("Content-Type", "image/svg+xml");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(svg);
sendSanitizedSvg(res, svgContent);
}
function returnAttachedImage(req: Request, res: Response) {
@@ -75,7 +82,12 @@ function returnAttachedImage(req: Request, res: Response) {
res.set("Content-Type", attachment.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(attachment.getContent());
if (attachment.mime === "image/svg+xml") {
sendSanitizedSvg(res, attachment.getContent());
} else {
res.send(attachment.getContent());
}
}
function updateImage(req: Request) {
@@ -116,3 +128,9 @@ export default {
returnAttachedImage,
updateImage
};
function sendSanitizedSvg(res: Response, content: string | Buffer) {
const svgString = typeof content === "string" ? content : content.toString("utf-8");
res.set("Content-Security-Policy", "script-src 'none'");
res.send(sanitizeSvg(svgString));
}

View File

@@ -119,6 +119,22 @@ export function sanitizeSqlIdentifier(str: string) {
return str.replace(/[^A-Za-z0-9_]/g, "");
}
/**
* Sanitize SVG to remove potentially dangerous elements and attributes.
* This prevents XSS via script injection in SVG content.
*/
export function sanitizeSvg(svg: string): string {
return svg
// Remove script elements
.replace(/<script[\s\S]*?<\/script>/gi, '')
// Remove on* event handlers (onclick, onload, onerror, etc.)
.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '')
.replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '')
// Remove javascript: URLs
.replace(/href\s*=\s*["']javascript:[^"']*["']/gi, 'href="#"')
.replace(/xlink:href\s*=\s*["']javascript:[^"']*["']/gi, 'xlink:href="#"');
}
export const escapeHtml = escape;
export const unescapeHtml = unescape;
@@ -556,6 +572,7 @@ export default {
replaceAll,
safeExtractMessageAndStackFromError,
sanitizeSqlIdentifier,
sanitizeSvg,
stripTags,
slugify,
timeLimit,

View File

@@ -9,7 +9,7 @@ import SearchContext from "../services/search/search_context.js";
import type SNote from "./shaca/entities/snote.js";
import type SAttachment from "./shaca/entities/sattachment.js";
import { getDefaultTemplatePath, renderNoteContent } from "./content_renderer.js";
import utils from "../services/utils.js";
import utils, { sanitizeSvg } from "../services/utils.js";
function addNoIndexHeader(note: SNote, res: Response) {
if (note.isLabelTruthy("shareDisallowRobotIndexing")) {
@@ -17,22 +17,6 @@ function addNoIndexHeader(note: SNote, res: Response) {
}
}
/**
* Sanitize SVG to remove potentially dangerous elements and attributes.
* This prevents XSS via script injection in SVG exports.
*/
function sanitizeSvg(svg: string): string {
return svg
// Remove script elements
.replace(/<script[\s\S]*?<\/script>/gi, '')
// Remove on* event handlers (onclick, onload, onerror, etc.)
.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '')
.replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '')
// Remove javascript: URLs
.replace(/href\s*=\s*["']javascript:[^"']*["']/gi, 'href="#"')
.replace(/xlink:href\s*=\s*["']javascript:[^"']*["']/gi, 'xlink:href="#"');
}
function requestCredentials(res: Response) {
res.setHeader("WWW-Authenticate", 'Basic realm="User Visible Realm", charset="UTF-8"').sendStatus(401);
}