chore: update svg conversion and database import handling (#6796)

This commit is contained in:
Anthony Stirling
2026-06-24 22:01:32 +01:00
committed by GitHub
parent b040277220
commit d06d3cabaf
7 changed files with 254 additions and 6 deletions
@@ -244,10 +244,7 @@ public class SvgSanitizer {
return false;
}
return normalized.startsWith("http://")
|| normalized.startsWith("https://")
|| normalized.startsWith("//")
|| normalized.startsWith("file:");
return true;
}
private boolean isUrlAllowed(String url) {
@@ -97,4 +97,51 @@ class SvgSanitizerTest {
byte[] invalid = "not xml at all".getBytes(StandardCharsets.UTF_8);
assertThrows(IOException.class, () -> sanitizer.sanitize(invalid));
}
@Test
void testSanitize_removesRootRelativeLocalPath() throws IOException {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
+ "<image href=\"/tmp/image.png\" width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("/tmp/image.png"), "Root-relative local path must be stripped");
}
@Test
void testSanitize_removesRelativeLocalPath() throws IOException {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
+ "<image href=\"../../assets/image.png\" width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("assets/image.png"), "Relative local path must be stripped");
}
@Test
void testSanitize_removesRootRelativeWindowsDrivePath() throws IOException {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\" "
+ "xmlns:xlink=\"http://www.w3.org/1999/xlink\">"
+ "<image xlink:href=\"/C:/Users/x/external-image.svg\""
+ " width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(
output.contains("external-image"), "Root-relative Windows path must be stripped");
}
@Test
void testSanitize_keepsInDocumentFragmentReference() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
+ "<use href=\"#gradient\"/><rect width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertTrue(
output.contains("#gradient"), "In-document fragment references must be preserved");
}
}
@@ -45,6 +45,10 @@ public class SvgOverlayUtil {
@Override
public void checkLoadExternalResource(
ParsedURL resourceURL, ParsedURL docURL) {
// Inline data: URIs are self-contained (no network/file fetch).
if (resourceURL != null && "data".equals(resourceURL.getProtocol())) {
return;
}
throw new SecurityException(
"External resource loading is disabled for SVG overlays: "
+ resourceURL);
@@ -19,6 +19,7 @@ import org.apache.batik.bridge.GVTBuilder;
import org.apache.batik.bridge.UserAgent;
import org.apache.batik.bridge.UserAgentAdapter;
import org.apache.batik.gvt.GraphicsNode;
import org.apache.batik.util.ParsedURL;
import org.apache.batik.util.XMLResourceDescriptor;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
@@ -63,7 +64,7 @@ public class SvgToPdf {
}
// 2. Build the GVT (Graphics Vector Tree) with timeout protection
UserAgent userAgent = new UserAgentAdapter();
UserAgent userAgent = createSecureUserAgent();
DocumentLoader loader = new DocumentLoader(userAgent);
BridgeContext ctx = new BridgeContext(userAgent, loader);
ctx.setDynamicState(BridgeContext.DYNAMIC);
@@ -94,6 +95,21 @@ public class SvgToPdf {
}
}
private UserAgent createSecureUserAgent() {
return new UserAgentAdapter() {
@Override
public void checkLoadExternalResource(ParsedURL resourceURL, ParsedURL docURL) {
// Inline data: URIs are self-contained (no network/file fetch) - allow them.
if (resourceURL != null && "data".equals(resourceURL.getProtocol())) {
return;
}
throw new SecurityException(
"External resource loading is disabled for SVG to PDF conversion: "
+ resourceURL);
}
};
}
private GraphicsNode buildGvtWithTimeout(BridgeContext ctx, SVGDocument svgDoc)
throws IOException {
GVTBuilder builder = new GVTBuilder();
@@ -202,7 +218,7 @@ public class SvgToPdf {
svgDoc = factory.createSVGDocument("file:///input.svg", inputStream);
}
UserAgent userAgent = new UserAgentAdapter();
UserAgent userAgent = createSecureUserAgent();
DocumentLoader loader = new DocumentLoader(userAgent);
BridgeContext ctx = new BridgeContext(userAgent, loader);
ctx.setDynamicState(BridgeContext.DYNAMIC);
@@ -2,12 +2,23 @@ package stirling.software.SPDF.utils;
import static org.junit.jupiter.api.Assertions.*;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import javax.imageio.ImageIO;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.junit.jupiter.api.Test;
class SvgToPdfTest {
@@ -124,4 +135,91 @@ class SvgToPdfTest {
List<byte[]> svgs = Arrays.asList(null, null, new byte[0]);
assertThrows(IOException.class, () -> SvgToPdf.combineIntoPdf(svgs));
}
@Test
void convert_doesNotEmbedExternalFileResource() throws Exception {
Path external = Files.createTempFile("svg-external", ".png");
BufferedImage red = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
Graphics2D g = red.createGraphics();
g.setColor(Color.RED);
g.fillRect(0, 0, 100, 100);
g.dispose();
ImageIO.write(red, "png", external.toFile());
try {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\" "
+ "xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100\" height=\"100\">"
+ "<image x=\"0\" y=\"0\" width=\"100\" height=\"100\" xlink:href=\""
+ external.toUri()
+ "\"/></svg>";
byte[] pdf;
try {
pdf = SvgToPdf.convert(svg.getBytes(StandardCharsets.UTF_8));
} catch (IOException blocked) {
return;
}
try (PDDocument doc = Loader.loadPDF(pdf)) {
BufferedImage page = new PDFRenderer(doc).renderImageWithDPI(0, 72);
int rgb = page.getRGB(page.getWidth() / 2, page.getHeight() / 2);
int r = (rgb >> 16) & 0xff;
int gg = (rgb >> 8) & 0xff;
int b = rgb & 0xff;
assertFalse(
r > 200 && gg < 60 && b < 60,
"External file image must not be rendered into the PDF");
}
} finally {
Files.deleteIfExists(external);
}
}
// A self-contained SVG whose only content is an inline base64 data: image (a solid-red vector
// SVG). Vector data: images decode via batik-bridge without batik-codec, so this exercises the
// data: security allowance independent of raster codecs.
private static String svgWithInlineRedImage() {
String innerSvg =
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\">"
+ "<rect width=\"100\" height=\"100\" fill=\"red\"/></svg>";
String dataUri =
"data:image/svg+xml;base64,"
+ Base64.getEncoder()
.encodeToString(innerSvg.getBytes(StandardCharsets.UTF_8));
return "<svg xmlns=\"http://www.w3.org/2000/svg\" "
+ "xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100\" height=\"100\">"
+ "<image x=\"0\" y=\"0\" width=\"100\" height=\"100\" xlink:href=\""
+ dataUri
+ "\"/></svg>";
}
@Test
void convert_rendersInlineDataUriImage() throws IOException {
byte[] pdf = SvgToPdf.convert(svgWithInlineRedImage().getBytes(StandardCharsets.UTF_8));
try (PDDocument doc = Loader.loadPDF(pdf)) {
BufferedImage page = new PDFRenderer(doc).renderImageWithDPI(0, 72);
int rgb = page.getRGB(page.getWidth() / 2, page.getHeight() / 2);
int r = (rgb >> 16) & 0xff;
int gg = (rgb >> 8) & 0xff;
int b = rgb & 0xff;
assertTrue(
r > 200 && gg < 60 && b < 60,
"Inline data: image must be rendered into the PDF (center rgb="
+ Integer.toHexString(rgb)
+ ")");
}
}
@Test
void combineIntoPdf_keepsPageWithInlineDataUriImage() throws IOException {
List<byte[]> svgs =
List.of(
SIMPLE_SVG.getBytes(StandardCharsets.UTF_8),
svgWithInlineRedImage().getBytes(StandardCharsets.UTF_8));
byte[] pdf = SvgToPdf.combineIntoPdf(svgs);
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertEquals(2, doc.getNumberOfPages(), "inline data: image page must not be dropped");
}
}
}
@@ -90,6 +90,20 @@ public class DatabaseService implements DatabaseServiceInterface {
Pattern.compile("(?i)\\bFALSE\\b"),
Pattern.compile("(?i)\\bNULL\\b"));
private static final java.util.List<Pattern> DENIED_PATTERNS =
java.util.List.of(
Pattern.compile("(?i)\\bCREATE\\s+(FORCE\\s+)?ALIAS\\b"),
Pattern.compile("(?i)\\bCREATE\\s+(FORCE\\s+)?TRIGGER\\b"),
Pattern.compile("(?i)\\bCREATE\\s+(FORCE\\s+)?AGGREGATE\\b"),
Pattern.compile("(?i)\\bCREATE\\s+LINKED\\s+TABLE\\b"),
Pattern.compile("(?i)\\bFILE_WRITE\\s*\\("),
Pattern.compile("(?i)\\bFILE_READ\\s*\\("),
Pattern.compile("(?i)\\bCSVWRITE\\s*\\("),
Pattern.compile("(?i)\\bCSVREAD\\s*\\("),
Pattern.compile("(?i)\\bLINK_SCHEMA\\s*\\("),
Pattern.compile("(?i)\\bRUNSCRIPT\\b"),
Pattern.compile("(?i)\\bSCRIPT\\s+TO\\b"));
private final ApplicationProperties.Datasource datasourceProps;
private final DataSource dataSource;
private final DatabaseNotificationServiceInterface backupNotificationService;
@@ -508,6 +522,17 @@ public class DatabaseService implements DatabaseServiceInterface {
String content = Files.readString(scriptPath);
String normalizedContent = normalizeSqlContent(content);
String codeOnly = stripStringLiterals(normalizedContent);
for (Pattern deniedPattern : DENIED_PATTERNS) {
if (deniedPattern.matcher(codeOnly).find()) {
log.error(
"Blocked disallowed SQL in backup file matching: {}",
deniedPattern.pattern());
throw new IllegalArgumentException(
"SQL script contains disallowed operations and was rejected.");
}
}
// Validate that content only contains allowed operations (whitelist approach)
// Split by semicolons to check individual statements
String[] statements = normalizedContent.split(";");
@@ -558,6 +583,10 @@ public class DatabaseService implements DatabaseServiceInterface {
return sql.trim();
}
private String stripStringLiterals(String sql) {
return sql.replaceAll("'(?:[^']|'')*'", "''");
}
/**
* Checks for invalid characters or sequences
*
@@ -168,4 +168,61 @@ class DatabaseServiceMoreTest {
assertThat(resolved.startsWith(tempDir)).isTrue();
}
}
@Nested
@DisplayName("SQL import validation")
class SqlValidation {
private Path writeScript(String sql) throws IOException {
Path script = Files.createTempFile("import", ".sql");
Files.writeString(script, sql);
return script;
}
@Test
@DisplayName("rejects FILE_WRITE")
void rejectsFileWrite() throws IOException {
Path script = writeScript("CREATE TABLE X AS SELECT FILE_WRITE('data', '/tmp/out');");
assertThatThrownBy(() -> databaseService.importDatabaseFromUI(script))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("rejects FILE_READ in an INSERT")
void rejectsFileRead() throws IOException {
Path script = writeScript("INSERT INTO PUBLIC.X(D) VALUES (FILE_READ('/tmp/in'));");
assertThatThrownBy(() -> databaseService.importDatabaseFromUI(script))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("rejects CREATE ALIAS")
void rejectsCreateAlias() throws IOException {
Path script =
writeScript(
"CREATE ALIAS MYALIAS AS 'String run(String c) throws Exception"
+ " { return c; }';");
assertThatThrownBy(() -> databaseService.importDatabaseFromUI(script))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("rejects RUNSCRIPT FROM")
void rejectsRunscript() throws IOException {
Path script = writeScript("RUNSCRIPT FROM '/tmp/other.sql';");
assertThatThrownBy(() -> databaseService.importDatabaseFromUI(script))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("allows a backup whose data mentions a keyword")
void allowsKeywordInsideStringData() throws IOException {
Path script =
writeScript(
"CREATE TABLE PUBLIC.NOTES(ID INT, BODY CHARACTER VARYING);"
+ " INSERT INTO PUBLIC.NOTES(ID, BODY)"
+ " VALUES(1, 'plain text with FILE_WRITE() inside');");
assertThat(databaseService.importDatabaseFromUI(script)).isTrue();
}
}
}