diff --git a/apps/server/src/routes/api/image.ts b/apps/server/src/routes/api/image.ts index aa24e7aae9..988921bdfd 100644 --- a/apps/server/src/routes/api/image.ts +++ b/apps/server/src/routes/api/image.ts @@ -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 = ``; + let svgContent: string | Buffer = ``; 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)); +} diff --git a/apps/server/src/services/utils.ts b/apps/server/src/services/utils.ts index a2b707edf9..4acea02bdd 100644 --- a/apps/server/src/services/utils.ts +++ b/apps/server/src/services/utils.ts @@ -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(//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, diff --git a/apps/server/src/share/routes.ts b/apps/server/src/share/routes.ts index f164b2bf4b..41e57120c0 100644 --- a/apps/server/src/share/routes.ts +++ b/apps/server/src/share/routes.ts @@ -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(//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); }