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

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


The following commit(s) were added to refs/heads/master by this push:
     new 8d1e9c5323 Make JSON-schema regex validation interruptible so a 
tripped MCP DoS-guard reclaims its worker thread (TODO-359)
8d1e9c5323 is described below

commit 8d1e9c53235b681a24b8e83eb0cf773d7b13d899
Author: James Bognar <[email protected]>
AuthorDate: Thu Aug 13 21:48:51 2026 -0400

    Make JSON-schema regex validation interruptible so a tripped MCP DoS-guard 
reclaims its worker thread (TODO-359)
    
    JsonSchemaValidator.validateString now feeds pattern matching through an 
InterruptibleCharSequence that samples Thread.isInterrupted() every 4096 charAt 
reads and aborts the match. java.util.regex.Matcher does not observe 
interrupts, so previously a catastrophically-backtracking pattern kept a 
McpSchemaSafety VALIDATION_POOL worker pinning a core after the budget trip 
already returned the client a fast -32602 (a mild DoS residual: N such requests 
could pin N cores). With the wrapper,  [...]
---
 .../bean/jsonschema/JsonSchemaValidator.java       | 91 +++++++++++++++++++++-
 .../rest/server/mcp/v20260728/McpSchemaSafety.java | 11 +++
 .../server/mcp/v20260728/McpSchemaSafety_Test.java | 77 +++++++++++++++++-
 3 files changed, 176 insertions(+), 3 deletions(-)

diff --git 
a/juneau-bean/juneau-bean-jsonschema/src/main/java/org/apache/juneau/bean/jsonschema/JsonSchemaValidator.java
 
b/juneau-bean/juneau-bean-jsonschema/src/main/java/org/apache/juneau/bean/jsonschema/JsonSchemaValidator.java
index c8e650c912..210b05355f 100644
--- 
a/juneau-bean/juneau-bean-jsonschema/src/main/java/org/apache/juneau/bean/jsonschema/JsonSchemaValidator.java
+++ 
b/juneau-bean/juneau-bean-jsonschema/src/main/java/org/apache/juneau/bean/jsonschema/JsonSchemaValidator.java
@@ -99,6 +99,17 @@ import org.apache.juneau.marshall.marshaller.*;
  * Instances are immutable and safe to share across threads.  The compiled 
{@link Pattern} for the {@code pattern}
  * keyword is cached on construction.
  *
