diff --git a/docs/modules/setup/pages/configuration.adoc b/docs/modules/setup/pages/configuration.adoc index ffe318f17..f7ecb5fc0 100644 --- a/docs/modules/setup/pages/configuration.adoc +++ b/docs/modules/setup/pages/configuration.adoc @@ -59,13 +59,15 @@ By default, Kroki is running in `SECURE` mode. ==== Some diagram libraries allow referencing external entities by URL or accessing resources from the filesystem. -For example PlantUML allows the `!import` directive to pull fragments from the filesystem or a remote URL or the standard library. +For example, PlantUML allows the `!import` directive to pull fragments from the filesystem or a remote URL or the standard library. It is the responsibility of the upstream codebases to ensure that they can be safely used without risk. Because Kroki does not perform code review of these services, our default setting is to be paranoid and block imports unless known safe. We encourage anyone running their own Kroki server to review the services security settings and select the security mode appropriate for their use case. ==== +=== PlantUML + While running in `SECURE` mode, Kroki will prevent PlantUML from including files using the `!include` or `!includeurl` directive. If you want to enable this feature, you can set the safe mode using the environment variable `KROKI_SAFE_MODE`: @@ -82,6 +84,14 @@ KROKI_PLANTUML_INCLUDE_WHITELIST:: The name of a file that consists of a list of 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. 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. +=== Structurizr + +Structurizr's restricted mode is activated unless Kroki is running in `UNSAFE` mode: + +> Run this parser in restricted mode (this stops `!include`, `!docs`, `!adrs` from working). + +If you want to enable this feature, you can set the safe mode using the global environment variable `KROKI_SAFE_MODE` or the specific environment variable `KROKI_STRUCTURIZR_SAFE_MODE` (i.e., the safe mode will only apply to Structurizr). + == Cross-origin resource sharing (CORS) By default, the following headers are allowed: diff --git a/server/src/main/java/io/kroki/server/service/Structurizr.java b/server/src/main/java/io/kroki/server/service/Structurizr.java index babfce57f..9304aa559 100644 --- a/server/src/main/java/io/kroki/server/service/Structurizr.java +++ b/server/src/main/java/io/kroki/server/service/Structurizr.java @@ -10,6 +10,7 @@ import io.kroki.server.error.BadRequestException; import io.kroki.server.error.DecodeException; import io.kroki.server.format.FileFormat; +import io.kroki.server.security.SafeMode; import io.vertx.core.AsyncResult; import io.vertx.core.Handler; import io.vertx.core.Vertx; @@ -23,12 +24,14 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.util.*; +import java.util.concurrent.Callable; import java.util.stream.Collectors; public class Structurizr implements DiagramService { private final Vertx vertx; private final StructurizrPlantUMLExporter structurizrPlantUMLExporter; + private final SafeMode safeMode; private final SourceDecoder sourceDecoder; private final PlantumlCommand plantumlCommand; @@ -48,6 +51,7 @@ public class Structurizr implements DiagramService { public Structurizr(Vertx vertx, JsonObject config) { this.vertx = vertx; + this.safeMode = SafeMode.get(config.getString("KROKI_STRUCTURIZR_SAFE_MODE", config.getString("KROKI_SAFE_MODE", "secure")), SafeMode.SECURE); this.structurizrPlantUMLExporter = new StructurizrPlantUMLExporter(); this.sourceDecoder = new SourceDecoder() { @Override @@ -75,19 +79,20 @@ public String getVersion() { @Override public void convert(String sourceDecoded, String serviceName, FileFormat fileFormat, JsonObject options, Handler> handler) { - vertx.executeBlocking(future -> { - try { - byte[] data = convert(sourceDecoded, fileFormat, options); - future.complete(data); - } catch (Exception e) { - future.fail(e); - } - }, res -> handler.handle(res.map(o -> Buffer.buffer((byte[]) o)))); + vertx.executeBlocking(() -> convert(sourceDecoded, fileFormat, options), res -> handler.handle(res.map(Buffer::buffer))); } - static byte[] convert(String source, FileFormat fileFormat, PlantumlCommand plantumlCommand, StructurizrPlantUMLExporter structurizrPlantUMLExporter, JsonObject options) throws IOException, InterruptedException { + static byte[] convert( + String source, + FileFormat fileFormat, + PlantumlCommand plantumlCommand, + StructurizrPlantUMLExporter structurizrPlantUMLExporter, + SafeMode safeMode, + JsonObject options + ) throws IOException, InterruptedException { StructurizrDslParser parser = new StructurizrDslParser(); try { + parser.setRestricted(safeMode != SafeMode.UNSAFE); parser.parse(source); ViewSet viewSet = parser.getWorkspace().getViews(); Collection views = viewSet.getViews(); @@ -137,7 +142,7 @@ static byte[] convert(String source, FileFormat fileFormat, PlantumlCommand plan if (outputOption != null) { outputOption = outputOption.trim(); } - + String diagramPlantUML; if (outputOption == null || outputOption.equals("diagram")) { diagramPlantUML = diagram.getDefinition(); @@ -161,7 +166,7 @@ static byte[] convert(String source, FileFormat fileFormat, PlantumlCommand plan } private byte[] convert(String source, FileFormat fileFormat, JsonObject options) throws IOException, InterruptedException { - return convert(source, fileFormat, this.plantumlCommand, this.structurizrPlantUMLExporter, options); + return convert(source, fileFormat, this.plantumlCommand, this.structurizrPlantUMLExporter, this.safeMode, options); } private static void applyTheme(ViewSet viewSet, StructurizrTheme theme) { diff --git a/server/src/test/java/io/kroki/server/service/StructurizrServiceTest.java b/server/src/test/java/io/kroki/server/service/StructurizrServiceTest.java index 7028f29f4..d45634b10 100644 --- a/server/src/test/java/io/kroki/server/service/StructurizrServiceTest.java +++ b/server/src/test/java/io/kroki/server/service/StructurizrServiceTest.java @@ -4,6 +4,7 @@ import io.kroki.server.DownloadPlantumlNativeImage; import io.kroki.server.error.BadRequestException; import io.kroki.server.format.FileFormat; +import io.kroki.server.security.SafeMode; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; import io.vertx.junit5.Checkpoint; @@ -25,6 +26,7 @@ import java.io.InputStreamReader; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.Objects; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -64,7 +66,7 @@ public void should_convert_getting_started_example(String output) throws IOExcep options.put("output", output); } - byte[] result = Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), options); + byte[] result = Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), SafeMode.SAFE, options); assertThat(stripComments(new String(result))).isEqualToIgnoringNewLines(expected); } else { logger.info("/usr/bin/dot not found, skipping test."); @@ -78,7 +80,7 @@ public void should_convert_bigbank_example_container_view() throws IOException, String expected = read("./bigbank.containers.expected.svg"); JsonObject options = new JsonObject(); options.put("view-key", "Containers"); - byte[] result = Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), options); + byte[] result = Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), SafeMode.SAFE, options); assertThat(stripComments(new String(result))).isEqualToIgnoringNewLines(expected); } else { logger.info("/usr/bin/dot not found, skipping test."); @@ -92,7 +94,7 @@ public void should_convert_bigbank_example_systemcontext_view() throws IOExcepti String expected = read("./bigbank.systemcontext.expected.svg"); JsonObject options = new JsonObject(); options.put("view-key", "SystemContext"); - byte[] result = Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), options); + byte[] result = Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), SafeMode.SAFE, options); assertThat(stripComments(new String(result))).isEqualToIgnoringNewLines(expected); } else { logger.info("/usr/bin/dot not found, skipping test."); @@ -107,7 +109,7 @@ public void should_convert_bigbank_example_systemcontext_legend() throws IOExcep JsonObject options = new JsonObject(); options.put("view-key", "SystemContext"); options.put("output", "legend"); - byte[] result = Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), options); + byte[] result = Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), SafeMode.SAFE, options); assertThat(stripComments(new String(result))).isEqualToIgnoringNewLines(expected); } else { logger.info("/usr/bin/dot not found, skipping test."); @@ -119,7 +121,32 @@ public void should_convert_aws_example() throws IOException, InterruptedExceptio if (Files.isExecutable(Paths.get("/usr/bin/dot"))) { String source = read("./aws.structurizr"); String expected = read("./aws.expected.svg"); - byte[] result = Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), new JsonObject()); + byte[] result = Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), SafeMode.SAFE, new JsonObject()); + assertThat(stripComments(new String(result))).isEqualToIgnoringNewLines(expected); + } else { + logger.info("/usr/bin/dot not found, skipping test."); + } + } + + @Test + public void should_convert_docs_example() throws IOException { + if (Files.isExecutable(Paths.get("/usr/bin/dot"))) { + String source = read("docs.structurizr"); + assertThatThrownBy(() -> Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), SafeMode.SAFE, new JsonObject())) + .isInstanceOf(BadRequestException.class) + .hasMessageStartingWith("Unable to parse the Structurizr DSL. !docs is not available when the parser is running in restricted mode at line 5: !docs src/test/resources/docs"); + } else { + logger.info("/usr/bin/dot not found, skipping test."); + } + } + + @Test + public void should_convert_docs_example_unsafe() throws IOException, InterruptedException { + if (Files.isExecutable(Paths.get("/usr/bin/dot"))) { + String source = read("docs.structurizr"); + String expected = read("./docs.expected.svg"); + // "docs" is not included when using the PlantUML exporter + byte[] result = Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), SafeMode.UNSAFE, new JsonObject()); assertThat(stripComments(new String(result))).isEqualToIgnoringNewLines(expected); } else { logger.info("/usr/bin/dot not found, skipping test."); @@ -131,9 +158,7 @@ public void should_throw_exception_when_view_does_not_exist() throws IOException String source = read("./bigbank.structurizr"); JsonObject options = new JsonObject(); options.put("view-key", "NonExisting"); - assertThatThrownBy(() -> { - Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), options); - }) + assertThatThrownBy(() -> Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), SafeMode.SAFE, options)) .isInstanceOf(BadRequestException.class) .hasMessage("Unable to find view for key: NonExisting."); } @@ -141,9 +166,7 @@ public void should_throw_exception_when_view_does_not_exist() throws IOException @Test public void should_throw_exception_when_diagram_is_empty() throws IOException { String source = read("./no-view.structurizr"); - assertThatThrownBy(() -> { - Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), new JsonObject()); - }) + assertThatThrownBy(() -> Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), SafeMode.SAFE, new JsonObject())) .isInstanceOf(BadRequestException.class) .hasMessage("Empty diagram, does not have any view."); } @@ -151,13 +174,20 @@ public void should_throw_exception_when_diagram_is_empty() throws IOException { @Test public void should_throw_exception_when_script_directive_used() throws IOException { String source = read("./script.structurizr"); - assertThatThrownBy(() -> { - Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), new JsonObject()); - }) + assertThatThrownBy(() -> Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), SafeMode.UNSAFE, new JsonObject())) .isInstanceOf(BadRequestException.class) .hasMessageStartingWith("Unable to parse the Structurizr DSL. Error running inline script, caused by java.lang.RuntimeException: Could not load a scripting engine for extension \"kts\" at line 5"); } + + @Test + public void should_throw_exception_when_script_directive_used_safe() throws IOException { + String source = read("./script.structurizr"); + assertThatThrownBy(() -> Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), SafeMode.SAFE, new JsonObject())) + .isInstanceOf(BadRequestException.class) + .hasMessageStartingWith("Unable to parse the Structurizr DSL. !script is not available when the parser is running in restricted mode at line 2"); + } + @Test public void should_throw_exception_when_unknown_output_specified() throws IOException { String source = read("./bigbank.structurizr"); @@ -165,9 +195,7 @@ public void should_throw_exception_when_unknown_output_specified() throws IOExce JsonObject options = new JsonObject(); options.put("output", "invalid"); - assertThatThrownBy(() -> { - Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), options); - }) + assertThatThrownBy(() -> Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), SafeMode.SAFE, options)) .isInstanceOf(BadRequestException.class) .hasMessageStartingWith("Unknown output option: invalid"); } @@ -175,7 +203,7 @@ public void should_throw_exception_when_unknown_output_specified() throws IOExce @Test public void should_preserve_styles_defined_in_workspace_while_applying_theme() throws IOException, InterruptedException { String source = read("./workspace-style-with-theme.structurizr"); - byte[] convert = Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), new JsonObject()); + byte[] convert = Structurizr.convert(source, FileFormat.SVG, plantumlCommand, new StructurizrPlantUMLExporter(), SafeMode.SAFE, new JsonObject()); assertThat(new String(convert)).isEqualTo(read("./workspace-style-with-theme.svg")); } @@ -184,7 +212,7 @@ private String stripComments(String xmlContent) { } private String read(String name) throws IOException { - try (BufferedReader buffer = new BufferedReader(new InputStreamReader(StructurizrServiceTest.class.getClassLoader().getResourceAsStream(name)))) { + try (BufferedReader buffer = new BufferedReader(new InputStreamReader(Objects.requireNonNull(StructurizrServiceTest.class.getClassLoader().getResourceAsStream(name))))) { return buffer.lines().collect(Collectors.joining("\n")); } } diff --git a/server/src/test/resources/docs.expected.svg b/server/src/test/resources/docs.expected.svg new file mode 100644 index 000000000..fdf876e01 --- /dev/null +++ b/server/src/test/resources/docs.expected.svg @@ -0,0 +1 @@ +Software System - System ContextUser[Person]Software System[Software System]Uses diff --git a/server/src/test/resources/docs.structurizr b/server/src/test/resources/docs.structurizr new file mode 100644 index 000000000..45bbf2a76 --- /dev/null +++ b/server/src/test/resources/docs.structurizr @@ -0,0 +1,18 @@ +workspace { + model { + user = person "User" + softwareSystem = softwareSystem "Software System" { + !docs src/test/resources/docs + } + user -> softwareSystem "Uses" + } + + views { + systemContext softwareSystem { + include * + autolayout + } + + theme default + } +} diff --git a/server/src/test/resources/docs/index.md b/server/src/test/resources/docs/index.md new file mode 100644 index 000000000..008cd3139 --- /dev/null +++ b/server/src/test/resources/docs/index.md @@ -0,0 +1,6 @@ +# System documentation + + +## Workspace + +This is a *workspace*!