Implement shared HTML sanitization configuration

**Backend Changes:**
- Add html-sanitization-config.json with allowedTags, allowedAttributes, and allowedCssProperties
- Create HtmlSanitizationConfigDto for configuration data transfer
- Update HtmlSanitizationService to load configuration from JSON file with fallback
- Add HtmlSanitizationController with public API endpoint at /api/config/html-sanitization
- Update SecurityConfig to allow public access to /api/config/** endpoints

**Frontend Changes:**
- Add configApi.getHtmlSanitizationConfig() to fetch backend configuration
- Create sanitization.ts utility with sanitizeHtml() and sanitizeHtmlSync() functions
- Update story reading page to use shared sanitization configuration
- Add preloadSanitizationConfig() for early configuration loading
- Handle TrustedHTML type conversion and DOMPurify config compatibility

**Benefits:**
- Consistent HTML sanitization rules between frontend and backend
- Centralized configuration in JSON file for easy maintenance
- Automatic fallback to safe defaults if configuration loading fails
- API-driven approach allows runtime configuration updates
- Maintains security while providing flexibility for content formatting

Resolves HTML sanitization inconsistencies and provides foundation for configurable content safety rules.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Stefan Hardegger
2025-07-23 16:18:03 +02:00
parent f2001e0d0c
commit 5a48ebcfeb
4 changed files with 194 additions and 5 deletions

View File

@@ -0,0 +1,166 @@
import DOMPurify from 'dompurify';
import { configApi } from './api';
interface SanitizationConfig {
allowedTags: string[];
allowedAttributes: Record<string, string[]>;
allowedCssProperties: string[];
removedAttributes: Record<string, string[]>;
description: string;
}
let cachedConfig: SanitizationConfig | null = null;
let configPromise: Promise<SanitizationConfig> | null = null;
/**
* Fetch sanitization configuration from backend
*/
async function fetchSanitizationConfig(): Promise<SanitizationConfig> {
if (cachedConfig) {
return cachedConfig;
}
if (configPromise) {
return configPromise;
}
configPromise = configApi.getHtmlSanitizationConfig()
.then(config => {
cachedConfig = config;
configPromise = null;
return config;
})
.catch(error => {
console.error('Failed to fetch sanitization config, using fallback:', error);
configPromise = null;
// Return fallback configuration
const fallbackConfig: SanitizationConfig = {
allowedTags: [
'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins',
'sup', 'sub', 'small', 'big', 'mark', 'pre', 'code',
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption',
'blockquote', 'cite', 'q', 'hr'
],
allowedAttributes: {
'p': ['class', 'style'],
'div': ['class', 'style'],
'span': ['class', 'style'],
'h1': ['class', 'style'],
'h2': ['class', 'style'],
'h3': ['class', 'style'],
'h4': ['class', 'style'],
'h5': ['class', 'style'],
'h6': ['class', 'style'],
'a': ['class'],
'table': ['class'],
'td': ['class', 'colspan', 'rowspan'],
'th': ['class', 'colspan', 'rowspan']
},
allowedCssProperties: [
'color', 'background-color', 'font-size', 'font-weight',
'font-style', 'text-align', 'text-decoration', 'margin',
'padding', 'text-indent', 'line-height'
],
removedAttributes: {
'a': ['href', 'target']
},
description: 'Fallback sanitization configuration'
};
cachedConfig = fallbackConfig;
return fallbackConfig;
});
return configPromise;
}
/**
* Create DOMPurify configuration from backend sanitization config
*/
function createDOMPurifyConfig(config: SanitizationConfig) {
const allowedTags = config.allowedTags;
const allowedAttributes: Record<string, string[]> = { ...config.allowedAttributes };
// Remove attributes that should be stripped (like href from links)
if (config.removedAttributes) {
Object.keys(config.removedAttributes).forEach(tag => {
const attributesToRemove = config.removedAttributes[tag];
if (allowedAttributes[tag]) {
allowedAttributes[tag] = allowedAttributes[tag].filter(
attr => !attributesToRemove.includes(attr)
);
}
});
}
return {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: Object.keys(allowedAttributes).reduce((acc: string[], tag) => {
return [...acc, ...allowedAttributes[tag]];
}, []),
// Allow style attribute but sanitize CSS properties
ALLOW_UNKNOWN_PROTOCOLS: false,
SANITIZE_DOM: true,
KEEP_CONTENT: true,
// Custom hook to sanitize style attributes
ALLOW_DATA_ATTR: false,
} as DOMPurify.Config;
}
/**
* Sanitize HTML content using shared configuration from backend
*/
export async function sanitizeHtml(html: string): Promise<string> {
if (!html || html.trim() === '') {
return '';
}
try {
const config = await fetchSanitizationConfig();
const domPurifyConfig = createDOMPurifyConfig(config);
// Configure DOMPurify with our settings
const cleanHtml = DOMPurify.sanitize(html, domPurifyConfig as any);
return cleanHtml.toString();
} catch (error) {
console.error('Error during HTML sanitization:', error);
// Fallback to basic DOMPurify sanitization
return DOMPurify.sanitize(html).toString();
}
}
/**
* Synchronous sanitization using cached config (for cases where async is not possible)
* Falls back to basic DOMPurify if no config is cached
*/
export function sanitizeHtmlSync(html: string): string {
if (!html || html.trim() === '') {
return '';
}
if (cachedConfig) {
const domPurifyConfig = createDOMPurifyConfig(cachedConfig);
return DOMPurify.sanitize(html, domPurifyConfig as any).toString();
}
// Fallback to basic DOMPurify
console.warn('No cached sanitization config available, using DOMPurify defaults');
return DOMPurify.sanitize(html).toString();
}
/**
* Preload sanitization configuration (call early in app lifecycle)
*/
export function preloadSanitizationConfig(): Promise<SanitizationConfig> {
return fetchSanitizationConfig();
}
/**
* Clear cached configuration (useful for testing or config updates)
*/
export function clearSanitizationCache(): void {
cachedConfig = null;
configPromise = null;
}