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 // Public endpoints
.requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/files/images/**").permitAll() // Public image serving .requestMatchers("/api/files/images/**").permitAll() // Public image serving
.requestMatchers("/api/config/**").permitAll() // Public configuration endpoints
.requestMatchers("/actuator/health").permitAll() .requestMatchers("/actuator/health").permitAll()
// All other API endpoints require authentication // All other API endpoints require authentication
.requestMatchers("/api/**").authenticated() .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; package com.storycove.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.storycove.dto.HtmlSanitizationConfigDto;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
import org.jsoup.safety.Safelist; 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 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 @Service
public class HtmlSanitizationService { 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() { public HtmlSanitizationService(ObjectMapper objectMapper) {
// Create a custom allowlist for story content this.objectMapper = objectMapper;
this.allowlist = Safelist.relaxed() }
// Basic formatting
.addTags("p", "br", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6") @PostConstruct
// Text formatting public void initializeSanitization() {
.addTags("b", "strong", "i", "em", "u", "s", "strike", "del", "ins") loadConfiguration();
.addTags("sup", "sub", "small", "big", "mark", "pre", "code") createSafelist();
// Lists }
.addTags("ul", "ol", "li", "dl", "dt", "dd")
// Links (but remove href for security) private void loadConfiguration() {
.addTags("a").removeAttributes("a", "href", "target") try {
// Tables ClassPathResource resource = new ClassPathResource("html-sanitization-config.json");
.addTags("table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption") try (InputStream inputStream = resource.getInputStream()) {
// Quotes this.config = objectMapper.readValue(inputStream, HtmlSanitizationConfigDto.class);
.addTags("blockquote", "cite", "q") logger.info("Loaded HTML sanitization configuration with {} allowed tags",
// Horizontal rule config.getAllowedTags().size());
.addTags("hr") }
// Allow basic styling attributes } catch (IOException e) {
.addAttributes("p", "class", "style") logger.error("Failed to load HTML sanitization configuration, using fallback", e);
.addAttributes("div", "class", "style") createFallbackConfiguration();
.addAttributes("span", "class", "style") }
.addAttributes("h1", "class", "style") }
.addAttributes("h2", "class", "style")
.addAttributes("h3", "class", "style") private void createFallbackConfiguration() {
.addAttributes("h4", "class", "style") // Fallback configuration if JSON loading fails
.addAttributes("h5", "class", "style") this.config = new HtmlSanitizationConfigDto();
.addAttributes("h6", "class", "style") this.config.setAllowedTags(List.of(
// Allow limited CSS properties "p", "br", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
.addProtocols("style", "color", "background-color", "font-size", "font-weight", "b", "strong", "i", "em", "u", "s", "strike", "del", "ins",
"font-style", "text-align", "text-decoration", "margin", "padding"); "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) { 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 LoadingSpinner from '../../../components/ui/LoadingSpinner';
import Button from '../../../components/ui/Button'; import Button from '../../../components/ui/Button';
import StoryRating from '../../../components/stories/StoryRating'; import StoryRating from '../../../components/stories/StoryRating';
import DOMPurify from 'dompurify'; import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization';
export default function StoryReadingPage() { export default function StoryReadingPage() {
const params = useParams(); const params = useParams();
@@ -18,6 +18,7 @@ export default function StoryReadingPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [readingProgress, setReadingProgress] = useState(0); const [readingProgress, setReadingProgress] = useState(0);
const [sanitizedContent, setSanitizedContent] = useState<string>('');
const storyId = params.id as string; const storyId = params.id as string;
@@ -25,9 +26,19 @@ export default function StoryReadingPage() {
const loadStory = async () => { const loadStory = async () => {
try { try {
setLoading(true); 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); setStory(storyData);
// Sanitize story content
const sanitized = await sanitizeHtml(storyData.contentHtml || '');
setSanitizedContent(sanitized);
// Load series stories if part of a series // Load series stories if part of a series
if (storyData.seriesId) { if (storyData.seriesId) {
const seriesData = await seriesApi.getSeriesStories(storyData.seriesId); const seriesData = await seriesApi.getSeriesStories(storyData.seriesId);
@@ -119,8 +130,6 @@ export default function StoryReadingPage() {
); );
} }
const sanitizedContent = DOMPurify.sanitize(story.contentHtml);
return ( return (
<div className="min-h-screen theme-bg"> <div className="min-h-screen theme-bg">
{/* Progress Bar */} {/* 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 // Image utility
export const getImageUrl = (path: string): string => { export const getImageUrl = (path: string): string => {
if (!path) return ''; 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