2 Commits

Author SHA1 Message Date
Stefan Hardegger
030aac7846 j 2025-07-23 16:21:39 +02:00
Stefan Hardegger
5a48ebcfeb 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>
2025-07-23 16:18:03 +02:00
9 changed files with 414 additions and 37 deletions

View File

@@ -42,6 +42,7 @@ public class SecurityConfig {
// Public endpoints
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/files/images/**").permitAll() // Public image serving
.requestMatchers("/api/config/**").permitAll() // Public configuration endpoints
.requestMatchers("/actuator/health").permitAll()
// All other API endpoints require authentication
.requestMatchers("/api/**").authenticated()

View File

@@ -0,0 +1,31 @@
package com.storycove.controller;
import com.storycove.dto.HtmlSanitizationConfigDto;
import com.storycove.service.HtmlSanitizationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/config")
public class HtmlSanitizationController {
private final HtmlSanitizationService htmlSanitizationService;
@Autowired
public HtmlSanitizationController(HtmlSanitizationService htmlSanitizationService) {
this.htmlSanitizationService = htmlSanitizationService;
}
/**
* Get the HTML sanitization configuration for frontend use
* This allows the frontend to use the same sanitization rules as the backend
*/
@GetMapping("/html-sanitization")
public ResponseEntity<HtmlSanitizationConfigDto> getHtmlSanitizationConfig() {
HtmlSanitizationConfigDto config = htmlSanitizationService.getConfiguration();
return ResponseEntity.ok(config);
}
}

View File

