This commit is contained in:
Stefan Hardegger
2025-07-24 16:25:23 +02:00
parent a38812877a
commit 12a8f2ee27
3 changed files with 111 additions and 38 deletions

View File

@@ -201,14 +201,14 @@ public class TypesenseService {
if (authorFilters != null && !authorFilters.isEmpty()) { if (authorFilters != null && !authorFilters.isEmpty()) {
String authorFilter = authorFilters.stream() String authorFilter = authorFilters.stream()
.map(author -> "authorName:=" + author) .map(author -> "authorName:=" + escapeTypesenseValue(author))
.collect(Collectors.joining(" || ")); .collect(Collectors.joining(" || "));
filterConditions.add("(" + authorFilter + ")"); filterConditions.add("(" + authorFilter + ")");
} }
if (tagFilters != null && !tagFilters.isEmpty()) { if (tagFilters != null && !tagFilters.isEmpty()) {
String tagFilter = tagFilters.stream() String tagFilter = tagFilters.stream()
.map(tag -> "tagNames:=" + tag) .map(tag -> "tagNames:=" + escapeTypesenseValue(tag))
.collect(Collectors.joining(" || ")); .collect(Collectors.joining(" || "));
filterConditions.add("(" + tagFilter + ")"); filterConditions.add("(" + tagFilter + ")");
} }
@@ -890,4 +890,31 @@ public class TypesenseService {
return Map.of("error", e.getMessage()); return Map.of("error", e.getMessage());
} }
} }
/**
* Escape special characters in Typesense filter values.
* Typesense requires certain characters to be escaped or quoted.
*/
private String escapeTypesenseValue(String value) {
if (value == null) {
return "";
}
// If the value contains spaces, special characters, or quotes, wrap it in backticks
if (value.contains(" ") || value.contains("'") || value.contains("\"") ||
value.contains("(") || value.contains(")") || value.contains("[") ||
value.contains("]") || value.contains("{") || value.contains("}") ||
value.contains(":") || value.contains("=") || value.contains("&") ||
value.contains("|") || value.contains("!") || value.contains("<") ||
value.contains(">") || value.contains("@") || value.contains("#") ||
value.contains("$") || value.contains("%") || value.contains("^") ||
value.contains("*") || value.contains("+") || value.contains("?") ||
value.contains("\\") || value.contains("/") || value.contains("~") ||
value.contains("`")) {
// Escape backticks in the value and wrap with backticks
return "`" + value.replace("`", "\\`") + "`";
}
return value;
}
} }

View File

