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