diff --git a/src/main/java/org/dataone/security/TemporaryMitigationValve.java b/src/main/java/org/dataone/security/TemporaryMitigationValve.java new file mode 100644 index 0000000..a86e489 --- /dev/null +++ b/src/main/java/org/dataone/security/TemporaryMitigationValve.java @@ -0,0 +1,105 @@ +/** + * This work was created by participants in the DataONE project, and is + * jointly copyrighted by participating institutions in DataONE. For + * more information on DataONE, see our web site at http://dataone.org. + * + * Copyright 2026 + * + * Licensed 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. + * + * $Id$ + */ + +package org.dataone.security; + +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; + +import javax.servlet.ServletException; + +import org.apache.catalina.Valve; +import org.apache.catalina.connector.Request; +import org.apache.catalina.connector.Response; +import org.apache.catalina.valves.ValveBase; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Temporary mitigation Valve to handle GHSA-95v2-fvxr-qg83-style path confusion/bypass attempts. + * Added 2026-07-10. + * Enable via the {@code } element under {@code } in {@code server.xml}, e.g.: + * {@code } + */ +public class TemporaryMitigationValve extends ValveBase { + + private static final Logger logger = LogManager.getLogger(TemporaryMitigationValve.class.getName()); + + private static final int MAX_DECODE_ROUNDS = 3; + + // deny patterns + private static final Pattern SUSPICIOUS = Pattern.compile( + "(?i)(\\.{2}|%2e|%2f|%5c|\\\\|/\\./|/\\.\\./|%252e|%252f|%255c)" + ); + + @Override + public void invoke(Request request, Response response) throws IOException, ServletException { + String uri = safe(request.getRequestURI()); + String qs = request.getQueryString(); + String target = (qs == null) ? uri : (uri + "?" + qs); + + if (isSuspicious(target)) { + logger.warn("Rejecting suspicious request from remote address {} for URI {}", + safe(request.getRemoteAddr()), uri); + response.sendError(400, "Malformed request target"); + return; + } + + Valve next = getNext(); + if (next != null) { + next.invoke(request, response); + } + } + + private boolean isSuspicious(String input) { + String current = input; + for (int i = 0; i < MAX_DECODE_ROUNDS; i++) { + if (SUSPICIOUS.matcher(current).find()) { + return true; + } + String decoded = decodeOnce(current); + if (decoded == null) { + return true; + } + if (decoded.equals(current)) { + break; + } + current = decoded; + } + return SUSPICIOUS.matcher(current).find(); + } + + private String decodeOnce(String value) { + try { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + // Invalid %-encoding: signal decode failure to caller. + return null; + } + } + + private String safe(String v) { + return v == null ? "" : v; + } +} \ No newline at end of file diff --git a/src/test/java/org/dataone/security/TemporaryMitigationValveTest.java b/src/test/java/org/dataone/security/TemporaryMitigationValveTest.java new file mode 100644 index 0000000..bae86dc --- /dev/null +++ b/src/test/java/org/dataone/security/TemporaryMitigationValveTest.java @@ -0,0 +1,73 @@ +/** + * This work was created by participants in the DataONE project, and is + * jointly copyrighted by participating institutions in DataONE. For + * more information on DataONE, see our web site at http://dataone.org. + * + * Copyright 2026 + * + * Licensed 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. + * + * $Id$ + */ + +package org.dataone.security; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +import org.junit.Test; + +public class TemporaryMitigationValveTest { + + private final TemporaryMitigationValve valve = new TemporaryMitigationValve(); + + @Test + public void rejectsPlainTraversal() { + assertTrue(isSuspicious("/v2/resolve/../etc/passwd")); + } + + @Test + public void rejectsSingleEncodedTraversal() { + assertTrue(isSuspicious("/v2/resolve/%2e%2e%2fetc/passwd")); + } + + @Test + public void rejectsDoubleEncodedTraversal() { + assertTrue(isSuspicious("/v2/resolve/%252e%252e%252fetc/passwd")); + } + + @Test + public void rejectsMalformedPercentEncoding() { + assertTrue(isSuspicious("/v2/resolve/%ZZ")); + } + + @Test + public void allowsNormalRequestTarget() { + assertFalse(isSuspicious("/v2/resolve/abc123?includeMetadata=true")); + } + + private boolean isSuspicious(String input) { + try { + Method method = TemporaryMitigationValve.class.getDeclaredMethod("isSuspicious", String.class); + method.setAccessible(true); + return (Boolean) method.invoke(valve, input); + } catch (NoSuchMethodException | IllegalAccessException e) { + throw new AssertionError("Failed to access TemporaryMitigationValve.isSuspicious", e); + } catch (InvocationTargetException e) { + throw new AssertionError("Unexpected exception from TemporaryMitigationValve.isSuspicious", e); + } + } +}