From 030aac784631f20cf6dea5aa912328e1355acd7c Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Wed, 23 Jul 2025 16:21:39 +0200 Subject: [PATCH] j --- .../com/storycove/config/SecurityConfig.java | 1 + .../HtmlSanitizationController.java | 31 ++++ .../dto/HtmlSanitizationConfigDto.java | 54 +++++++ .../service/HtmlSanitizationService.java | 132 +++++++++++++----- .../resources/html-sanitization-config.json | 34 +++++ 5 files changed, 220 insertions(+), 32 deletions(-) create mode 100644 backend/src/main/java/com/storycove/controller/HtmlSanitizationController.java create mode 100644 backend/src/main/java/com/storycove/dto/HtmlSanitizationConfigDto.java create mode 100644 backend/src/main/resources/html-sanitization-config.json diff --git a/backend/src/main/java/com/storycove/config/SecurityConfig.java b/backend/src/main/java/com/storycove/config/SecurityConfig.java index 27839e5..66444de 100644 --- a/backend/src/main/java/com/storycove/config/SecurityConfig.java +++ b/backend/src/main/java/com/storycove/config/SecurityConfig.java @@ -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() diff --git a/backend/src/main/java/com/storycove/controller/HtmlSanitizationController.java b/backend/src/main/java/com/storycove/controller/HtmlSanitizationController.java new file mode 100644 index 0000000..c5aa491 --- /dev/null +++ b/backend/src/main/java/com/storycove/controller/HtmlSanitizationController.java @@ -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 getHtmlSanitizationConfig() { + HtmlSanitizationConfigDto config = htmlSanitizationService.getConfiguration(); + return ResponseEntity.ok(config); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/dto/HtmlSanitizationConfigDto.java b/backend/src/main/java/com/storycove/dto/HtmlSanitizationConfigDto.java new file mode 100644 index 0000000..dca10d9 --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/HtmlSanitizationConfigDto.java @@ -0,0 +1,54 @@ +package com.storycove.dto; + +import java.util.List; +import java.util.Map; + +public class HtmlSanitizationConfigDto { + private List allowedTags; + private Map> allowedAttributes; + private List allowedCssProperties; + private Map> removedAttributes; + private String description; + + public HtmlSanitizationConfigDto() {} + + public List getAllowedTags() { + return allowedTags; + } + + public void setAllowedTags(List allowedTags) { + this.allowedTags = allowedTags; + } + + public Map> getAllowedAttributes() { + return allowedAttributes; + } + + public void setAllowedAttributes(Map> allowedAttributes) { + this.allowedAttributes = allowedAttributes; + } + + public List getAllowedCssProperties() { + return allowedCssProperties; + } + + public void setAllowedCssProperties(List allowedCssProperties) { + this.allowedCssProperties = allowedCssProperties; + } + + public Map> getRemovedAttributes() { + return removedAttributes; + } + + public void setRemovedAttributes(Map> removedAttributes) { + this.removedAttributes = removedAttributes; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/HtmlSanitizationService.java b/backend/src/main/java/com/storycove/service/HtmlSanitizationService.java index 316d56e..e381bb8 100644 --- a/backend/src/main/java/com/storycove/service/HtmlSanitizationService.java +++ b/backend/src/main/java/com/storycove/service/HtmlSanitizationService.java @@ -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> entry : config.getAllowedAttributes().entrySet()) { + String tag = entry.getKey(); + List 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> entry : config.getRemovedAttributes().entrySet()) { + String tag = entry.getKey(); + List 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) { diff --git a/backend/src/main/resources/html-sanitization-config.json b/backend/src/main/resources/html-sanitization-config.json new file mode 100644 index 0000000..b680146 --- /dev/null +++ b/backend/src/main/resources/html-sanitization-config.json @@ -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." +} \ No newline at end of file