This commit is contained in:
Stefan Hardegger
2025-07-23 16:21:39 +02:00
parent 5a48ebcfeb
commit 030aac7846
5 changed files with 220 additions and 32 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."
}