+ * <h5 class='section'>Interruptible {@code pattern} matching:</h5>
+ * <p>
+ * A {@code pattern} keyword can encode a catastrophically-backtracking 
regular expression that a hostile input can
+ * drive into effectively-unbounded work. {@link java.util.regex.Matcher} 
never checks {@link Thread#isInterrupted()},
+ * so such a match cannot be stopped by interrupting the matching thread - it 
runs to completion, pinning a core even
+ * after the caller has given up. To make a runaway match abortable, the 
string is fed to the matcher through an
+ * {@link InterruptibleCharSequence}: because the matcher reads its input 
exclusively via {@link CharSequence#charAt(int)},
+ * every character read periodically samples the thread's interrupt status 
and, once set, throws to unwind the match
+ * promptly. A caller that bounds validation on a worker thread (for example 
an MCP {@code tools/call} DoS guard) can
+ * therefore reclaim the thread by interrupting it, rather than leaking a core 
to a match that ignores cancellation.
+ *
  * <h5 class='section'>See Also:</h5><ul>
  *     <li class='link'><a 
href="https://json-schema.org/draft/2020-12/json-schema-validation.html";>JSON 
Schema 2020-12 Validation</a>
  * </ul>
@@ -295,7 +306,7 @@ public final class JsonSchemaValidator implements 
PropertyValidator {
                var pattern = patternOverride;
                if (pattern == null && nn(s.getPattern()))
                        pattern = Pattern.compile(s.getPattern());
-               if (pattern != null && ! pattern.matcher(value).find())
+               if (pattern != null && ! pattern.matcher(new 
InterruptibleCharSequence(value)).find())
                        throw new SchemaValidationException("Value '%s' does 
not match pattern '%s'.", value, s.getPattern());
        }
 
@@ -455,4 +466,82 @@ public final class JsonSchemaValidator implements 
PropertyValidator {
                        return null;
                }
        }
+
+       // 
=================================================================================================================
+       // Interruptible pattern matching
+       // 
=================================================================================================================
+
+       /**
+        * A read-only {@link CharSequence} view over the string being matched 
that lets a runaway {@link Matcher} be
+        * aborted by interrupting the matching thread.
+        *
+        * <p>
+        * {@link Matcher} reads its input exclusively through {@link 
#charAt(int)} and never observes the thread's
+        * interrupt status, so a catastrophically-backtracking pattern cannot 
be stopped by {@link Thread#interrupt()}
+        * alone. Interposing this sequence makes every {@link 
#CHECK_INTERVAL}-th character read sample
+        * {@link Thread#isInterrupted()} and throw {@link 
InterruptedMatchException} once it is set, unwinding the match
+        * promptly. The interrupt status is checked without clearing it, so an 
outer caller can still observe it.
+        *
+        * <p>
+        * Instances are single-use and not thread-safe: a {@link Matcher} 
drives its input from one thread, which is the
+        * same thread whose interrupt status is being sampled.
+        */
+       private static final class InterruptibleCharSequence implements 
CharSequence {
+
+               /**
+                * Number of character reads between interrupt checks. Large 
enough that the sampling overhead is negligible
+                * against the matcher's per-character work, small enough that 
an interrupt aborts a runaway match within
+                * microseconds (catastrophic backtracking re-reads characters 
an enormous number of times).
+                */
+               private static final int CHECK_INTERVAL = 1 << 12;
+
+               private final CharSequence delegate;
+               private int readsUntilCheck = CHECK_INTERVAL;
+
+               InterruptibleCharSequence(CharSequence delegate) {
+                       this.delegate = delegate;
+               }
+
+               @Override
+               public char charAt(int index) {
+                       if (--readsUntilCheck <= 0) {
+                               readsUntilCheck = CHECK_INTERVAL;
+                               if (Thread.currentThread().isInterrupted())
+                                       throw new InterruptedMatchException();
+                       }
+                       return delegate.charAt(index);
+               }
+
+               @Override
+               public int length() {
+                       return delegate.length();
+               }
+
+               @Override
+               public CharSequence subSequence(int start, int end) {
+                       return delegate.subSequence(start, end);
+               }
+
+               @Override
+               public String toString() {
+                       return delegate.toString();
+               }
+       }
+
+       /**
+        * Thrown by {@link InterruptibleCharSequence} to abort a {@code 
pattern} match whose thread has been interrupted.
+        *
+        * <p>
+        * This is control flow rather than a reportable error: the stack trace 
is suppressed since the abort point (deep
+        * inside {@link Matcher}) carries no useful diagnostic, and the only 
thing that interrupts a validating thread is
+        * a caller cancelling the work.
+        */
+       private static final class InterruptedMatchException extends 
RuntimeException {
+
+               private static final long serialVersionUID = 1L;
+
+               InterruptedMatchException() {
+                       super(null, null, false, false);
+               }
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java
index c17065ab14..3336430f29 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java
@@ -53,6 +53,17 @@ import org.apache.juneau.rest.server.mcp.McpSchema;
  * time, the task is cancelled and a {@code -32602} error is raised instead of 
hanging the request thread.
  *
  * <p>
+ * <b>Cancellation actually reclaims the worker thread.</b> When the budget 
trips, {@link #awaitBounded} calls
+ * {@link Future#cancel(boolean) future.cancel(true)}, which interrupts the 
validating thread. A
+ * {@link java.util.regex.Matcher} does not observe {@link Thread#interrupt()} 
on its own, so historically a runaway
+ * regex kept burning a core in the background even though the client already 
had its fast {@code -32602} - a mild DoS
+ * residual where a flood of such requests could pin a core each. {@link 
JsonSchemaValidator} now feeds the matched
+ * string through an interruptible {@link CharSequence} (see its class notes), 
so an interrupt aborts a
+ * catastrophically-backtracking match promptly and the pool thread returns to 
idle rather than spinning. The external
+ * contract is unchanged: a genuine overrun still returns the same {@code 
-32602}, and the CPU-time budget with its
+ * wall-clock fallback is untouched.
+ *
+ * <p>
  * <b>The compute budget is charged against the validating thread's actual CPU 
time, not wall-clock time.</b>
  * The DoS threat being defended against is a schema that burns CPU 
(catastrophic backtracking, quadratic
  * blowups); the amount of CPU a validation consumes is exactly what that 
budget should cap. Measuring
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java
index fd4855c23f..40371222f8 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java
@@ -20,6 +20,7 @@ import static org.apache.juneau.test.bct.BctAssertions.*;
 import static org.junit.jupiter.api.Assertions.*;
 
 import java.io.*;
+import java.lang.management.*;
 import java.util.*;
 import java.util.concurrent.*;
 import java.util.concurrent.atomic.*;
@@ -185,8 +186,7 @@ class McpSchemaSafety_Test {
        @Test
        void d02_schedulingLatency_notCountedAgainstComputeBudget() {
                // Exercises McpSchemaSafety.awaitBounded() directly (rather 
than saturating the shared
-               // VALIDATION_POOL, which other tests in this class can leave 
with a permanently-stuck thread since a
-               // catastrophically-backtracking regex match is not 
interruptible) with a task-local executor that
+               // VALIDATION_POOL) with a task-local executor that
                // deliberately delays counting down `started` well past 
MAX_VALIDATION_MILLIS before doing its
                // (instantaneous) "work". If scheduling latency were - the 
regression this guards against - counted
                // against the compute budget, awaitBounded() would throw a 
timeout error despite the task's own
@@ -296,6 +296,79 @@ class McpSchemaSafety_Test {
                }
        }
 
+       @SuppressWarnings({
+               "java:S2925" // Thread.sleep here lets the match descend into 
backtracking before the interrupt, not a wait-and-hope synchronization delay.
+       })
+       @Test
+       void d06_interruptAbortsCatastrophicMatch() throws Exception {
+               // Root-cause proof for the DoS-residual fix. A 
catastrophically-backtracking pattern applied to a
+               // non-matching input runs effectively forever, and 
java.util.regex.Matcher ignores Thread.interrupt().
+               // With JsonSchemaValidator now feeding the matched string 
through an interruptible CharSequence,
+               // interrupting the matching thread aborts the match promptly; 
without the fix, worker.join(...) below
+               // would time out with the thread still pinning a core.
+               var validator = JsonSchemaValidator.of(JsonMap.of("type", 
"string", "pattern", "^(.*a){25}$"));
+               var input = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!";  // 40 
'a's then a non-matching char
+
+               var worker = new Thread(() -> {
+                       try {
+                               validator.validate(input);
+                       } catch (@SuppressWarnings("unused") RuntimeException 
ignored) {
+                               // aborted (interrupt) or a legitimate 
validation failure - either way the thread unwinds
+                       }
+               }, "d06-interruptible-match");
+               worker.setDaemon(true);
+               worker.start();
+
+               Thread.sleep(200);  // let the matcher get well into 
backtracking
+               worker.interrupt();
+               worker.join(5000);
+
+               assertFalse(worker.isAlive(), "interrupt did not abort the 
catastrophically-backtracking match");
+       }
+
+       @SuppressWarnings({
+               "java:S2925" // Thread.sleep here samples the pool threads' CPU 
over a fixed window; it is the measurement window, not a wait-and-hope delay.
+       })
+       @Test
+       void d07_poolWorkerStopsBurningAfterBudgetTrip() throws Exception {
+               // End-to-end proof through the real DoS guard: after 
validateInput trips the compute budget and cancels
+               // the worker, the pool thread must stop burning CPU rather 
than keep spinning on the runaway regex. We
+               // sample the validation-pool threads' aggregate CPU time 
across a window after the trip; a still-running
+               // match would accrue ~a full core of CPU over that window, 
while the fix drives it to ~0. Gated on
+               // per-thread CPU timing (as d03) since the measurement relies 
on it; the margin is generous to stay robust.
+               Assumptions.assumeTrue(McpSchemaSafety.cpuTimeBudgetEnabled(), 
"per-thread CPU timing unavailable on this JVM");
+
+               var schema = McpSchema.of(JsonMap.of(
+                       "type", "object",
+                       "properties", JsonMap.of("s", JsonMap.of("type", 
"string", "pattern", "^(.*a){25}$"))));
+               var args = new LinkedHashMap<String,Object>();
+               args.put("s", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!");
+
+               var e = assertThrows(McpException.class, () -> 
McpSchemaSafety.validateInput(schema, args));
+               assertEquals(-32602, e.getCode());
+
+               var threadMx = ManagementFactory.getThreadMXBean();
+               var before = poolCpuNanos(threadMx);
+               Thread.sleep(500);  // measurement window
+               var after = poolCpuNanos(threadMx);
+               var burnedMs = (after - before) / 1_000_000;
+
+               assertTrue(burnedMs < 200, () -> "validation-pool worker kept 
burning CPU after budget trip: " + burnedMs + "ms");
+       }
+
+       private static long poolCpuNanos(ThreadMXBean threadMx) {
+               var total = 0L;
+               for (var id : threadMx.getAllThreadIds()) {
+                       var info = threadMx.getThreadInfo(id);
+                       if (info != null && 
info.getThreadName().startsWith("mcp-2026-07-28-schema-validation")) {
+                               var cpu = threadMx.getThreadCpuTime(id);
+                               if (cpu > 0)
+                                       total += cpu;
+                       }
+               }
+               return total;
+       }
+
        // -------- shared JsonValueSafety delegation now supports arrays 
---------
 
        @Test

Reply via email to