Support for new property to ignore responses in exceptions thrown by the Client API. If the property jersey.config.client.ignoreExceptionResponse is set to true, any response in an exception thrown by the Client API will be mapped to an empty response that only includes the status code of the original one. This is to prevent accidental leaks of confidential data.

Signed-off-by: Santiago Pericasgeertsen <santiago.pericasgeertsen@oracle.com>
diff --git a/core-client/src/main/java/org/glassfish/jersey/client/ClientProperties.java b/core-client/src/main/java/org/glassfish/jersey/client/ClientProperties.java
index b01857a..c0e8515 100644
--- a/core-client/src/main/java/org/glassfish/jersey/client/ClientProperties.java
+++ b/core-client/src/main/java/org/glassfish/jersey/client/ClientProperties.java
@@ -168,6 +168,27 @@
     public static final String USE_ENCODING = "jersey.config.client.useEncoding";
 
     /**
+     * Ignore a response in an exception thrown by the client API by not forwarding
+     * it to this service's client. A value of {@code true} indicates that responses
+     * will be ignored, and only the response status will return to the client. This
+     * property will normally be specified as a system property; note that system
+     * properties are only visible if {@link CommonProperties#ALLOW_SYSTEM_PROPERTIES_PROVIDER}
+     * is set to {@code true}.
+     * <p>
+     * The value MUST be an instance convertible to {@link java.lang.Boolean}.
+     * </p>
+     * <p>
+     * The default value is {@code false}.
+     * </p>
+     * <p>
+     * The name of the configuration property is <tt>{@value}</tt>.
+     * </p>
+     *
+     * @see org.glassfish.jersey.CommonProperties#ALLOW_SYSTEM_PROPERTIES_PROVIDER
+     */
+    public static final String IGNORE_EXCEPTION_RESPONSE = "jersey.config.client.ignoreExceptionResponse";
+
+    /**
      * If {@code true} then disable auto-discovery on the client.
      * <p>
      * By default auto-discovery on client is automatically enabled if global
diff --git a/core-client/src/main/java/org/glassfish/jersey/client/JerseyInvocation.java b/core-client/src/main/java/org/glassfish/jersey/client/JerseyInvocation.java
index ceb9009..0c9453f 100644
--- a/core-client/src/main/java/org/glassfish/jersey/client/JerseyInvocation.java
+++ b/core-client/src/main/java/org/glassfish/jersey/client/JerseyInvocation.java
@@ -82,6 +82,8 @@
     // Copy request context when invoke or submit methods are invoked.
     private final boolean copyRequestContext;
 
+    private boolean ignoreResponseException;
+
     private JerseyInvocation(final Builder builder) {
         this(builder, false);
     }
@@ -91,6 +93,15 @@
 
         this.requestContext = new ClientRequest(builder.requestContext);
         this.copyRequestContext = copyRequestContext;
+
+        Object value = builder.requestContext.getConfiguration()
+                .getProperty(ClientProperties.IGNORE_EXCEPTION_RESPONSE);
+        if (value != null) {
+            Boolean booleanValue = PropertiesHelper.convertValue(value, Boolean.class);
+            if (booleanValue != null) {
+                this.ignoreResponseException = booleanValue;
+            }
+        }
     }
 
     private enum EntityPresence {
@@ -875,56 +886,60 @@
     }
 
     private ProcessingException convertToException(final Response response) {
+        // Use an empty response if ignoring response in exception
+        final int statusCode = response.getStatus();
+        final Response finalResponse = ignoreResponseException ? Response.status(statusCode).build() : response;
+
         try {
             // Buffer and close entity input stream (if any) to prevent
             // leaking connections (see JERSEY-2157).
             response.bufferEntity();
 
             final WebApplicationException webAppException;
-            final int statusCode = response.getStatus();
             final Response.Status status = Response.Status.fromStatusCode(statusCode);
 
             if (status == null) {
-                final Response.Status.Family statusFamily = response.getStatusInfo().getFamily();
-                webAppException = createExceptionForFamily(response, statusFamily);
+                final Response.Status.Family statusFamily = finalResponse.getStatusInfo().getFamily();
+                webAppException = createExceptionForFamily(finalResponse, statusFamily);
             } else {
                 switch (status) {
                     case BAD_REQUEST:
-                        webAppException = new BadRequestException(response);
+                        webAppException = new BadRequestException(finalResponse);
                         break;
                     case UNAUTHORIZED:
-                        webAppException = new NotAuthorizedException(response);
+                        webAppException = new NotAuthorizedException(finalResponse);
                         break;
                     case FORBIDDEN:
-                        webAppException = new ForbiddenException(response);
+                        webAppException = new ForbiddenException(finalResponse);
                         break;
                     case NOT_FOUND:
-                        webAppException = new NotFoundException(response);
+                        webAppException = new NotFoundException(finalResponse);
                         break;
                     case METHOD_NOT_ALLOWED:
-                        webAppException = new NotAllowedException(response);
+                        webAppException = new NotAllowedException(finalResponse);
                         break;
                     case NOT_ACCEPTABLE:
-                        webAppException = new NotAcceptableException(response);
+                        webAppException = new NotAcceptableException(finalResponse);
                         break;
                     case UNSUPPORTED_MEDIA_TYPE:
-                        webAppException = new NotSupportedException(response);
+                        webAppException = new NotSupportedException(finalResponse);
                         break;
                     case INTERNAL_SERVER_ERROR:
-                        webAppException = new InternalServerErrorException(response);
+                        webAppException = new InternalServerErrorException(finalResponse);
                         break;
                     case SERVICE_UNAVAILABLE:
-                        webAppException = new ServiceUnavailableException(response);
+                        webAppException = new ServiceUnavailableException(finalResponse);
                         break;
                     default:
-                        final Response.Status.Family statusFamily = response.getStatusInfo().getFamily();
-                        webAppException = createExceptionForFamily(response, statusFamily);
+                        final Response.Status.Family statusFamily = finalResponse.getStatusInfo().getFamily();
+                        webAppException = createExceptionForFamily(finalResponse, statusFamily);
                 }
             }
 
-            return new ResponseProcessingException(response, webAppException);
+            return new ResponseProcessingException(finalResponse, webAppException);
         } catch (final Throwable t) {
-            return new ResponseProcessingException(response, LocalizationMessages.RESPONSE_TO_EXCEPTION_CONVERSION_FAILED(), t);
+            return new ResponseProcessingException(finalResponse,
+                    LocalizationMessages.RESPONSE_TO_EXCEPTION_CONVERSION_FAILED(), t);
         }
     }
 
diff --git a/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/IgnoreExceptionResponseTest.java b/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/IgnoreExceptionResponseTest.java
new file mode 100644
index 0000000..88125d8
--- /dev/null
+++ b/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/IgnoreExceptionResponseTest.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2020 Oracle and/or its affiliates. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0, which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the
+ * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
+ * version 2 with the GNU Classpath Exception, which is available at
+ * https://www.gnu.org/software/classpath/license.html.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
+ */
+
+package org.glassfish.jersey.tests.e2e.client;
+
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.WebApplicationException;
+import javax.ws.rs.client.Client;
+import javax.ws.rs.client.ClientBuilder;
+import javax.ws.rs.core.Application;
+import javax.ws.rs.core.NewCookie;
+import javax.ws.rs.core.Response;
+import java.net.URI;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static junit.framework.TestCase.assertEquals;
+import static junit.framework.TestCase.assertFalse;
+import static junit.framework.TestCase.assertNull;
+import org.glassfish.jersey.CommonProperties;
+import org.glassfish.jersey.client.ClientProperties;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * Tests ignoring of client responses in exceptions.
+ *
+ * @author Santiago Pericas-Geertsen
+ */
+public class IgnoreExceptionResponseTest extends JerseyTest {
+
+    static String lastAllowSystemProperties;
+    static String lastIgnoreExceptionResponse;
+    static AtomicReference<URI> baseUri = new AtomicReference<>();
+
+    @Override
+    protected Application configure() {
+        return new ResourceConfig(TestResource.class);
+    }
+
+    public IgnoreExceptionResponseTest() {
+        baseUri.set(getBaseUri());
+    }
+
+    /**
+     * Sets ignore exception response as system property after enabling the provider.
+     */
+    @BeforeClass
+    public static void startUp() {
+        lastAllowSystemProperties = System.setProperty(CommonProperties.ALLOW_SYSTEM_PROPERTIES_PROVIDER, "true");
+        lastIgnoreExceptionResponse = System.setProperty(ClientProperties.IGNORE_EXCEPTION_RESPONSE, "true");
+    }
+
+    /**
+     * Restores state after completion.
+     */
+    @AfterClass
+    public static void cleanUp() {
+        if (lastIgnoreExceptionResponse != null) {
+            System.setProperty(ClientProperties.IGNORE_EXCEPTION_RESPONSE, lastIgnoreExceptionResponse);
+        }
+        if (lastAllowSystemProperties != null) {
+            System.setProperty(CommonProperties.ALLOW_SYSTEM_PROPERTIES_PROVIDER, lastAllowSystemProperties);
+        }
+    }
+
+    @Test
+    public void test() {
+        Client client = ClientBuilder.newClient();
+        Response r = client.target(getBaseUri())
+                .path("test")
+                .path("first")
+                .request()
+                .get();
+        assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), r.getStatus());
+        assertNull(r.getHeaderString("confidential"));
+        assertNull(r.getCookies().get("confidential"));
+        assertFalse(r.hasEntity());
+    }
+
+    @Path("test")
+    public static class TestResource {
+
+        @Path("first")
+        @GET
+        public String first() {
+            Client client = ClientBuilder.newClient();
+            String entity = client.target(baseUri.get())
+                    .path("test")
+                    .path("second")
+                    .request()
+                    .get(String.class);     // WebApplicationException may be thrown
+            return processEntity(entity);
+        }
+
+        @Path("second")
+        @GET
+        public String second() {
+            throw new WebApplicationException(
+                    "Leaking confidential information",
+                    Response.status(500)
+                            .header("confidential", "nuke-codes")
+                            .cookie(NewCookie.valueOf("confidential=more-nuke-codes"))
+                            .entity("even-more-nuke-codes")
+                            .build());
+        }
+
+        private String processEntity(String entity) {
+            return entity;          // filter confidential information
+        }
+    }
+}