Skip to content

Commit e34dcfb

Browse files
dsolistorresclaude
andcommitted
fix(rest-api): address PR review — return 500 on JSP failure and enforce READ permission in rules include (#34888)
BaseRestPortlet.getJspResponse(): - Replaced the legacy debug-HTML fallback with a WebApplicationException(500). Non-WebApplicationException failures (e.g. NullPointerException, IOException) now log the full stack trace at ERROR for admins and return a plain 500 with no entity, instead of HTTP 200 with internal exception details rendered as HTML. Side effect: also closes the pre-existing XSS surface where path and e.toString() were interpolated into the error markup unencoded. include.jsp: - Added explicit READ permission check via PermissionAPI.doesUserHavePermission after findContentletByIdentifierAnyLanguage. Anonymous sessions and users without READ on the contentlet now receive 403 with a generic "Access denied" body, instead of leaking existence via 200/404. BaseRestPortletTest: - Rewrote getJspResponse_returnsErrorHtmlForGenericException and getJspResponse_returnsErrorHtmlForRuntimeException as getJspResponse_throws500ForGenericException / getJspResponse_throws500ForRuntimeException; both assert WAE(500) propagates and getResponse().hasEntity() == false to lock in the no-detail-leak contract. - Replaced fully qualified javax.servlet.ServletException with the imported simple name for consistency with the rest of the file. Refs: #34888 Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 9975407 commit e34dcfb

3 files changed

Lines changed: 53 additions & 33 deletions

File tree

dotCMS/src/main/java/com/dotcms/rest/BaseRestPortlet.java

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -131,19 +131,12 @@ private String getJspResponse ( HttpServletRequest request, HttpServletResponse
131131
throw (WebApplicationException) t;
132132
}
133133
}
134-
Logger.debug(this.getClass(), "unable to parse: " + path);
135-
Logger.error( this.getClass(), e.toString(), e );
136-
StringWriter sw = new StringWriter();
137-
sw.append("<div style='padding:30px;'>");
138-
sw.append("unable to parse: <a href='" + path + "' target='debug'>" + path + "</a>");
139-
sw.append("<hr>");
140-
sw.append("<pre style='width:90%;overflow:hidden;white-space:pre-wrap'>");
141-
sw.append(e.toString());
142-
143-
sw.append("</pre>");
144-
sw.append("</div>");
145-
return sw.toString();
146-
134+
// For any other failure, log the cause so admins/devs can investigate
135+
// and return a plain 500 — never leak internal exception details in
136+
// the response body, and never return HTTP 200 for a failed render.
137+
Logger.error(this.getClass(), "Failed to render JSP: " + path, e);
138+
throw new WebApplicationException(
139+
Response.status(Response.Status.INTERNAL_SERVER_ERROR).build());
147140
}
148141

149142
}

dotCMS/src/main/webapp/WEB-INF/jsp/rules/include.jsp

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<%@ page import="com.dotmarketing.util.Config" %>
22
<%@page import="com.dotmarketing.business.APILocator"%>
3+
<%@page import="com.dotmarketing.business.PermissionAPI"%>
34
<%@page import="com.dotcms.repackage.org.apache.struts.Globals"%>
45
<%@ page import="com.dotmarketing.util.PortletURLUtil" %>
56
<%@page import="com.dotmarketing.portlets.contentlet.model.Contentlet"%>
@@ -51,6 +52,16 @@
5152
.build()
5253
);
5354
}
55+
56+
if (user == null || !APILocator.getPermissionAPI().doesUserHavePermission(
57+
contentlet, PermissionAPI.PERMISSION_READ, user, false)) {
58+
Logger.debug(this, "rules/include - user lacks READ on id=" + id);
59+
throw new WebApplicationException(
60+
Response.status(Response.Status.FORBIDDEN)
61+
.entity("Access denied")
62+
.build()
63+
);
64+
}
5465
%>
5566

5667

dotCMS/src/test/java/com/dotcms/rest/BaseRestPortletTest.java

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.dotcms.rest;
22

