diff --git a/src/main/java/org/unlaxer/tinyexpression/instances/FileBaseTinyExpressionInstancesCache.java b/src/main/java/org/unlaxer/tinyexpression/instances/FileBaseTinyExpressionInstancesCache.java index 9b4ecf22..449df10d 100644 --- a/src/main/java/org/unlaxer/tinyexpression/instances/FileBaseTinyExpressionInstancesCache.java +++ b/src/main/java/org/unlaxer/tinyexpression/instances/FileBaseTinyExpressionInstancesCache.java @@ -35,10 +35,22 @@ public class FileBaseTinyExpressionInstancesCache implements TinyExpressionInsta public FileBaseTinyExpressionInstancesCache(Path rootFolder , FormulaInfoAdditionalFields formulaInfoAdditionalFields) { super(); - this.rootFolder = rootFolder; + this.rootFolder = rootFolder != null ? rootFolder.toAbsolutePath().normalize() : null; this.formulaInfoAdditionalFields = formulaInfoAdditionalFields; } + static Path resolveUnderRoot(Path rootFolder, String tenantIdString) { + if (rootFolder == null) { + throw new IllegalArgumentException("rootFolder is not set"); + } + Path resolved = rootFolder.resolve(tenantIdString).normalize(); + if (!resolved.startsWith(rootFolder)) { + throw new IllegalArgumentException( + "tenantId escapes rootFolder: " + tenantIdString); + } + return resolved; + } + @Override public boolean clearCache(TenantID tenantID) { calculatorsByTenantId.remove(tenantID); @@ -51,7 +63,7 @@ public List cache(TenantID tenantID, Comparator comparat List cache = calculatorsByTenantId.computeIfAbsent(tenantID, tenantId->{ - Path resolve = rootFolder.resolve(tenantId.asString()).resolve(FILENAME); + Path resolve = resolveUnderRoot(rootFolder, tenantId.asString()).resolve(FILENAME); try(InputStream inputStream = Files.newInputStream(resolve);){ Try parse = FormulaInfoList.parse(inputStream, formulaInfoAdditionalFields, classLoader); diff --git a/src/main/java/org/unlaxer/tinyexpression/mcp/McpServer.java b/src/main/java/org/unlaxer/tinyexpression/mcp/McpServer.java index 609cde15..9340f5d5 100644 --- a/src/main/java/org/unlaxer/tinyexpression/mcp/McpServer.java +++ b/src/main/java/org/unlaxer/tinyexpression/mcp/McpServer.java @@ -41,6 +41,8 @@ public class McpServer { private static final ObjectMapper MAPPER = new ObjectMapper(); + static final long MAX_REQUEST_BODY_BYTES = 16L * 1024 * 1024; + private final HttpServer server; private final boolean allowJavaCode; @@ -85,7 +87,13 @@ public void handle(HttpExchange ex) throws IOException { sessionId = UUID.randomUUID().toString(); } - String bodyStr = readBody(ex); + String bodyStr; + try { + bodyStr = readBody(ex); + } catch (IOException e) { + sendWithSession(ex, 413, MAPPER.writeValueAsString(errorResp(null, -32603, "Request body too large")), sessionId); + return; + } JsonNode req; try { req = MAPPER.readTree(bodyStr); @@ -859,8 +867,19 @@ private static class BatchFormula { // ─── HTTP helpers ────────────────────────────────────────── private static String readBody(HttpExchange ex) throws IOException { + String contentLength = ex.getRequestHeaders().getFirst("Content-Length"); + if (contentLength != null) { + try { + long len = Long.parseLong(contentLength.trim()); + if (len > MAX_REQUEST_BODY_BYTES) { + throw new IOException("Request body too large: " + len + " > " + MAX_REQUEST_BODY_BYTES); + } + } catch (NumberFormatException ignored) { + // fall through to bounded read + } + } try (InputStream is = ex.getRequestBody()) { - return new String(is.readAllBytes(), StandardCharsets.UTF_8); + return new String(is.readNBytes((int) MAX_REQUEST_BODY_BYTES), StandardCharsets.UTF_8); } } diff --git a/src/test/java/org/unlaxer/tinyexpression/instances/FileBaseTinyExpressionInstancesCacheTest.java b/src/test/java/org/unlaxer/tinyexpression/instances/FileBaseTinyExpressionInstancesCacheTest.java new file mode 100644 index 00000000..52054f28 --- /dev/null +++ b/src/test/java/org/unlaxer/tinyexpression/instances/FileBaseTinyExpressionInstancesCacheTest.java @@ -0,0 +1,41 @@ +package org.unlaxer.tinyexpression.instances; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.junit.Test; + +public class FileBaseTinyExpressionInstancesCacheTest { + + @Test + public void resolveUnderRoot_acceptsSimpleId() { + Path root = Paths.get("/tmp/formula-root"); + Path resolved = FileBaseTinyExpressionInstancesCache.resolveUnderRoot(root, "69"); + assertEquals(root.resolve("69"), resolved); + assertTrue(resolved.startsWith(root)); + } + + @Test + public void resolveUnderRoot_rejectsTraversal() { + Path root = Paths.get("/tmp/formula-root"); + assertThrows(IllegalArgumentException.class, + () -> FileBaseTinyExpressionInstancesCache.resolveUnderRoot(root, "../../etc")); + } + + @Test + public void resolveUnderRoot_rejectsAbsolutePath() { + Path root = Paths.get("/tmp/formula-root"); + assertThrows(IllegalArgumentException.class, + () -> FileBaseTinyExpressionInstancesCache.resolveUnderRoot(root, "/etc")); + } + + @Test + public void resolveUnderRoot_rejectsNullRoot() { + assertThrows(IllegalArgumentException.class, + () -> FileBaseTinyExpressionInstancesCache.resolveUnderRoot(null, "69")); + } +} diff --git a/src/test/java/org/unlaxer/tinyexpression/mcp/McpServerTest.java b/src/test/java/org/unlaxer/tinyexpression/mcp/McpServerTest.java index 11c832e0..fb75a7d1 100644 --- a/src/test/java/org/unlaxer/tinyexpression/mcp/McpServerTest.java +++ b/src/test/java/org/unlaxer/tinyexpression/mcp/McpServerTest.java @@ -270,6 +270,22 @@ public void resourcesRead_guide_returnsMarkdown() throws Exception { assertTrue(text.contains("tinyexpression MCP")); } + @Test + public void oversizedBody_rejectedWith413() throws Exception { + long max = McpServer.MAX_REQUEST_BODY_BYTES; + byte[] padding = new byte[(int) Math.min(max + 1024, Integer.MAX_VALUE - 8)]; + java.util.Arrays.fill(padding, (byte) ' '); + String body = new String(padding, java.nio.charset.StandardCharsets.UTF_8); + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + "/mcp")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + assertEquals(413, resp.statusCode()); + } + // ─── helpers ────────────────────────────────────────────────── private JsonNode rpc(String method, JsonNode params) throws Exception {