@@ -33,18 +33,6 @@ export default function RichTextEditor({
}); });
}, []); }, []);
const handleVisualChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const plainText = e.target.value;
// Convert plain text to basic HTML paragraphs
const htmlContent = plainText
.split('\n\n')
.filter(paragraph => paragraph.trim())
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
.join('\n');
onChange(htmlContent);
setHtmlValue(htmlContent);
};
const handleVisualContentChange = () => { const handleVisualContentChange = () => {
const div = visualDivRef.current; const div = visualDivRef.current;
@@ -198,16 +186,20 @@ export default function RichTextEditor({
// Split plain text into paragraphs and insert as HTML // Split plain text into paragraphs and insert as HTML
const paragraphs = plainText.split('\n\n').filter(p => p.trim()); const paragraphs = plainText.split('\n\n').filter(p => p.trim());
const fragment = document.createDocumentFragment();
paragraphs.forEach((paragraph, index) => { paragraphs.forEach((paragraph, index) => {
if (index > 0) { if (index > 0) {
// Add some spacing between paragraphs // Add some spacing between paragraphs
range.insertNode(document.createElement('br')); fragment.appendChild(document.createElement('br'));
} }
const p = document.createElement('p'); const p = document.createElement('p');
p.textContent = paragraph.replace(/\n/g, ' '); p.textContent = paragraph.replace(/\n/g, ' ');
range.insertNode(p); fragment.appendChild(p);
}); });
range.insertNode(fragment);
range.collapse(false); range.collapse(false);
selection.removeAllRanges(); selection.removeAllRanges();
selection.addRange(range); selection.addRange(range);

View File

@@ -12,6 +12,38 @@ interface SanitizationConfig {
let cachedConfig: SanitizationConfig | null = null; let cachedConfig: SanitizationConfig | null = null;
let configPromise: Promise<SanitizationConfig> | null = null; let configPromise: Promise<SanitizationConfig> | null = null;
/**
* Filter CSS properties in a style attribute value
*/
function filterCssProperties(styleValue: string, allowedProperties: string[]): string {
// Parse CSS declarations
const declarations = styleValue.split(';').map(decl => decl.trim()).filter(decl => decl);
const filteredDeclarations = declarations.filter(declaration => {
const colonIndex = declaration.indexOf(':');
if (colonIndex === -1) return false;
const property = declaration.substring(0, colonIndex).trim().toLowerCase();
const isAllowed = allowedProperties.includes(property);
if (!isAllowed) {
console.log(`CSS property "${property}" was filtered out (not in allowed list)`);
}
return isAllowed;
});
const result = filteredDeclarations.join('; ');
if (declarations.length !== filteredDeclarations.length) {
console.log(`CSS filtering: ${declarations.length} -> ${filteredDeclarations.length} properties`);
console.log('Original:', styleValue);
console.log('Filtered:', result);
}
return result;
}
/** /**
* Fetch sanitization configuration from backend * Fetch sanitization configuration from backend
*/ */
@@ -109,6 +141,23 @@ function createDOMPurifyConfig(config: SanitizationConfig) {
ALLOW_DATA_ATTR: false, ALLOW_DATA_ATTR: false,
}; };
// Clear any existing hooks and add CSS property filtering
DOMPurify.removeAllHooks();
DOMPurify.addHook('afterSanitizeAttributes', function (node) {
// Only process elements with style attributes
if (node.hasAttribute && node.hasAttribute('style')) {
const styleValue = node.getAttribute('style');
if (styleValue) {
const filteredStyle = filterCssProperties(styleValue, config.allowedCssProperties);
if (filteredStyle) {
node.setAttribute('style', filteredStyle);
} else {
node.removeAttribute('style');
}
}
}
});
return domPurifyConfig; return domPurifyConfig;
} }
@@ -163,40 +212,45 @@ export function sanitizeHtmlSync(html: string): string {
// Use comprehensive fallback configuration that preserves formatting // Use comprehensive fallback configuration that preserves formatting
console.log('Using fallback sanitization configuration with formatting support'); console.log('Using fallback sanitization configuration with formatting support');
const fallbackAllowedCssProperties = [
'color', 'font-size', 'font-weight',
'font-style', 'text-align', 'text-decoration', 'margin',
'padding', 'text-indent', 'line-height'
];
const fallbackConfig: DOMPurify.Config = { const fallbackConfig: DOMPurify.Config = {
ALLOWED_TAGS: [ ALLOWED_TAGS: [
// Basic block elements 'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'div', 'span', 'section', 'article',
// Headers
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
// Text formatting
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins', 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins',
'sup', 'sub', 'small', 'big', 'mark', 'abbr', 'dfn', 'sup', 'sub', 'small', 'big', 'mark', 'pre', 'code', 'kbd', 'samp', 'var',
// Code and preformatted 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a',
'pre', 'code', 'kbd', 'samp', 'var', 'tt',
// Lists
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
// Links (but href will be removed by backend config)
'a',
// Tables
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col',
// Quotes and misc 'blockquote', 'cite', 'q', 'hr', 'details', 'summary'
'blockquote', 'cite', 'q', 'hr', 'details', 'summary',
// Common website elements that might have formatting
'font', 'center'
], ],
ALLOWED_ATTR: [ ALLOWED_ATTR: [
'class', 'style', 'colspan', 'rowspan', 'align', 'valign', 'class', 'style', 'colspan', 'rowspan'
// Font attributes (though deprecated, websites still use them)
'color', 'size', 'face'
], ],
ALLOW_UNKNOWN_PROTOCOLS: false, ALLOW_UNKNOWN_PROTOCOLS: false,
SANITIZE_DOM: true, SANITIZE_DOM: true,
KEEP_CONTENT: true, KEEP_CONTENT: true,
ALLOW_DATA_ATTR: false, ALLOW_DATA_ATTR: false,
// Don't strip style attributes completely - let them through for basic formatting
FORBID_ATTR: [],
}; };
// Clear hooks and add CSS property filtering for fallback config
DOMPurify.removeAllHooks();
DOMPurify.addHook('afterSanitizeAttributes', function (node) {
if (node.hasAttribute && node.hasAttribute('style')) {
const styleValue = node.getAttribute('style');
if (styleValue) {
const filteredStyle = filterCssProperties(styleValue, fallbackAllowedCssProperties);
if (filteredStyle) {
node.setAttribute('style', filteredStyle);
} else {
node.removeAttribute('style');
}
}
}
});
return DOMPurify.sanitize(html, fallbackConfig as any).toString(); return DOMPurify.sanitize(html, fallbackConfig as any).toString();
} }