33
import static org.junit.Assert.assertEquals;
4+
import static org.junit.Assert.assertFalse;
45
import static org.junit.Assert.assertTrue;
56
import static org.junit.Assert.fail;
67
import static org.mockito.ArgumentMatchers.any;
@@ -14,6 +15,7 @@
1415
import java.lang.reflect.InvocationTargetException;
1516
import java.lang.reflect.Method;
1617
import javax.servlet.RequestDispatcher;
18+
import javax.servlet.ServletException;
1719
import javax.servlet.http.HttpServletRequest;
1820
import javax.servlet.http.HttpServletResponse;
1921
import javax.ws.rs.WebApplicationException;
@@ -117,8 +119,8 @@ public void getJspResponse_unwrapsWebApplicationExceptionFromServletException()
117119
Response.status(Response.Status.NOT_FOUND).entity("not found").build());
118120
// Tomcat/Jasper wraps any throwable raised inside a JSP in a ServletException
119121
// (specifically JasperException) before it reaches RequestDispatcher.include().
120-
final javax.servlet.ServletException wrapped =
121-
new javax.servlet.ServletException("jsp failed", root);
122+
final ServletException wrapped =
123+
new ServletException("jsp failed", root);
122124
doThrow(wrapped).when(mockDispatcher).include(any(), any());
123125

124126
try {
@@ -140,8 +142,8 @@ public void getJspResponse_unwrapsWebApplicationExceptionFromNestedCauseChain()
140142
Response.status(Response.Status.BAD_REQUEST).entity("bad id").build());
141143
// Real Jasper wraps the JSP throwable; wrap again to simulate any extra layer
142144
// (e.g. Jersey or filter-level wrappers) so the unwrap walks the full chain.
143-
final javax.servlet.ServletException middle =
144-
new javax.servlet.ServletException("jsp failed", root);
145+
final ServletException middle =
146+
new ServletException("jsp failed", root);
145147
final RuntimeException outer = new RuntimeException("outer", middle);
146148
doThrow(outer).when(mockDispatcher).include(any(), any());
147149

@@ -158,32 +160,46 @@ public void getJspResponse_unwrapsWebApplicationExceptionFromNestedCauseChain()
158160
}
159161

160162
// -------------------------------------------------------------------------
161-
// Ordinary exceptions → error HTML (existing behaviour preserved)
163+
// Non-WebApplicationException failures → WebApplicationException(500)
164+
// (no debug HTML, no HTTP 200, no internal details leaked in the response)
162165
// -------------------------------------------------------------------------
163166

164167
@Test
165-
public void getJspResponse_returnsErrorHtmlForGenericException() throws Exception {
168+
public void getJspResponse_throws500ForGenericException() throws Exception {
166169
doThrow(new IOException("disk full")).when(mockDispatcher).include(any(), any());
167170

168-
final Object result = getJspResponse.invoke(
169-
portlet, mockRequest, mockResponse, "rules", "include");
170-
171-
assertTrue("Result must be a String", result instanceof String);
172-
final String html = (String) result;
173-
assertTrue("Error HTML must mention the JSP path", html.contains("rules"));
174-
assertTrue("Error HTML must include the exception message", html.contains("disk full"));
171+
try {
172+
getJspResponse.invoke(portlet, mockRequest, mockResponse, "rules", "include");
173+
fail("Expected WebApplicationException(500) for non-WAE failures");
174+
} catch (final InvocationTargetException e) {
175+
assertTrue("Cause must be WebApplicationException",
176+
e.getCause() instanceof WebApplicationException);
177+
final WebApplicationException wae = (WebApplicationException) e.getCause();
178+
assertEquals("HTTP status must be 500",
179+
Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
180+
wae.getResponse().getStatus());
181+
assertFalse("Response body must not leak the internal exception message",
182+
wae.getResponse().hasEntity());
183+
}
175184
}
176185

177186
@Test
178-
public void getJspResponse_returnsErrorHtmlForRuntimeException() throws Exception {
187+
public void getJspResponse_throws500ForRuntimeException() throws Exception {
179188
doThrow(new IllegalArgumentException("bad uuid"))
180189
.when(mockDispatcher).include(any(), any());
181190

182-
final Object result = getJspResponse.invoke(
183-
portlet, mockRequest, mockResponse, "rules", "include");
184-
185-
assertTrue("Result must be a String", result instanceof String);
186-
assertTrue("Error HTML must include the exception message",
187-
((String) result).contains("bad uuid"));
191+
try {
192+
getJspResponse.invoke(portlet, mockRequest, mockResponse, "rules", "include");
193+
fail("Expected WebApplicationException(500) for non-WAE failures");
194+
} catch (final InvocationTargetException e) {
195+
assertTrue("Cause must be WebApplicationException",
196+
e.getCause() instanceof WebApplicationException);
197+
final WebApplicationException wae = (WebApplicationException) e.getCause();
198+
assertEquals("HTTP status must be 500",
199+
Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
200+
wae.getResponse().getStatus());
201+
assertFalse("Response body must not leak the internal exception message",
202+
wae.getResponse().hasEntity());
203+
}
188204
}
189205
}

0 commit comments

Comments
 (0)