From cb9aa76eb07105eca32bdcb5082449d33c261563 Mon Sep 17 00:00:00 2001 From: abnerchiu Date: Fri, 22 May 2026 23:49:38 +0800 Subject: [PATCH] feat: add maxFieldValueLength guard to prevent matching against large field values (e.g. base64 images) - Add configurable echo.matcher.max-field-value-length (default 2MB) - Skip matchValue when field value exceeds threshold in JSON/JsonPath/XML/XPath paths - Increase Undertow max-http-post-size to 10MB for large request bodies - Add unit tests for field value length guard --- .../com/echo/service/ConditionMatcher.java | 35 +++++++++- src/main/resources/application.yml | 3 + .../service/ConditionMatcherExtendedTest.java | 65 +++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/echo/service/ConditionMatcher.java b/src/main/java/com/echo/service/ConditionMatcher.java index ae06153..91c1f33 100644 --- a/src/main/java/com/echo/service/ConditionMatcher.java +++ b/src/main/java/com/echo/service/ConditionMatcher.java @@ -57,6 +57,19 @@ public class ConditionMatcher { private static final int MAX_JSON_SIZE = 10 * 1024 * 1024; // 10MB private static final int MAX_REGEX_INPUT_LENGTH = 10000; + /** 單一欄位值長度上限,超過則跳過比對(防止 base64 等大值拖慢匹配) */ + private final int maxFieldValueLength; + + /** 預設建構子,使用 2MB 上限(供測試及非 Spring 環境使用) */ + public ConditionMatcher() { + this(2 * 1024 * 1024); + } + + public ConditionMatcher( + @org.springframework.beans.factory.annotation.Value("${echo.matcher.max-field-value-length:2097152}") int maxFieldValueLength) { + this.maxFieldValueLength = maxFieldValueLength; + } + private final ObjectMapper objectMapper = new ObjectMapper(); private final Cache patternCache = Caffeine.newBuilder() .maximumSize(500) @@ -515,7 +528,12 @@ private boolean matchXPathPrepared(String condition, Document doc) { try { NodeList nodes = findXmlNodes(parsed.field(), doc); for (int i = 0; i < nodes.getLength(); i++) { - if (matchValue(parsed, nodes.item(i).getTextContent().trim())) { + String text = nodes.item(i).getTextContent().trim(); + if (text.length() > maxFieldValueLength) { + log.debug("Skipping XPath match: node value too large ({} chars), condition: {}", text.length(), condition); + continue; + } + if (matchValue(parsed, text)) { return true; } } @@ -530,7 +548,12 @@ private boolean matchXmlPrepared(String condition, Document doc) { try { NodeList nodes = findXmlNodes(parsed.field(), doc); for (int i = 0; i < nodes.getLength(); i++) { - if (matchValue(parsed, nodes.item(i).getTextContent().trim())) { + String text = nodes.item(i).getTextContent().trim(); + if (text.length() > maxFieldValueLength) { + log.debug("Skipping XML match: node value too large ({} chars), condition: {}", text.length(), condition); + continue; + } + if (matchValue(parsed, text)) { return true; } } @@ -564,6 +587,10 @@ private boolean matchJsonPrepared(String condition, JsonNode root) { return parsed.op() == Op.NOT_EQUAL; } String actual = node.isTextual() ? node.asText() : node.toString(); + if (actual.length() > maxFieldValueLength) { + log.debug("Skipping match: field value too large ({} chars), condition: {}", actual.length(), condition); + return false; + } return matchValue(parsed, actual); } catch (Exception e) { return false; @@ -575,6 +602,10 @@ private boolean matchJsonPath(String condition, String body) { var parsed = parseCondition(condition); Object result = JsonPath.read(body, parsed.field); String actual = result != null ? result.toString() : null; + if (actual != null && actual.length() > maxFieldValueLength) { + log.debug("Skipping JsonPath match: value too large ({} chars), condition: {}", actual.length(), condition); + return false; + } return matchValue(parsed, actual); } catch (PathNotFoundException e) { return false; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 44a5693..9473d5f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,7 @@ server: port: 8080 undertow: + max-http-post-size: 10485760 # 10MB,允許帶 base64 圖片的大 request 進入 threads: worker: 32 # Undertow worker threads,可用環境變數 SERVER_UNDERTOW_THREADS_WORKER 覆蓋 servlet: @@ -117,6 +118,8 @@ echo: flush-interval-seconds: 5 # 時間觸發間隔(秒) analysis: enabled: true # 啟用匹配鏈分析(near-miss / shadowed 偵測) + matcher: + max-field-value-length: 2097152 # 單一欄位值長度上限 (bytes),超過跳過比對,預設 2MB builtin-account: self-registration: false # 設為 true 允許未登入使用者自行申請帳號 ldap: diff --git a/src/test/java/com/echo/service/ConditionMatcherExtendedTest.java b/src/test/java/com/echo/service/ConditionMatcherExtendedTest.java index dbe6665..b2659cd 100644 --- a/src/test/java/com/echo/service/ConditionMatcherExtendedTest.java +++ b/src/test/java/com/echo/service/ConditionMatcherExtendedTest.java @@ -186,4 +186,69 @@ void regex_inputTooLong_shouldReturnFalse() { void cleanup_shouldNotThrow() { matcher.cleanup(); } + + // ========== maxFieldValueLength 防呆測試 ========== + + @Test + void jsonPrepared_fieldValueTooLarge_shouldReturnFalse() { + // 使用小閾值的 matcher 來測試 + ConditionMatcher smallMatcher = new ConditionMatcher(100); + String largeValue = "x".repeat(200); + String json = "{\"userId\":\"U001\",\"image\":\"" + largeValue + "\"}"; + PreparedBody prepared = smallMatcher.prepareBody(json); + + // image 欄位超過 100 字元,應該跳過 + assertThat(smallMatcher.matchesPrepared("image=something", null, null, prepared, null, null)).isFalse(); + // userId 欄位正常大小,應該正常匹配 + assertThat(smallMatcher.matchesPrepared("userId=U001", null, null, prepared, null, null)).isTrue(); + } + + @Test + void jsonPath_fieldValueTooLarge_shouldReturnFalse() { + ConditionMatcher smallMatcher = new ConditionMatcher(100); + String largeValue = "x".repeat(200); + String json = "{\"data\":{\"image\":\"" + largeValue + "\",\"id\":\"A1\"}}"; + PreparedBody prepared = smallMatcher.prepareBody(json); + + // JsonPath 取到的值超過閾值 + assertThat(smallMatcher.matchesPrepared("$.data.image=something", null, null, prepared, null, null)).isFalse(); + // 正常欄位 + assertThat(smallMatcher.matchesPrepared("$.data.id=A1", null, null, prepared, null, null)).isTrue(); + } + + @Test + void xmlPrepared_nodeValueTooLarge_shouldReturnFalse() { + ConditionMatcher smallMatcher = new ConditionMatcher(100); + String largeValue = "x".repeat(200); + String xml = "" + largeValue + "A1"; + PreparedBody prepared = smallMatcher.prepareBody(xml); + + // XML 節點值超過閾值 + assertThat(smallMatcher.matchesPrepared("image=something", null, null, prepared, null, null)).isFalse(); + // 正常節點 + assertThat(smallMatcher.matchesPrepared("id=A1", null, null, prepared, null, null)).isTrue(); + } + + @Test + void xpathPrepared_nodeValueTooLarge_shouldReturnFalse() { + ConditionMatcher smallMatcher = new ConditionMatcher(100); + String largeValue = "x".repeat(200); + String xml = "" + largeValue + "A1"; + PreparedBody prepared = smallMatcher.prepareBody(xml); + + // XPath 取到的值超過閾值 + assertThat(smallMatcher.matchesPrepared("//image=something", null, null, prepared, null, null)).isFalse(); + // 正常 XPath + assertThat(smallMatcher.matchesPrepared("//id=A1", null, null, prepared, null, null)).isTrue(); + } + + @Test + void defaultMatcher_2MB_shouldAllowNormalFields() { + // 預設 matcher (2MB 閾值) 對正常欄位不受影響 + String json = "{\"userId\":\"U001\",\"name\":\"test\"}"; + PreparedBody prepared = matcher.prepareBody(json); + + assertThat(matcher.matchesPrepared("userId=U001", null, null, prepared, null, null)).isTrue(); + assertThat(matcher.matchesPrepared("name=test", null, null, prepared, null, null)).isTrue(); + } }