Skip to content

Commit cc0dedb

Browse files
authored
feat(plantuml): allow to configure PlantUML security profile (#2004)
1 parent 600f980 commit cc0dedb

File tree

9 files changed

+150
-25
lines changed

9 files changed

+150
-25
lines changed

docs/modules/setup/pages/configuration.adoc

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,21 @@ KROKI_PLANTUML_INCLUDE_WHITELIST:: The name of a file that consists of a list of
8484
KROKI_PLANTUML_INCLUDE_WHITELIST_0, KROKI_PLANTUML_INCLUDE_WHITELIST_1, ... KROKI_PLANTUML_INCLUDE_WHITELIST___N__:: One regex to add to the include whitelist per environment variable. Search will stop at the first empty or undefined integer number.
8585
KROKI_PLANTUML_ALLOW_INCLUDE:: Either `false` (default) or `true`. Determines if PlantUML will fetch `!include` directives that reference external URLs. For example, PlantUML allows the !import directive to pull fragments from the filesystem or a remote URL or the standard library.
8686

87+
It's also possible to configure the security profile of PlantUML directly using `KROKI_PLANTUML_SECURITY_PROFILE`.
88+
89+
`KROKI_PLANTUML_SECURITY_PROFILE`:: The PlantUML security profile. Accepted values are `UNSECURE`, `ALLOWLIST`, `INTERNET` and `SANDBOX`.
90+
`KROKI_PLANTUML_ALLOWLIST_URL`:: A URL pattern allowlist for PlantUML includes. Only effective when using the `ALLOWLIST` security profile.
91+
`KROKI_PLANTUML_ALLOWLIST_PATH`:: A path pattern allowlist for PlantUML includes. Only effective when using the `ALLOWLIST` security profile.
92+
93+
By default, the security profile is inferred from the Kroki safe mode:
94+
95+
[horizontal]
96+
`UNSAFE`:: `UNSECURE`
97+
`SAFE`:: `ALLOWLIST`
98+
`SECURE`:: `SANDBOX`
99+
100+
For more information, see the https://plantuml.com/en/security[PlantUML security documentation].
101+
87102
=== Structurizr
88103

89104
Structurizr's restricted mode is activated unless Kroki is running in `UNSAFE` mode:
@@ -310,4 +325,4 @@ If SSL is enabled, both `KROKI_SSL_KEY` (or `KROKI_SSL_KEY_PATH`) and `KROKI_SSL
310325

311326
You can configure Open Telemetry tracing with the environment variables.
312327

313-
https://opentelemetry.io/docs/languages/java/configuration/#environment-variables-and-system-properties
328+
https://opentelemetry.io/docs/languages/java/configuration/#environment-variables-and-system-properties

server/src/main/java/io/kroki/server/service/Plantuml.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ public String decode(String encoded) throws DecodeException {
124124
return DiagramSource.plantumlDecode(encoded);
125125
}
126126
};
127-
this.plantumlCommand = new PlantumlCommand(config);
127+
this.plantumlCommand = new PlantumlCommand(this.safeMode, config);
128128
this.ditaaCommand = new DitaaCommand(config);
129129
this.includeWhitelist = parseIncludeWhitelist(config);
130130
this.logging = new Logging(logger);

server/src/main/java/io/kroki/server/service/PlantumlCommand.java

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import io.kroki.server.action.Commander;
55
import io.kroki.server.error.BadRequestException;
66
import io.kroki.server.format.FileFormat;
7+
import io.kroki.server.security.SafeMode;
78
import io.kroki.server.unit.TimeValue;
89
import io.vertx.core.json.JsonObject;
910
import org.slf4j.Logger;
@@ -26,12 +27,31 @@ public class PlantumlCommand {
2627
private static final Pattern ERROR_MESSAGE_RX = Pattern.compile(".*ERROR\\n(?<lineNumber>[0-9]+)\\n(?<cause>[^\\n]+)\\n.*", Pattern.MULTILINE | Pattern.DOTALL);
2728
private final String binPath;
2829
private final String includePath;
30+
private final String allowListUrl;
31+
private final String allowListPath;
32+
private final String securityProfile;
2933
private final TimeValue convertTimeout;
3034
private final Commander commander;
3135

32-
public PlantumlCommand(JsonObject config) {
36+
public PlantumlCommand(SafeMode safeMode, JsonObject config) {
3337
this.binPath = config.getString("KROKI_PLANTUML_BIN_PATH", "plantuml");
3438
this.includePath = config.getString("KROKI_PLANTUML_INCLUDE_PATH");
39+
this.allowListUrl = config.getString("KROKI_PLANTUML_ALLOWLIST_URL");
40+
this.allowListPath = config.getString("KROKI_PLANTUML_ALLOWLIST_PATH");
41+
String defaultSecurityProfile;
42+
switch (safeMode) {
43+
case UNSAFE:
44+
defaultSecurityProfile = "UNSECURE";
45+
break;
46+
case SAFE:
47+
defaultSecurityProfile = "ALLOWLIST";
48+
break;
49+
case SECURE:
50+
default:
51+
defaultSecurityProfile = "SANDBOX";
52+
break;
53+
}
54+
this.securityProfile = config.getString("KROKI_PLANTUML_SECURITY_PROFILE", defaultSecurityProfile);
3555
this.commander = new Commander(
3656
config,
3757
new CommandStatusHandler() {
@@ -74,8 +94,30 @@ public byte[] handle(int exitValue, byte[] stdout, byte[] stderr) {
7494
}
7595

7696
public byte[] convert(String source, FileFormat format, JsonObject options) throws IOException, InterruptedException {
97+
List<String> commandArgs = buildCommandArgs(format, options);
98+
99+
logger.debug("Executing PlantUML command: {}", commandArgs);
100+
101+
byte[] result = commander.execute(source.getBytes(), commandArgs.toArray(new String[0]));
102+
if (format == FileFormat.BASE64) {
103+
final String encodedBytes = "data:image/png;base64," + Base64.getUrlEncoder().encodeToString(result).replaceAll("\\s", "");
104+
return encodedBytes.getBytes();
105+
}
106+
return result;
107+
}
108+
109+
protected List<String> buildCommandArgs(FileFormat format, JsonObject options) {
77110
List<String> commands = new ArrayList<>();
78111
commands.add(binPath);
112+
if (securityProfile != null) {
113+
commands.add("-DPLANTUML_SECURITY_PROFILE=" + securityProfile);
114+
}
115+
if (allowListUrl != null) {
116+
commands.add("-Dplantuml.allowlist.url=" + allowListUrl);
117+
}
118+
if (allowListPath != null) {
119+
commands.add("-Dplantuml.allowlist.path=" + allowListPath);
120+
}
79121
if (includePath != null) {
80122
commands.add("-Dplantuml.include.path=" + includePath);
81123
}
@@ -92,14 +134,6 @@ public byte[] convert(String source, FileFormat format, JsonObject options) thro
92134
if (no_metadata != null) {
93135
commands.add("-nometadata");
94136
}
95-
96-
logger.debug("Executing PlantUML command: {}", commands);
97-
98-
byte[] result = commander.execute(source.getBytes(), commands.toArray(new String[0]));
99-
if (format == FileFormat.BASE64) {
100-
final String encodedBytes = "data:image/png;base64," + Base64.getUrlEncoder().encodeToString(result).replaceAll("\\s", "");
101-
return encodedBytes.getBytes();
102-
}
103-
return result;
137+
return commands;
104138
}
105139
}

server/src/main/java/io/kroki/server/service/Structurizr.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,23 @@ public String decode(String encoded) throws DecodeException {
5858
return DiagramSource.decode(encoded);
5959
}
6060
};
61-
this.plantumlCommand = new PlantumlCommand(config);
61+
this.plantumlCommand = createPlantumlCommand(this.safeMode, config);
62+
}
63+
64+
protected static PlantumlCommand createPlantumlCommand(SafeMode safeMode, JsonObject config) {
65+
String allowListUrl = config.getString("KROKI_PLANTUML_ALLOWLIST_URL");
66+
JsonObject plantumlConfig = config.copy();
67+
if (allowListUrl != null) {
68+
allowListUrl += ";https://static.structurizr.com";
69+
} else {
70+
allowListUrl = "https://static.structurizr.com";
71+
}
72+
plantumlConfig.put("KROKI_PLANTUML_ALLOWLIST_URL", allowListUrl);
73+
String plantumlSecurityProfile = plantumlConfig.getString("KROKI_PLANTUML_SECURITY_PROFILE");
74+
if (plantumlSecurityProfile == null && safeMode.value >= SafeMode.SECURE.value) {
75+
plantumlConfig.put("KROKI_PLANTUML_SECURITY_PROFILE", "ALLOWLIST");
76+
}
77+
return new PlantumlCommand(safeMode, plantumlConfig);
6278
}
6379

6480
@Override

server/src/test/java/io/kroki/server/DownloadPlantumlNativeImage.java

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,15 @@
88

99
public class DownloadPlantumlNativeImage {
1010

11-
public static Future<PlantumlCommand> download(Vertx vertx) {
11+
public static Future<String> download(Vertx vertx) {
1212
String plantumlVersion = new Plantuml(vertx, new JsonObject()).getVersion();
1313
String os = getOperatingSystemName();
1414
String arch = getArch();
1515
String zipName = "plantuml-" + os + "-" + arch + "-" + plantumlVersion + ".zip";
1616
String binaryExtension = getBinaryExtension(os);
1717
String binaryName = "plantuml-" + os + "-" + arch + "-" + plantumlVersion + binaryExtension;
1818
String downloadUrl = "https://github.com/yuzutech/plantuml/releases/download/v" + plantumlVersion + "/" + zipName;
19-
return DownloadNativeImage.download(vertx, downloadUrl, "PlantUML", zipName, binaryName).map(plantumlBinPath -> {
20-
JsonObject options = new JsonObject();
21-
options.put("KROKI_PLANTUML_BIN_PATH", plantumlBinPath);
22-
return new PlantumlCommand(options);
23-
});
19+
return DownloadNativeImage.download(vertx, downloadUrl, "PlantUML", zipName, binaryName);
2420
}
2521

2622
private static String getBinaryExtension(String os) {

server/src/test/java/io/kroki/server/service/C4PlantumlServiceTest.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,17 @@ public class C4PlantumlServiceTest {
2828

2929
@BeforeAll
3030
@Timeout(60)
31-
static void prepare(VertxTestContext context, Vertx vertx) throws InterruptedException {
31+
static void prepare(VertxTestContext context, Vertx vertx) {
3232
Checkpoint checkpoint = context.checkpoint();
3333
DownloadPlantumlNativeImage.download(vertx).onComplete(event -> {
3434
if (event.failed()) {
3535
context.failNow(event.cause());
3636
return;
3737
}
38-
plantumlCommand = event.result();
38+
String plantumlBinPath = event.result();
39+
JsonObject options = new JsonObject();
40+
options.put("KROKI_PLANTUML_BIN_PATH", plantumlBinPath);
41+
plantumlCommand = new PlantumlCommand(SafeMode.SECURE, options);
3942
checkpoint.flag();
4043
});
4144
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package io.kroki.server.service;
2+
3+
import io.kroki.server.format.FileFormat;
4+
import io.kroki.server.security.SafeMode;
5+
import io.vertx.core.json.JsonObject;
6+
import org.junit.jupiter.api.Test;
7+
8+
import java.util.List;
9+
10+
import static org.assertj.core.api.Assertions.assertThat;
11+
12+
public class PlantumlCommandTest {
13+
14+
@Test
15+
public void should_use_sandbox_security_profile_by_default() {
16+
JsonObject options = new JsonObject();
17+
PlantumlCommand cmd = new PlantumlCommand(SafeMode.SECURE, options);
18+
List<String> args = cmd.buildCommandArgs(FileFormat.SVG, options);
19+
assertThat(args).contains("-DPLANTUML_SECURITY_PROFILE=SANDBOX");
20+
}
21+
22+
@Test
23+
public void should_map_unsafe_mode_to_security_profile() {
24+
JsonObject options = new JsonObject();
25+
PlantumlCommand cmd = new PlantumlCommand(SafeMode.UNSAFE, options);
26+
List<String> args = cmd.buildCommandArgs(FileFormat.SVG, options);
27+
assertThat(args).contains("-DPLANTUML_SECURITY_PROFILE=UNSECURE");
28+
}
29+
30+
@Test
31+
public void should_map_safe_mode_to_security_profile() {
32+
JsonObject options = new JsonObject();
33+
PlantumlCommand cmd = new PlantumlCommand(SafeMode.SAFE, options);
34+
List<String> args = cmd.buildCommandArgs(FileFormat.SVG, options);
35+
assertThat(args).contains("-DPLANTUML_SECURITY_PROFILE=ALLOWLIST");
36+
}
37+
38+
@Test
39+
public void should_set_allowlist_url() {
40+
JsonObject options = new JsonObject();
41+
options.put("KROKI_PLANTUML_ALLOWLIST_URL", "https://plantuml.com/;http://somelocalserver:8080/commons");
42+
PlantumlCommand cmd = new PlantumlCommand(SafeMode.SAFE, options);
43+
List<String> args = cmd.buildCommandArgs(FileFormat.SVG, options);
44+
assertThat(args).contains("-Dplantuml.allowlist.url=https://plantuml.com/;http://somelocalserver:8080/commons");
45+
}
46+
47+
@Test
48+
public void should_set_allowlist_path() {
49+
JsonObject options = new JsonObject();
50+
options.put("KROKI_PLANTUML_ALLOWLIST_PATH", "/usr/common/:/usr/plantuml/");
51+
PlantumlCommand cmd = new PlantumlCommand(SafeMode.SAFE, options);
52+
List<String> args = cmd.buildCommandArgs(FileFormat.SVG, options);
53+
assertThat(args).contains("-Dplantuml.allowlist.path=/usr/common/:/usr/plantuml/");
54+
}
55+
}

server/src/test/java/io/kroki/server/service/PlantumlServiceTest.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,17 @@ public class PlantumlServiceTest {
4040

4141
@BeforeAll
4242
@Timeout(60)
43-
static void prepare(VertxTestContext context, Vertx vertx) throws InterruptedException {
43+
static void prepare(VertxTestContext context, Vertx vertx) {
4444
Checkpoint checkpoint = context.checkpoint();
4545
DownloadPlantumlNativeImage.download(vertx).onComplete(event -> {
4646
if (event.failed()) {
4747
context.failNow(event.cause());
4848
return;
4949
}
50-
plantumlCommand = event.result();
50+
String plantumlBinPath = event.result();
51+
JsonObject options = new JsonObject();
52+
options.put("KROKI_PLANTUML_BIN_PATH", plantumlBinPath);
53+
plantumlCommand = new PlantumlCommand(SafeMode.SECURE, options);
5154
checkpoint.flag();
5255
});
5356
}

server/src/test/java/io/kroki/server/service/StructurizrServiceTest.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,17 @@ public class StructurizrServiceTest {
4242

4343
@BeforeAll
4444
@Timeout(60)
45-
static void prepare(VertxTestContext context, Vertx vertx) throws InterruptedException {
45+
static void prepare(VertxTestContext context, Vertx vertx) {
4646
Checkpoint checkpoint = context.checkpoint();
4747
DownloadPlantumlNativeImage.download(vertx).onComplete(event -> {
4848
if (event.failed()) {
4949
context.failNow(event.cause());
5050
return;
5151
}
52-
plantumlCommand = event.result();
52+
String plantumlBinPath = event.result();
53+
JsonObject options = new JsonObject();
54+
options.put("KROKI_PLANTUML_BIN_PATH", plantumlBinPath);
55+
plantumlCommand = Structurizr.createPlantumlCommand(SafeMode.SAFE, options);
5356
checkpoint.flag();
5457
});
5558
}

0 commit comments

Comments
 (0)