@@ -0,0 +1,54 @@
package com.storycove.dto;
import java.util.List;
import java.util.Map;
public class HtmlSanitizationConfigDto {
private List<String> allowedTags;
private Map<String, List<String>> allowedAttributes;
private List<String> allowedCssProperties;
private Map<String, List<String>> removedAttributes;
private String description;
public HtmlSanitizationConfigDto() {}
public List<String> getAllowedTags() {
return allowedTags;
}
public void setAllowedTags(List<String> allowedTags) {
this.allowedTags = allowedTags;
}
public Map<String, List<String>> getAllowedAttributes() {
return allowedAttributes;
}
public void setAllowedAttributes(Map<String, List<String>> allowedAttributes) {
this.allowedAttributes = allowedAttributes;
}
public List<String> getAllowedCssProperties() {
return allowedCssProperties;
}
public void setAllowedCssProperties(List<String> allowedCssProperties) {
this.allowedCssProperties = allowedCssProperties;
}
public Map<String, List<String>> getRemovedAttributes() {
return removedAttributes;
}
public void setRemovedAttributes(Map<String, List<String>> removedAttributes) {
this.removedAttributes = removedAttributes;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}

View File

@@ -1,45 +1,113 @@
package com.storycove.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.storycove.dto.HtmlSanitizationConfigDto;
import org.jsoup.Jsoup;
import org.jsoup.safety.Safelist;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
@Service
public class HtmlSanitizationService {
private final Safelist allowlist;
private static final Logger logger = LoggerFactory.getLogger(HtmlSanitizationService.class);
private final ObjectMapper objectMapper;
private Safelist allowlist;
private HtmlSanitizationConfigDto config;
public HtmlSanitizationService() {
// Create a custom allowlist for story content
this.allowlist = Safelist.relaxed()
// Basic formatting
.addTags("p", "br", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6")
// Text formatting
.addTags("b", "strong", "i", "em", "u", "s", "strike", "del", "ins")
.addTags("sup", "sub", "small", "big", "mark", "pre", "code")
// Lists
.addTags("ul", "ol", "li", "dl", "dt", "dd")
// Links (but remove href for security)
.addTags("a").removeAttributes("a", "href", "target")
// Tables
.addTags("table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption")
// Quotes
.addTags("blockquote", "cite", "q")
// Horizontal rule
.addTags("hr")
// Allow basic styling attributes
.addAttributes("p", "class", "style")
.addAttributes("div", "class", "style")
.addAttributes("span", "class", "style")
.addAttributes("h1", "class", "style")
.addAttributes("h2", "class", "style")
.addAttributes("h3", "class", "style")
.addAttributes("h4", "class", "style")
.addAttributes("h5", "class", "style")
.addAttributes("h6", "class", "style")
// Allow limited CSS properties
.addProtocols("style", "color", "background-color", "font-size", "font-weight",
"font-style", "text-align", "text-decoration", "margin", "padding");
public HtmlSanitizationService(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@PostConstruct
public void initializeSanitization() {
loadConfiguration();
createSafelist();
}
private void loadConfiguration() {
try {
ClassPathResource resource = new ClassPathResource("html-sanitization-config.json");
try (InputStream inputStream = resource.getInputStream()) {
this.config = objectMapper.readValue(inputStream, HtmlSanitizationConfigDto.class);
logger.info("Loaded HTML sanitization configuration with {} allowed tags",
config.getAllowedTags().size());
}
} catch (IOException e) {
logger.error("Failed to load HTML sanitization configuration, using fallback", e);
createFallbackConfiguration();
}
}
private void createFallbackConfiguration() {
// Fallback configuration if JSON loading fails
this.config = new HtmlSanitizationConfigDto();
this.config.setAllowedTags(List.of(
"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"
));
this.config.setAllowedCssProperties(List.of(
"color", "background-color", "font-size", "font-weight",
"font-style", "text-align", "text-decoration", "margin", "padding"
));
}
private void createSafelist() {
this.allowlist = new Safelist();
// Add allowed tags
if (config.getAllowedTags() != null) {
config.getAllowedTags().forEach(allowlist::addTags);
}
// Add allowed attributes
if (config.getAllowedAttributes() != null) {
for (Map.Entry<String, List<String>> entry : config.getAllowedAttributes().entrySet()) {
String tag = entry.getKey();
List<String> attributes = entry.getValue();
if (attributes != null) {
allowlist.addAttributes(tag, attributes.toArray(new String[0]));
}
}
}
// Remove specific attributes (like href from links for security)
if (config.getRemovedAttributes() != null) {
for (Map.Entry<String, List<String>> entry : config.getRemovedAttributes().entrySet()) {
String tag = entry.getKey();
List<String> attributes = entry.getValue();
if (attributes != null) {
attributes.forEach(attr -> allowlist.removeAttributes(tag, attr));
}
}
}
// Add allowed CSS properties for style attributes
if (config.getAllowedCssProperties() != null) {
config.getAllowedCssProperties().forEach(property ->
allowlist.addProtocols("style", property));
}
logger.info("Created Jsoup Safelist with configuration");
}
/**
* Get the current sanitization configuration for sharing with frontend
*/
public HtmlSanitizationConfigDto getConfiguration() {
return config;
}
public String sanitize(String html) {

View File

@@ -0,0 +1,34 @@
{
"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": "HTML sanitization configuration for StoryCove story content. This configuration is shared between frontend (DOMPurify) and backend (Jsoup) to ensure consistency."
}

View File

@@ -8,7 +8,7 @@ import { Story } from '../../../types/api';
import LoadingSpinner from '../../../components/ui/LoadingSpinner';
import Button from '../../../components/ui/Button';
import StoryRating from '../../../components/stories/StoryRating';
import DOMPurify from 'dompurify';
import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization';
export default function StoryReadingPage() {
const params = useParams();
@@ -18,6 +18,7 @@ export default function StoryReadingPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [readingProgress, setReadingProgress] = useState(0);
const [sanitizedContent, setSanitizedContent] = useState<string>('');
const storyId = params.id as string;
@@ -25,9 +26,19 @@ export default function StoryReadingPage() {
const loadStory = async () => {
try {
setLoading(true);
const storyData = await storyApi.getStory(storyId);
// Preload sanitization config and load story in parallel
const [storyData] = await Promise.all([
storyApi.getStory(storyId),
preloadSanitizationConfig()
]);
setStory(storyData);
// Sanitize story content
const sanitized = await sanitizeHtml(storyData.contentHtml || '');
setSanitizedContent(sanitized);
// Load series stories if part of a series
if (storyData.seriesId) {
const seriesData = await seriesApi.getSeriesStories(storyData.seriesId);
@@ -119,8 +130,6 @@ export default function StoryReadingPage() {
);
}
const sanitizedContent = DOMPurify.sanitize(story.contentHtml);
return (
<div className="min-h-screen theme-bg">
{/* Progress Bar */}

View File

@@ -273,6 +273,20 @@ export const searchApi = {
},
};
// Configuration endpoints
export const configApi = {
getHtmlSanitizationConfig: async (): Promise<{
allowedTags: string[];
allowedAttributes: Record<string, string[]>;
allowedCssProperties: string[];
removedAttributes: Record<string, string[]>;
description: string;
}> => {
const response = await api.get('/config/html-sanitization');
return response.data;
},
};
// Image utility
export const getImageUrl = (path: string): string => {
if (!path) return '';

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;
}

File diff suppressed because one or more lines are too long