trilium/apps/server/src/services/html_sanitizer.ts
2025-10-06 20:04:07 +03:00

88 lines
3.2 KiB
TypeScript

import sanitizeHtml from "sanitize-html";
import { sanitizeUrl } from "@braintree/sanitize-url";
import optionService from "./options.js";
import { ALLOWED_PROTOCOLS, SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons";
// intended mainly as protection against XSS via import
// secondarily, it (partly) protects against "CSS takeover"
// sanitize also note titles, label values etc. - there are so many usages which make it difficult
// to guarantee all of them are properly handled
function sanitize(dirtyHtml: string) {
if (!dirtyHtml) {
return dirtyHtml;
}
// avoid H1 per https://github.com/zadam/trilium/issues/1552
// demote H1, and if that conflicts with existing H2, demote that, etc
const transformTags: Record<string, string> = {};
const lowercasedHtml = dirtyHtml.toLowerCase();
for (let i = 1; i < 6; ++i) {
if (lowercasedHtml.includes(`<h${i}`)) {
transformTags[`h${i}`] = `h${i + 1}`;
} else {
break;
}
}
// Get allowed tags from options, with fallback to default list if option not yet set
let allowedTags;
try {
allowedTags = JSON.parse(optionService.getOption("allowedHtmlTags"));
} catch (e) {
// Fallback to default list if option doesn't exist or is invalid
allowedTags = SANITIZER_DEFAULT_ALLOWED_TAGS;
}
const colorRegex = [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/, /^hsl\(\s*(\d{1,3})\s*,\s*(\d{1,3})%\s*,\s*(\d{1,3})%\s*\)$/];
const sizeRegex = [/^\d+\.?\d*(?:px|em|%)$/];
// to minimize document changes, compress H
return sanitizeHtml(dirtyHtml, {
allowedTags,
allowedAttributes: {
"*": ["class", "style", "title", "src", "href", "hash", "disabled", "align", "alt", "center", "data-*"],
input: ["type", "checked"],
img: ["width", "height"]
},
allowedStyles: {
"*": {
color: colorRegex,
"background-color": colorRegex
},
figure: {
float: [/^\s*(left|right|none)\s*$/],
width: sizeRegex,
height: sizeRegex
},
img: {
"aspect-ratio": [ /^\d+\/\d+$/ ],
width: sizeRegex,
height: sizeRegex
},
table: {
"border-color": colorRegex,
"border-style": [/^\s*(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)\s*$/]
},
td: {
border: [
/^\s*\d+(?:px|em|%)\s*(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)\s*(#(0x)?[0-9a-fA-F]+|rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)|hsl\(\s*(\d{1,3})\s*,\s*(\d{1,3})%\s*,\s*(\d{1,3})%\))\s*$/
]
},
col: {
width: sizeRegex
}
},
selfClosing: [ "img", "br", "hr", "area", "base", "basefont", "input", "link", "meta", "col" ],
allowedSchemes: ALLOWED_PROTOCOLS,
nonTextTags: ["head"],
transformTags
});
}
export default {
sanitize,
sanitizeUrl: (url: string) => {
return sanitizeUrl(url).trim();
}
};