From 71b75400747b4e545380a747b45e62cf980b4e98 Mon Sep 17 00:00:00 2001 From: Wikum Weerakutti Date: Tue, 16 Jun 2026 09:54:52 +0530 Subject: [PATCH] O3-5739: Skip XSS request wrapping for web-service paths XSSFilter no longer wraps requests whose URI contains /ws/, so REST and FHIR request bodies reach the resource layer as raw bytes. --- .../java/org/openmrs/web/xss/XSSFilter.java | 22 +++- .../org/openmrs/web/xss/XSSFilterTest.java | 124 ++++++++++++++++++ 2 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 omod/src/test/java/org/openmrs/web/xss/XSSFilterTest.java diff --git a/omod/src/main/java/org/openmrs/web/xss/XSSFilter.java b/omod/src/main/java/org/openmrs/web/xss/XSSFilter.java index c3857ffa1..5061f7c75 100644 --- a/omod/src/main/java/org/openmrs/web/xss/XSSFilter.java +++ b/omod/src/main/java/org/openmrs/web/xss/XSSFilter.java @@ -23,19 +23,20 @@ import org.springframework.web.multipart.support.DefaultMultipartHttpServletRequest; public class XSSFilter implements Filter { - + + private static final String WEB_SERVICE_PATH = "/ws/"; + @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - - if (!"GET".equalsIgnoreCase(((HttpServletRequest) request).getMethod())) { - if (ServletFileUpload.isMultipartContent((HttpServletRequest) request)) { + HttpServletRequest httpRequest = (HttpServletRequest) request; + if (!"GET".equalsIgnoreCase(httpRequest.getMethod()) && !isWebServiceRequest(httpRequest)) { + if (ServletFileUpload.isMultipartContent(httpRequest)) { request = new XSSMultipartRequestWrapper((DefaultMultipartHttpServletRequest) request); } else { - request = new XSSRequestWrapper((HttpServletRequest) request); + request = new XSSRequestWrapper(httpRequest); } } - chain.doFilter(request, response); } @@ -48,4 +49,13 @@ public void init(FilterConfig filterConfig) throws ServletException { public void destroy() { } + + private boolean isWebServiceRequest(HttpServletRequest request) { + String requestUri = request.getRequestURI(); + if (requestUri == null) { + return false; + } + String path = requestUri.substring(request.getContextPath().length()); + return path.startsWith(WEB_SERVICE_PATH); + } } diff --git a/omod/src/test/java/org/openmrs/web/xss/XSSFilterTest.java b/omod/src/test/java/org/openmrs/web/xss/XSSFilterTest.java new file mode 100644 index 000000000..3d5699b5b --- /dev/null +++ b/omod/src/test/java/org/openmrs/web/xss/XSSFilterTest.java @@ -0,0 +1,124 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.web.xss; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.nio.charset.StandardCharsets; + +import javax.servlet.FilterChain; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; + +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +/** + * Unit tests for {@link XSSFilter}. These assert the central contract: web-service request bodies + * (REST/FHIR under {@code /ws/}) must pass through untouched, while legacy server-rendered form + * parameters are still HTML-sanitized. + */ +public class XSSFilterTest { + + private XSSFilter filter; + + private CapturingFilterChain chain; + + @BeforeEach + public void setUp() { + filter = new XSSFilter(); + chain = new CapturingFilterChain(); + } + + @Test + public void doFilter_shouldPassWebServiceJsonBodyThroughUnchanged() throws Exception { + String json = "{\"value\":\"Hello <> & friends\"}"; + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/openmrs/ws/rest/v1/obs"); + request.setContextPath("/openmrs"); + request.setContent(json.getBytes(StandardCharsets.UTF_8)); + + filter.doFilter(request, new MockHttpServletResponse(), chain); + + assertNotNull(chain.capturedRequest); + String body = IOUtils.toString(chain.capturedRequest.getInputStream(), StandardCharsets.UTF_8.name()); + assertEquals(json, body, + "Web-service request bodies must reach the resource layer as raw bytes, never HTML-encoded"); + } + + @Test + public void doFilter_shouldPassFhirRequestBodyThroughUnchanged() throws Exception { + String json = "{\"valueString\":\"a < b & c > d\"}"; + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/openmrs/ws/fhir2/R4/Observation"); + request.setContextPath("/openmrs"); + request.setContent(json.getBytes(StandardCharsets.UTF_8)); + + filter.doFilter(request, new MockHttpServletResponse(), chain); + + assertNotNull(chain.capturedRequest); + String body = IOUtils.toString(chain.capturedRequest.getInputStream(), StandardCharsets.UTF_8.name()); + assertEquals(json, body, "FHIR request bodies must also pass through untouched"); + } + + @Test + public void doFilter_shouldSanitizeLegacyFormParameters() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/openmrs/admin/concepts/concept.form"); + request.setContextPath("/openmrs"); + request.setParameter("name", ""); + + filter.doFilter(request, new MockHttpServletResponse(), chain); + + assertNotNull(chain.capturedRequest); + assertEquals("<script>alert(1)</script>", chain.capturedRequest.getParameter("name"), + "Legacy form parameters must still be HTML-encoded to defend server-rendered pages"); + } + + @Test + public void doFilter_shouldNotWrapWebServiceRequestsSoParametersAreUntouched() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/openmrs/ws/rest/v1/concept"); + request.setContextPath("/openmrs"); + request.setParameter("q", ""); + + filter.doFilter(request, new MockHttpServletResponse(), chain); + + assertNotNull(chain.capturedRequest); + assertEquals("", chain.capturedRequest.getParameter("q"), + "Web-service requests are not wrapped, so their parameters are not HTML-encoded either"); + } + + @Test + public void doFilter_shouldSanitizeLegacyRequestWhoseUrlMerelyContainsWsElsewhere() throws Exception { + // A legacy endpoint reached via a URL that contains "/ws/" somewhere other than the path + // prefix must NOT be treated as a web service, otherwise it could evade param sanitization. + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/openmrs/patientDashboard/ws/edit.form"); + request.setContextPath("/openmrs"); + request.setParameter("name", ""); + + filter.doFilter(request, new MockHttpServletResponse(), chain); + + assertNotNull(chain.capturedRequest); + assertEquals("<x>", chain.capturedRequest.getParameter("name"), + "Only a /ws/ prefix (after the context path) is a web service; legacy paths stay sanitized"); + } + + private static class CapturingFilterChain implements FilterChain { + + private HttpServletRequest capturedRequest; + + @Override + public void doFilter(ServletRequest request, ServletResponse response) { + this.capturedRequest = (HttpServletRequest) request; + } + } +} \ No newline at end of file