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 52a7dd40a7 Resolve CodeQL code-scanning alerts: path-traversal, ReDoS,
XXE, info-exposure hardening (TODO-235)
52a7dd40a7 is described below
commit 52a7dd40a7fa3aa6d3d6dc125de2a10fe20ef947
Author: James Bognar <[email protected]>
AuthorDate: Tue Jul 14 11:02:50 2026 -0400
Resolve CodeQL code-scanning alerts: path-traversal, ReDoS, XXE,
info-exposure hardening (TODO-235)
- Path-injection: route juneau-sc GetConfiguration config lookups through
FileUtils.resolveSafely and
map traversal escape to 403 in LoadConfigResource; add PARSE-method
traversal regression test for
LogsResource.
- ReDoS: fix polynomial-time regex in UrlPathMatcher, bound
regex-validation input length in
HttpPartSchema, and properly escape literal fragments in
LogEntryFormatter's date/format templates.
- XXE: disable DTD processing on XmlReader's non-validating path and harden
the integration-test
XmlValidatorParser; add XXE regression test.
- Info exposure: AuthFilter returns a generic Unauthorized body (detail
logged server-side);
PlainTextPojoProcessor emits generic status text for Throwables in
production (gated by
renderResponseStackTraces).
- Correctness: explicit narrowing cast in CharSequenceReader.
Also dismissed 22 by-design/false-positive alerts (SSRF client transports,
MsgPack spec narrowing,
utility-level XSS/error-exposure FPs, and already-mitigated log-resource
path-injection) with
per-alert rationale recorded on GitHub.
Co-authored-by: Cursor <[email protected]>
---
.../juneau/commons/io/CharSequenceReader.java | 2 +-
.../juneau/marshall/httppart/HttpPartSchema.java | 11 +++-
.../org/apache/juneau/marshall/xml/XmlReader.java | 3 +
.../httppart/HttpPartSchema_Validation_Test.java | 9 +++
.../apache/juneau/marshall/xml/XmlXxe_Test.java | 68 ++++++++++++++++++++++
.../java/org/apache/juneau/XmlValidatorParser.java | 3 +
.../rest/server/RestServer_ErrorExposure_Test.java | 59 +++++++++++++++++++
.../juneau/rest/server/auth/AuthFilter_Test.java | 40 +++++++++++--
.../rest/server/util/UrlPathMatcher_Test.java | 19 ++++++
.../microservice/resources/LogEntryFormatter.java | 48 ++++++++++++---
.../resources/LogEntryFormatter_Test.java | 27 +++++++++
.../resources/LogsResource_PathTraversal_Test.java | 16 +++++
.../apache/juneau/rest/server/auth/AuthFilter.java | 16 ++++-
.../server/processor/PlainTextPojoProcessor.java | 8 +++
.../juneau/rest/server/util/UrlPathMatcher.java | 2 +-
.../server/config/repository/GetConfiguration.java | 19 +++---
.../server/config/rest/LoadConfigResource.java | 10 +++-
17 files changed, 333 insertions(+), 27 deletions(-)
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/io/CharSequenceReader.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/io/CharSequenceReader.java
index 5f8ccb3f5f..40d512243e 100644
---
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/io/CharSequenceReader.java
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/io/CharSequenceReader.java
@@ -174,7 +174,7 @@ public class CharSequenceReader extends BufferedReader {
return 0;
long n = Math.min((long) length - next, ns);
n = Math.max(-next, n);
- next += n;
+ next += (int) n; // Safe narrowing: n is bounded to [-next,
length-next], so next+n stays within int range [0, length].
return n;
}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/httppart/HttpPartSchema.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/httppart/HttpPartSchema.java
index b6101be852..70fab81c14 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/httppart/HttpPartSchema.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/httppart/HttpPartSchema.java
@@ -4909,8 +4909,17 @@ public class HttpPartSchema {
return pattern == null || pattern.matcher(x).matches();
}
+ // Upper bound on the length of a value validated as a regular
expression. The value here is intentionally
+ // a caller-authored regex (JSON-Schema "format":"regex"), so it must
be compiled rather than quoted; the
+ // compiled pattern is immediately discarded and never matched against
anything. This cap bounds the cost
+ // of that throwaway compilation so an over-long or deeply-nested
pattern can't be used as a DoS vector.
+ private static final int MAX_REGEX_VALIDATION_LENGTH = 1000;
+
private static boolean isValidRegex(String x) {
- // ECMA-262 regex validation
+ // ECMA-262 regex validation. Reject over-long values before
compiling (the compiled Pattern is
+ // discarded and never used for matching, so bounding length is
sufficient to cap the work done here).
+ if (x.length() > MAX_REGEX_VALIDATION_LENGTH)
+ return false;
try {
Pattern.compile(x);
return true;
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlReader.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlReader.java
index 1792978372..bc3696ef8e 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlReader.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlReader.java
@@ -66,6 +66,9 @@ public class XmlReader implements XMLStreamReader,
Positionable {
factory.setProperty(XMLInputFactory.IS_COALESCING,
true);
factory.setProperty(XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
+ // Disable DTD processing entirely on the
non-validating path to close the residual XXE surface (e.g. billion-laughs and
SYSTEM-entity attacks via a DOCTYPE). DTD support is left enabled only when
validation is explicitly requested, since DTD validation requires it.
+ if (! validating)
+
factory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
if
(factory.isPropertySupported(XMLInputFactory.REPORTER) && nn(reporter))
factory.setProperty(XMLInputFactory.REPORTER,
reporter);
if
(factory.isPropertySupported(XMLInputFactory.RESOLVER) && nn(resolver))
diff --git
a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/httppart/HttpPartSchema_Validation_Test.java
b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/httppart/HttpPartSchema_Validation_Test.java
index 5f5ec00ae1..0c27908aed 100644
---
a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/httppart/HttpPartSchema_Validation_Test.java
+++
b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/httppart/HttpPartSchema_Validation_Test.java
@@ -273,6 +273,15 @@ class HttpPartSchema_Validation_Test extends TestBase {
assertThrowsWithMessage(SchemaValidationException.class, "Value
does not match expected format", ()->s.validateInput("[unclosed"));
}
+ @Test void a30b_format_regex_overLengthRejected() {
+ var s =
HttpPartSchema.create().tString().noValidate().format("regex").build();
+ // A value at the validation cap that is a valid regex still
validates.
+ assertDoesNotThrow(()->s.validateInput("a".repeat(1000)));
+ // A value longer than the cap is rejected without attempting
compilation, bounding the work done here
+ // so an over-long/deeply-nested pattern cannot be used as a
compile-time DoS vector.
+ assertThrowsWithMessage(SchemaValidationException.class, "Value
does not match expected format", ()->s.validateInput("(".repeat(5000)));
+ }
+
//-----------------------------------------------------------------------------------------------------------------
// DATE / DATE_TIME / DATE_TIME_ZONE format validation
//-----------------------------------------------------------------------------------------------------------------
diff --git
a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/xml/XmlXxe_Test.java
b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/xml/XmlXxe_Test.java
new file mode 100644
index 0000000000..f17beeaea0
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/xml/XmlXxe_Test.java
@@ -0,0 +1,68 @@
+/*
+ * 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.juneau.marshall.xml;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.file.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.json5.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.*;
+
+/**
+ * Verifies that {@link XmlReader} is not vulnerable to XML External Entity
(XXE) attacks.
+ *
+ * <p>
+ * The default (non-validating) parser disables DTD processing entirely, so
any document carrying a
+ * {@code <!DOCTYPE ...>} declaration (the vector for
external/parameter-entity attacks) is rejected outright and no
+ * external entity is ever resolved.
+ */
+class XmlXxe_Test extends TestBase {
+
+ @Test void a01_externalEntityNotResolved(@TempDir Path tempDir) throws
Exception {
+ var secret = tempDir.resolve("secret.txt");
+ Files.writeString(secret, "TOP-SECRET-CONTENTS");
+ var uri = secret.toUri().toString();
+
+ var xml = "<?xml version=\"1.0\"?>"
+ + "<!DOCTYPE A [<!ENTITY xxe SYSTEM \"" + uri + "\">]>"
+ + "<A>&xxe;</A>";
+
+ // DTD processing is disabled, so parsing must fail rather than
resolving the external entity.
+ var e = assertThrows(Exception.class, () ->
XmlParser.DEFAULT.parse(xml, Json5Map.class));
+
+ // The secret file contents must never appear anywhere in the
failure.
+ assertFalse(e.toString().contains("TOP-SECRET-CONTENTS"),
"External entity was resolved");
+ }
+
+ @Test void a02_doctypeRejected() {
+ var xml = "<?xml version=\"1.0\"?>"
+ + "<!DOCTYPE A [<!ELEMENT A ANY>]>"
+ + "<A>x</A>";
+
+ // Any DOCTYPE declaration is rejected on the non-validating
path.
+ assertThrows(Exception.class, () ->
XmlParser.DEFAULT.parse(xml, Json5Map.class));
+ }
+
+ @Test void a03_normalDocumentStillParses() throws Exception {
+ var xml = "<A b='1'><c>2</c></A>";
+ var m = XmlParser.DEFAULT.parse(xml, Json5Map.class);
+ assertEquals("{b:'1',c:'2'}", m.toString());
+ }
+}
diff --git
a/juneau-integration-tests/src/test/java/org/apache/juneau/XmlValidatorParser.java
b/juneau-integration-tests/src/test/java/org/apache/juneau/XmlValidatorParser.java
index 031e138e9a..0797ebbd53 100755
---
a/juneau-integration-tests/src/test/java/org/apache/juneau/XmlValidatorParser.java
+++
b/juneau-integration-tests/src/test/java/org/apache/juneau/XmlValidatorParser.java
@@ -91,6 +91,9 @@ public class XmlValidatorParser extends XmlParser {
protected XMLStreamReader getStaxReader(Reader in) throws Exception {
var factory = XMLInputFactory.newInstance();
factory.setProperty("javax.xml.stream.isNamespaceAware", false);
+ // This validator only checks well-formedness of serializer
output (which never contains DTDs), so DTD processing and external-entity
resolution are disabled to close the XXE surface.
+ factory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
+
factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
var parser = factory.createXMLStreamReader(in);
parser.nextTag();
return parser;
diff --git
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/RestServer_ErrorExposure_Test.java
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/RestServer_ErrorExposure_Test.java
new file mode 100644
index 0000000000..2e90cbf53f
--- /dev/null
+++
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/RestServer_ErrorExposure_Test.java
@@ -0,0 +1,59 @@
+/*
+ * 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.juneau.rest.server;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Guards that unexpected server-side exception detail (message, class name,
stack trace) is not leaked to
+ * HTTP clients in production, while remaining available in development when
the
+ * {@code renderResponseStackTraces} flag is enabled.
+ */
+class RestServer_ErrorExposure_Test extends TestBase {
+
+ private static final String SECRET = "SECRET-DB-PASSWORD=hunter2";
+
+ // Production (default): renderResponseStackTraces is off.
+ @Rest
+ public static class A {
+ @RestGet public String boom() { throw new
RuntimeException(SECRET); }
+ }
+
+ // Development: renderResponseStackTraces explicitly enabled.
+ @Rest(renderResponseStackTraces="true")
+ public static class B {
+ @RestGet public String boom() { throw new
RuntimeException(SECRET); }
+ }
+
+ @Test void a01_productionMode_hidesInternalExceptionDetail() throws
Exception {
+ var a = MockRestClient.buildLax(A.class);
+ var body =
a.get("/boom").run().assertStatus(500).getContent().asString();
+ assertFalse(body.contains(SECRET), "Production body leaked the
exception message: " + body);
+ assertFalse(body.contains("RuntimeException"), "Production body
leaked the exception class name: " + body);
+ assertTrue(body.contains("Internal Server Error"), "Production
body should carry only the generic status text: " + body);
+ }
+
+ @Test void a02_debugMode_rendersDetail() throws Exception {
+ var b = MockRestClient.buildLax(B.class);
+ var body =
b.get("/boom").run().assertStatus(500).getContent().asString();
+ assertTrue(body.contains(SECRET), "Debug mode
(renderResponseStackTraces=true) should render exception detail: " + body);
+ }
+}
diff --git
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/AuthFilter_Test.java
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/AuthFilter_Test.java
index 5cb64ee053..8aad77a6d1 100644
---
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/AuthFilter_Test.java
+++
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/auth/AuthFilter_Test.java
@@ -180,10 +180,6 @@ class AuthFilter_Test extends TestBase {
}
@Test void d03_doFilter_authException_nullMessage_stillWrites401()
throws Exception {
- // Even when constructed with a null message,
BasicHttpException#getMessage() falls back
- // to the status reason phrase ("Unauthorized") — so the {@code
msg != null} branch in
- // AuthFilter#sendChallenge is effectively unreachable through
public API (the else-branch
- // is dead code given BasicHttpException#getMessage() never
returns null). // NOSONAR
var f = new TestFilter(req -> { throw new
AuthenticationException((String) null); });
var req = MockServletRequest.create("GET", "/x");
var resp = MockServletResponse.create();
@@ -195,6 +191,42 @@ class AuthFilter_Test extends TestBase {
assertEquals(HttpServletResponse.SC_UNAUTHORIZED,
resp.getStatus());
}
+ //
========================================================================
+ // sendChallenge — response body must be generic (no info leakage)
+ //
========================================================================
+
+ @Test void d04_sendChallenge_writesGenericBody_notExceptionMessage()
throws Exception {
+ var resp = mock(HttpServletResponse.class);
+ var sw = new StringWriter();
+ when(resp.getWriter()).thenReturn(new PrintWriter(sw));
+ // Sensitive detail that must NOT be echoed back to the client.
+ var e = new AuthenticationException("token signature invalid:
secret-key-id=abc123")
+ .wwwAuthenticate("Bearer realm=\"api\"");
+
+ AuthFilter.sendChallenge(resp, e);
+
+ verify(resp).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(resp).setHeader("WWW-Authenticate", "Bearer
realm=\"api\"");
+ var body = sw.toString();
+ assertEquals("Unauthorized", body);
+ assertFalse(body.contains("secret-key-id"), "Auth-failure body
must not leak the exception message");
+ }
+
+ @Test void d05_sendChallenge_wrappedCauseNotLeaked() throws Exception {
+ var resp = mock(HttpServletResponse.class);
+ var sw = new StringWriter();
+ when(resp.getWriter()).thenReturn(new PrintWriter(sw));
+ // A custom validator can wrap an internal exception whose
message must not reach the client.
+ var e = new AuthenticationException(new
IllegalStateException("jdbc://internal-host:5432 unreachable"));
+
+ AuthFilter.sendChallenge(resp, e);
+
+ verify(resp).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ var body = sw.toString();
+ assertEquals("Unauthorized", body);
+ assertFalse(body.contains("internal-host"), "Wrapped cause
message must not leak to the client");
+ }
+
//
========================================================================
// doFilter — exceptions from the downstream chain propagate
//
========================================================================
diff --git
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/util/UrlPathMatcher_Test.java
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/util/UrlPathMatcher_Test.java
index ca177e0dbc..d3b4cb3f2f 100644
---
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/util/UrlPathMatcher_Test.java
+++
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/util/UrlPathMatcher_Test.java
@@ -20,6 +20,7 @@ import static
org.apache.juneau.rest.server.util.UrlPathMatcher.*;
import static org.apache.juneau.test.bct.BctAssertions.*;
import static org.junit.jupiter.api.Assertions.*;
+import java.time.*;
import java.util.*;
import org.apache.juneau.*;
@@ -357,4 +358,22 @@ class UrlPathMatcher_Test extends TestBase {
check(p, "/*.*", "{}");
shouldNotMatch(p, "/foo", "/foo", "/*", null);
}
+
+
//------------------------------------------------------------------------------------------------------------------
+ // ReDoS safety
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void j01_redos_pathologicalPatternCompilesQuickly() {
+ // Constructing a matcher computes a comparator string via a
'\{[^{}]+\}' scan. A pattern string
+ // consisting of many '{' characters previously drove that scan
into O(n^2) backtracking; with the
+ // non-overlapping character class the construction stays
effectively linear and must complete promptly.
+ var pattern = "/" + "{".repeat(200_000);
+ assertTimeoutPreemptively(Duration.ofSeconds(2), () ->
of(pattern));
+ }
+
+ @Test void j02_redos_variablePatternComparatorUnchanged() {
+ // The comparator computation for well-formed variable patterns
must be unaffected by the regex change.
+ assertEquals(of("/foo/{id}/bar").getComparator(),
of("/foo/{name}/bar").getComparator());
+ assertEquals("/X/W/X/W/W",
of("/foo/{id}/bar/{x}").getComparator());
+ }
}
\ No newline at end of file
diff --git
a/juneau-microservice/juneau-microservice/src/main/java/org/apache/juneau/microservice/resources/LogEntryFormatter.java
b/juneau-microservice/juneau-microservice/src/main/java/org/apache/juneau/microservice/resources/LogEntryFormatter.java
index db58a1105e..01e3fd66f7 100644
---
a/juneau-microservice/juneau-microservice/src/main/java/org/apache/juneau/microservice/resources/LogEntryFormatter.java
+++
b/juneau-microservice/juneau-microservice/src/main/java/org/apache/juneau/microservice/resources/LogEntryFormatter.java
@@ -62,6 +62,36 @@ public class LogEntryFormatter extends Formatter {
return Integer.toHexString(i);
}
+ // Appends a single character to the log-parsing regex as a literal,
escaping it when it is a regex
+ // metacharacter. The format and date-format strings are
caller-supplied configuration, so their
+ // literal characters must be escaped to keep them matched verbatim and
prevent regex-injection.
+ private static void appendLiteral(StringBuilder re, char c) {
+ if (! (Character.isLetterOrDigit(c) ||
Character.isWhitespace(c)))
+ re.append('\\');
+ re.append(c);
+ }
+
+ // Appends each character of a string to the log-parsing regex as a
literal (see {@link #appendLiteral(StringBuilder, char)}).
+ private static void appendLiteral(StringBuilder re, String s) {
+ for (var i = 0; i < s.length(); i++)
+ appendLiteral(re, s.charAt(i));
+ }
+
+ // Converts a SimpleDateFormat pattern into a regex fragment that
matches formatted dates: date/time pattern
+ // letters become "\d" matchers and all other characters are treated as
regex literals. Escaping the
+ // non-letter characters (rather than only ".") prevents
regex-injection from a caller-supplied date format.
+ private static String dateFormatToRegex(String dateFormat) {
+ var re = new StringBuilder();
+ for (var i = 0; i < dateFormat.length(); i++) {
+ var c = dateFormat.charAt(i);
+ if ("mHhsSdMy".indexOf(c) >= 0)
+ re.append("\\d");
+ else
+ appendLiteral(re, c);
+ }
+ return re.toString();
+ }
+
private ConcurrentHashMap<String,AtomicInteger> hashes;
private DateFormat df;
private String format;
@@ -135,17 +165,15 @@ public class LogEntryFormatter extends Formatter {
if (state == S1) {
if (c == '%')
state = S2;
- else {
- if (! (Character.isLetterOrDigit(c) ||
Character.isWhitespace(c)))
- re.append('\\');
- re.append(c);
- }
+ else
+ appendLiteral(re, c);
} else if (state == S2) {
if (Character.isDigit(c)) {
i1 = i;
state = S3;
} else {
- re.append("\\%").append(c);
+ re.append("\\%");
+ appendLiteral(re, c);
state = S1;
}
} else if (state == S3) { // NOSONAR - State check
necessary for state machine
@@ -155,7 +183,8 @@ public class LogEntryFormatter extends Formatter {
// Stay in S3: group numbers may be
multi-digit (e.g. %10$s for {spanId}).
state = S3; // NOSONAR - explicit
self-transition documents the multi-digit case
} else {
-
re.append("\\%").append(format.substring(i1, i)); // HTT - requires %digit
followed by non-$ which can't come from standard format placeholders
+ re.append("\\%");
+ appendLiteral(re, format.substring(i1,
i)); // HTT - requires %digit followed by non-$ which can't come from standard
format placeholders
state = S1;
}
} else if (state == S4) {
@@ -164,7 +193,7 @@ public class LogEntryFormatter extends Formatter {
switch (group) {
case 1:
fieldIndexes.put("date", index++);
-
re.append("(").append(dateFormat.replaceAll("[mHhsSdMy]", "\\\\d").replace(".",
"\\.")).append(")");
+
re.append("(").append(dateFormatToRegex(dateFormat)).append(")");
break;
case 2:
fieldIndexes.put("class", index++);
@@ -205,7 +234,8 @@ public class LogEntryFormatter extends Formatter {
default: // HTT - group numbers
> 10 would require a format placeholder beyond {spanId} // NOSONAR
}
} else {
-
re.append("\\%").append(format.substring(i1, i)); // HTT - requires %digit$
followed by non-s which can't come from standard format placeholders
+ re.append("\\%");
+ appendLiteral(re, format.substring(i1,
i)); // HTT - requires %digit$ followed by non-s which can't come from standard
format placeholders
}
state = S1;
}
diff --git
a/juneau-microservice/juneau-microservice/src/test/java/org/apache/juneau/microservice/resources/LogEntryFormatter_Test.java
b/juneau-microservice/juneau-microservice/src/test/java/org/apache/juneau/microservice/resources/LogEntryFormatter_Test.java
index b057056311..1c8b29540e 100644
---
a/juneau-microservice/juneau-microservice/src/test/java/org/apache/juneau/microservice/resources/LogEntryFormatter_Test.java
+++
b/juneau-microservice/juneau-microservice/src/test/java/org/apache/juneau/microservice/resources/LogEntryFormatter_Test.java
@@ -227,4 +227,31 @@ class LogEntryFormatter_Test extends TestBase {
assertEquals("", TraceContext.currentTraceId());
assertEquals("", TraceContext.currentSpanId());
}
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // Regex-injection safety - the caller-supplied format/dateFormat
strings are escaped into the parsing regex.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_dateFormat_regexMetacharsEscapedAndMatch() {
+ // A date format containing regex metacharacters must be
escaped (not injected) yet still match its own
+ // formatted output. With the previous partial escaping,
"(MM)" and "[dd]" produced a stray capture
+ // group and character class, so the resulting pattern no
longer matched the formatted date.
+ var f = new LogEntryFormatter("[{date}] {msg}", "yyyy(MM)[dd]",
false);
+ var r = new LogRecord(Level.INFO, "hello");
+ var line = f.format(r);
+ var m = f.getLogEntryPattern().matcher(line);
+ assertTrue(m.matches(), "Pattern should match its own formatted
output with a metacharacter date format, got:\n" + line);
+ }
+
+ @Test void c02_dateFormat_unbalancedMetacharsDoNotThrow() {
+ // An unbalanced regex metacharacter in the date format
previously produced an invalid pattern that
+ // threw a PatternSyntaxException during construction; it must
now be escaped and compile cleanly.
+ assertDoesNotThrow(() -> new LogEntryFormatter("[{date}]
{msg}", "yyyy(((MM", false));
+ }
+
+ @Test void c03_format_regexMetacharsEscaped() {
+ // Regex metacharacters in the literal portions of the format
string must be escaped, so an adversarial
+ // format cannot inject regex syntax or break compilation of
the log-parsing pattern.
+ assertDoesNotThrow(() -> new
LogEntryFormatter("(([{date}]{level})+ {msg}%n", DATE_FORMAT, false));
+ }
}
diff --git
a/juneau-microservice/juneau-microservice/src/test/java/org/apache/juneau/microservice/resources/LogsResource_PathTraversal_Test.java
b/juneau-microservice/juneau-microservice/src/test/java/org/apache/juneau/microservice/resources/LogsResource_PathTraversal_Test.java
index c306090032..5e3328ca7f 100644
---
a/juneau-microservice/juneau-microservice/src/test/java/org/apache/juneau/microservice/resources/LogsResource_PathTraversal_Test.java
+++
b/juneau-microservice/juneau-microservice/src/test/java/org/apache/juneau/microservice/resources/LogsResource_PathTraversal_Test.java
@@ -291,4 +291,20 @@ class LogsResource_PathTraversal_Test extends TestBase {
"Symlink-escape response must not leak the
outside-root secret");
}
}
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // PARSE traversal — the remaining operation surface
(view/download/delete covered above)
+
//-----------------------------------------------------------------------------------------------------------------
+
+ @Test void t13_methodPARSE_traversal_returns403() throws Exception {
+ try (var c = buildClient()) {
+ @SuppressWarnings({
+ "resource" // Closeable resources in tests are
intentionally unassigned; closing is handled by test infrastructure.
+ })
+ var resp =
c.get("/../outside-secret.log?method=PARSE").run();
+ assertEquals(403, resp.getStatusCode(), "GET
/../outside-secret.log?method=PARSE must be rejected");
+
assertFalse(resp.getContent().asString().contains("AUDIT_OUTSIDE_LOG_SECRET"),
+ "Response body must not leak the outside-root
secret");
+ }
+ }
}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/auth/AuthFilter.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/auth/AuthFilter.java
index fe83ccd45d..628b10e7c4 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/auth/AuthFilter.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/auth/AuthFilter.java
@@ -18,6 +18,8 @@ package org.apache.juneau.rest.server.auth;
import java.io.*;
import java.util.*;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
@@ -61,6 +63,11 @@ public abstract class AuthFilter implements Filter,
Authenticator {
/** WWW-Authenticate response header name (RFC 7235 §4.1). */
static final String WWW_AUTHENTICATE = "WWW-Authenticate";
+ private static final Logger LOG =
Logger.getLogger(AuthFilter.class.getName());
+
+ /** Generic {@code 401} response body — deliberately reveals no failure
detail to the client. */
+ static final String GENERIC_UNAUTHORIZED_MESSAGE = "Unauthorized";
+
/**
* Standalone-filter entry point.
*
@@ -124,8 +131,11 @@ public abstract class AuthFilter implements Filter,
Authenticator {
.findFirst()
.ifPresent(v -> resp.setHeader(WWW_AUTHENTICATE, v));
resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
- var msg = e.getMessage();
- if (msg != null)
- resp.getWriter().write(msg);
+ // Return a generic body to the client. The exception message
can carry internal detail from a
+ // custom validator (e.g. a wrapped cause), so it must not be
echoed over the wire — the
+ // WWW-Authenticate challenge set above is the only
auth-specific data a client needs. Detailed
+ // failure information is logged server-side for diagnostics.
+ LOG.log(Level.FINE, e, () -> "Authentication failed.");
+ resp.getWriter().write(GENERIC_UNAUTHORIZED_MESSAGE);
}
}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/processor/PlainTextPojoProcessor.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/processor/PlainTextPojoProcessor.java
index b2a2356ee5..c29b37e223 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/processor/PlainTextPojoProcessor.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/processor/PlainTextPojoProcessor.java
@@ -58,6 +58,14 @@ public class PlainTextPojoProcessor implements
ResponseProcessor {
w.append("null");
} else if (o instanceof Map || o instanceof Collection ||
o.getClass().isArray()) {
w.append(Json5.of(o));
+ } else if (o instanceof Throwable && !
opSession.getRestContext().isRenderResponseStackTraces()) {
+ // A thrown exception reached the plain-text renderer
as the response content. Emitting its
+ // toString() (class name + message) would leak
internal detail to the client, so in production
+ // we write only the generic HTTP status text.
Structured detail remains in the 'Thrown'
+ // response header and server-side logs; enable
renderResponseStackTraces for full in-response
+ // detail during development.
+ var statusText =
RestUtils.getHttpResponseText(res.getStatus());
+ w.append(statusText == null ? "" : statusText);
} else {
w.append(req.getMarshallingSession().getClassMetaForObject(o).toString(o));
}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/UrlPathMatcher.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/UrlPathMatcher.java
index 6c34cc1688..f329b81957 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/UrlPathMatcher.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/util/UrlPathMatcher.java
@@ -98,7 +98,7 @@ public abstract sealed class UrlPathMatcher implements
Comparable<UrlPathMatcher
this.pattern = patternString;
}
- var c = patternString.replaceAll("\\{[^\\}]+\\}",
".").replaceAll("\\w+", "X").replace(".", "W");
+ var c = patternString.replaceAll("\\{[^{}]+\\}",
".").replaceAll("\\w+", "X").replace(".", "W");
if (c.isEmpty())
c = "+";
if (! c.endsWith("/*"))
diff --git
a/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/repository/GetConfiguration.java
b/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/repository/GetConfiguration.java
index e84cccecd4..7d07daf8d1 100644
---
a/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/repository/GetConfiguration.java
+++
b/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/repository/GetConfiguration.java
@@ -16,6 +16,8 @@
*/
package org.apache.juneau.server.config.repository;
+import static org.apache.juneau.commons.utils.FileUtils.*;
+
import java.io.*;
import java.nio.file.*;
import java.util.*;
@@ -34,7 +36,6 @@ public class GetConfiguration implements Command,
GetValue<Map<String,ConfigItem
private static final String APPLICATION = "APPLICATION";
private static final String PROJECT = "PROJECT";
private static final String EXT = ".cfg";
- private static final String BAR = "/";
private Map<String,ConfigItem> configs = new HashMap<>();
@@ -67,9 +68,11 @@ public class GetConfiguration implements Command,
GetValue<Map<String,ConfigItem
var gitControl = new GitControl(pathStr, git);
- var path = new File(pathStr);
+ // Trusted operator-configured local git checkout directory
(GitServer/pathLocal).
+ // Serves as the confinement root for the project/application
config file lookups below.
+ var root = new File(pathStr);
- if (path.isDirectory()) {
+ if (root.isDirectory()) {
gitControl.pullFromRepo();
} else {
gitControl.cloneRepo();
@@ -81,14 +84,16 @@ public class GetConfiguration implements Command,
GetValue<Map<String,ConfigItem
var fileDefaultStr = APPLICATION.toLowerCase().concat(EXT);
var fileProjectStr = this.project.concat(EXT);
- var fileDefault = new
File(pathStr.concat(BAR).concat(fileDefaultStr));
- if (fileDefault.exists()) {
+ // Resolve the config file names under the checkout root
through the shared boundary check
+ // so a crafted project name cannot escape the repo directory
(empty = file absent).
+ var fileDefault = resolveSafely(root,
fileDefaultStr).orElse(null);
+ if (fileDefault != null) {
var lines = new
String(Files.readAllBytes(fileDefault.toPath()));
configs.put(APPLICATION, new ConfigItem(lines));
}
- var fileProject = new
File(pathStr.concat(BAR).concat(fileProjectStr));
- if (fileProject.exists()) {
+ var fileProject = resolveSafely(root,
fileProjectStr).orElse(null);
+ if (fileProject != null) {
var linesProject = new
String(Files.readAllBytes(fileProject.toPath()));
configs.put(PROJECT, new ConfigItem(linesProject));
}
diff --git
a/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/rest/LoadConfigResource.java
b/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/rest/LoadConfigResource.java
index 0816e519e6..c1c1a504d5 100644
---
a/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/rest/LoadConfigResource.java
+++
b/juneau-sc/juneau-sc-server/src/main/java/org/apache/juneau/server/config/rest/LoadConfigResource.java
@@ -17,6 +17,7 @@
package org.apache.juneau.server.config.rest;
import org.apache.juneau.http.*;
+import org.apache.juneau.http.response.*;
import org.apache.juneau.marshall.json.*;
import org.apache.juneau.rest.server.*;
import org.apache.juneau.rest.server.servlet.*;
@@ -47,7 +48,14 @@ public class LoadConfigResource extends RestServlet {
var jsonSerializer = JsonSerializer.DEFAULT_READABLE;
var config = new GetConfiguration(project, branch);
- config.execute();
+ try {
+ config.execute();
+ } catch (@SuppressWarnings("unused") IllegalArgumentException
e) {
+ // A crafted project/branch that escapes the checkout
root is rejected by
+ // FileUtils.resolveSafely(). Map that boundary
violation to 403 with a generic
+ // message so the offending path is never echoed back
to the client.
+ throw new Forbidden("Access denied.");
+ }
return jsonSerializer.serialize(config.get());
}