mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
chore: update svg conversion and database import handling (#6796)
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -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
|
||||
*
|
||||
|
||||
+57
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user