This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/master by this push:
     new 7dab55a50 Simplify identifier validation patterns to a single 
character class (#873)
7dab55a50 is described below

commit 7dab55a5068a0ba169367b73f8e24492ea4e8452
Author: Serge Huber <[email protected]>
AuthorDate: Thu Sep 24 19:54:47 2026 +0200

    Simplify identifier validation patterns to a single character class (#873)
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../META-INF/cxs/schemas/events/event.json         |  10 +-
 .../resources/META-INF/cxs/schemas/items/item.json |   2 +-
 .../unomi/schema/impl/SchemaPatternSafetyTest.java | 116 +++++++++++++++++++++
 .../test/java/org/apache/unomi/itests/AllITs.java  |   1 +
 .../unomi/itests/SchemaIdPatternSafetyIT.java      | 104 ++++++++++++++++++
 .../META-INF/cxs/schemas/rest/requestIds.json      |   6 +-
 6 files changed, 230 insertions(+), 9 deletions(-)

diff --git 
a/extensions/json-schema/services/src/main/resources/META-INF/cxs/schemas/events/event.json
 
b/extensions/json-schema/services/src/main/resources/META-INF/cxs/schemas/events/event.json
index b4673a760..2e9f75c6c 100644
--- 
a/extensions/json-schema/services/src/main/resources/META-INF/cxs/schemas/events/event.json
+++ 
b/extensions/json-schema/services/src/main/resources/META-INF/cxs/schemas/events/event.json
@@ -13,24 +13,24 @@
   "properties" : {
     "eventType" : {
       "type" : "string",
-      "pattern" : "^(\\w|[-_@\\.]){0,60}$"
+      "pattern" : "^[\\[email protected]]{0,60}$"
     },
     "profileId" : {
       "type" : [ "string"],
-      "pattern" : "^(\\w|[-_@\\.]){0,60}$"
+      "pattern" : "^[\\[email protected]]{0,60}$"
     },
     "sessionId" : {
       "type" : [ "string"],
-      "pattern" : "^(\\w|[-_@\\.]){0,60}$"
+      "pattern" : "^[\\[email protected]]{0,60}$"
     },
     "scope" : {
       "type" : [ "string"],
       "validateScope": true,
-      "pattern" : "^(\\w|[-_@\\.]){0,60}$"
+      "pattern" : "^[\\[email protected]]{0,60}$"
     },
     "sourceId" : {
       "type" : [ "string"],
-      "pattern" : "^(\\w|[-_@\\.]){0,60}$"
+      "pattern" : "^[\\[email protected]]{0,60}$"
     },
     "persistent" : {
       "type" : "boolean"
diff --git 
a/extensions/json-schema/services/src/main/resources/META-INF/cxs/schemas/items/item.json
 
b/extensions/json-schema/services/src/main/resources/META-INF/cxs/schemas/items/item.json
index 4f46c3bef..e98b21763 100644
--- 
a/extensions/json-schema/services/src/main/resources/META-INF/cxs/schemas/items/item.json
+++ 
b/extensions/json-schema/services/src/main/resources/META-INF/cxs/schemas/items/item.json
@@ -12,7 +12,7 @@
   "properties" : {
     "itemId" : {
       "type" : ["null","string"],
-      "pattern" : "^(\\w|[-_@\\.]){0,60}$",
+      "pattern" : "^[\\[email protected]]{0,60}$",
       "description" : "The identifier for the item"
     },
     "itemType" : {
diff --git 
a/extensions/json-schema/services/src/test/java/org/apache/unomi/schema/impl/SchemaPatternSafetyTest.java
 
b/extensions/json-schema/services/src/test/java/org/apache/unomi/schema/impl/SchemaPatternSafetyTest.java
new file mode 100644
index 000000000..db9591779
--- /dev/null
+++ 
b/extensions/json-schema/services/src/test/java/org/apache/unomi/schema/impl/SchemaPatternSafetyTest.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.schema.impl;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.Test;
+
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Guards the identifier-validation patterns in the shipped schemas against a 
linear-time regression.
+ *
+ * These patterns run on identifiers taken from public tracking requests, so 
they must evaluate in
+ * linear time. A pattern with overlapping quantified alternatives (for example
+ * {@code ^(\w|[-_@\.]){0,60}$}, where {@code _} matches both branches) 
degrades to exponential
+ * backtracking on the java.util.regex engine, so a bounded id can pin a 
validation thread. This test
+ * fails if any shipped id pattern exhibits that behaviour, and also pins down 
the accepted/rejected
+ * identifier space so a "simplification" cannot quietly change validation 
semantics.
+ */
+public class SchemaPatternSafetyTest {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private static final String[] SCHEMA_RESOURCES = {
+            "/META-INF/cxs/schemas/events/event.json",
+            "/META-INF/cxs/schemas/items/item.json"
+    };
+
+    private List<String> collectPatterns(String resource) throws Exception {
+        try (InputStream is = getClass().getResourceAsStream(resource)) {
+            assertTrue("Missing shipped schema resource " + resource, is != 
null);
+            List<String> patterns = new ArrayList<>();
+            collectPatterns(MAPPER.readTree(is), patterns);
+            return patterns;
+        }
+    }
+
+    private void collectPatterns(JsonNode node, List<String> patterns) {
+        if (node.isObject()) {
+            for (Iterator<Map.Entry<String, JsonNode>> it = node.fields(); 
it.hasNext(); ) {
+                Map.Entry<String, JsonNode> field = it.next();
+                if ("pattern".equals(field.getKey()) && 
field.getValue().isTextual()) {
+                    patterns.add(field.getValue().asText());
+                } else {
+                    collectPatterns(field.getValue(), patterns);
+                }
+            }
+        } else if (node.isArray()) {
+            for (JsonNode child : node) {
+                collectPatterns(child, patterns);
+            }
+        }
+    }
+
+    @Test(timeout = 5000)
+    public void idPatternsAreLinearTimeAndKeepSemantics() throws Exception {
+        // The classic exponential-backtracking probe for the old id pattern: 
a run of characters that
+        // are valid under both alternatives, followed by one invalid 
character forcing full backtracking.
+        StringBuilder probe = new StringBuilder();
+        for (int i = 0; i < 59; i++) {
+            probe.append('_');
+        }
+        probe.append('!');
+
+        List<String> patterns = new ArrayList<>();
+        for (String resource : SCHEMA_RESOURCES) {
+            patterns.addAll(collectPatterns(resource));
+        }
+        assertFalse("Expected id patterns in the shipped schemas", 
patterns.isEmpty());
+
+        for (String patternString : patterns) {
+            Pattern pattern = Pattern.compile(patternString);
+
+            // Must finish effectively instantly; the test-level timeout 
catches exponential backtracking.
+            assertFalse("Probe input must be rejected by " + patternString,
+                    pattern.matcher(probe).matches());
+
+            // Previously accepted identifiers must still be accepted.
+            assertTrue(pattern.matcher("[email protected]").matches());
+            assertTrue(pattern.matcher("").matches());
+            assertTrue(pattern.matcher("a.b-c_d@e").matches());
+
+            // Previously rejected identifiers must still be rejected.
+            assertFalse(pattern.matcher("white space").matches());
+            assertFalse(pattern.matcher("slash/id").matches());
+            StringBuilder tooLong = new StringBuilder();
+            for (int i = 0; i < 61; i++) {
+                tooLong.append('a');
+            }
+            assertFalse(pattern.matcher(tooLong).matches());
+        }
+    }
+}
diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java 
b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
index f13057981..19b07796b 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -57,6 +57,7 @@ import org.junit.runners.Suite.SuiteClasses;
         PatchIT.class,
         ContextServletIT.class,
         ConditionRegexSafetyIT.class,
+        SchemaIdPatternSafetyIT.class,
         ContextEndpointBaselineIT.class,
         SecurityIT.class,
         RuleServiceIT.class,
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/SchemaIdPatternSafetyIT.java 
b/itests/src/test/java/org/apache/unomi/itests/SchemaIdPatternSafetyIT.java
new file mode 100644
index 000000000..e8ba6783e
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/SchemaIdPatternSafetyIT.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.itests;
+
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
+import org.ops4j.pax.exam.spi.reactors.PerSuite;
+
+/**
+ * Identifiers arriving on the public tracking endpoints are matched against a 
shipped validation
+ * pattern before anything else happens, without administrator credentials. If 
that match were
+ * unbounded, one small request would occupy a request thread indefinitely.
+ * <p>
+ * These tests assert the property that matters operationally — the request 
<em>returns</em> — rather
+ * than any particular status code. The inputs used are ones that took 
exponential time before the fix
+ * (seconds at 26 characters, and far beyond that at the lengths used here), 
so a regression does not
+ * produce a wrong answer, it produces a request that never comes back. Each 
is therefore bounded by a
+ * timeout: if the boundary regresses, the test fails by timing out rather 
than by assertion.
+ */
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class SchemaIdPatternSafetyIT extends BaseIT {
+
+    private final static String CONTEXT_URL = "/cxs/context.json";
+
+    private final static String UNOMI_API_KEY_HTTP_HEADER_KEY = 
"X-Unomi-Api-Key";
+
+    /** Generous next to the sub-millisecond a bounded evaluation needs, 
decisive against an unbounded one. */
+    private final static int RESPONSE_TIMEOUT_MS = 30_000;
+
+    /**
+     * An identifier built to defeat the shipped id-validation pattern: a run 
of characters that the
+     * pattern's alternatives both accept, followed by one it rejects. Before 
the fix this forced the
+     * matcher to explore every division of the run.
+     */
+    @Test(timeout = RESPONSE_TIMEOUT_MS)
+    public void testMaliciousProfileIdIsValidatedInBoundedTime() throws 
Exception {
+        final StringBuilder maliciousId = new StringBuilder();
+        for (int i = 0; i < 55; i++) {
+            maliciousId.append('_');
+        }
+        maliciousId.append('!');
+
+        final String payload = "{\"profileId\":\"" + maliciousId + 
"\",\"sessionId\":\"regex-safety-session\","
+                + "\"requiredProfileProperties\":[\"*\"],\"events\":[]}";
+
+        assertReturnsPromptly(payload);
+    }
+
+    /** The same shape supplied as a sessionId, which is validated by the same 
pattern. */
+    @Test(timeout = RESPONSE_TIMEOUT_MS)
+    public void testMaliciousSessionIdIsValidatedInBoundedTime() throws 
Exception {
+        final StringBuilder maliciousId = new StringBuilder();
+        for (int i = 0; i < 55; i++) {
+            maliciousId.append('_');
+        }
+        maliciousId.append('!');
+
+        final String payload = "{\"sessionId\":\"" + maliciousId + 
"\",\"requiredProfileProperties\":[\"*\"],"
+                + "\"events\":[]}";
+
+        assertReturnsPromptly(payload);
+    }
+
+    /**
+     * Sends the payload to the public context endpoint and requires an 
answer. Any HTTP status is
+     * acceptable — rejecting the input and processing it are both fine 
outcomes. What is not acceptable
+     * is the request not completing, which is exactly what an unbounded 
evaluation causes.
+     */
+    private void assertReturnsPromptly(final String payload) throws Exception {
+        final HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+        request.setEntity(new StringEntity(payload, 
ContentType.APPLICATION_JSON));
+
+        final long startedAt = System.currentTimeMillis();
+        try (CloseableHttpResponse response = httpClient.execute(request)) {
+            final long elapsed = System.currentTimeMillis() - startedAt;
+            Assert.assertNotNull("The public endpoint must answer rather than 
hang", response);
+            Assert.assertTrue("The public endpoint answered, but took " + 
elapsed
+                    + "ms, which suggests the evaluation is no longer 
bounded", elapsed < RESPONSE_TIMEOUT_MS);
+        }
+    }
+}
diff --git a/rest/src/main/resources/META-INF/cxs/schemas/rest/requestIds.json 
b/rest/src/main/resources/META-INF/cxs/schemas/rest/requestIds.json
index 366a79524..ec1cf3808 100644
--- a/rest/src/main/resources/META-INF/cxs/schemas/rest/requestIds.json
+++ b/rest/src/main/resources/META-INF/cxs/schemas/rest/requestIds.json
@@ -12,15 +12,15 @@
   "properties": {
     "sessionId": {
       "type": ["null", "string"],
-      "pattern" : "^(\\w|[-_@\\.]){0,60}$"
+      "pattern" : "^[\\[email protected]]{0,60}$"
     },
     "profileId": {
       "type": ["null", "string"],
-      "pattern" : "^(\\w|[-_@\\.]){0,60}$"
+      "pattern" : "^[\\[email protected]]{0,60}$"
     },
     "personaId" : {
       "type" : ["null", "string"],
-      "pattern" : "^(\\w|[-_@\\.]){0,60}$"
+      "pattern" : "^[\\[email protected]]{0,60}$"
     }
   }
 }

Reply via email to