diff --git a/.changes/next-release/feature-ApacheHTTPClient5-6dda086.json b/.changes/next-release/feature-ApacheHTTPClient5-6dda086.json new file mode 100644 index 000000000000..d55ea8f3e6ed --- /dev/null +++ b/.changes/next-release/feature-ApacheHTTPClient5-6dda086.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Apache HTTP Client 5", + "contributor": "", + "description": "Fail fast at Apache5HttpClient construction when SecurityManager is active and jdk.net.NetworkPermission setOption.TCP_KEEPIDLE, setOption.TCP_KEEPINTERVAL, setOption.TCP_KEEPCOUNT are not granted." +} diff --git a/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheSecurityManagerHttpCallTest.java b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheSecurityManagerHttpCallTest.java new file mode 100644 index 000000000000..7dd40f46dd10 --- /dev/null +++ b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheSecurityManagerHttpCallTest.java @@ -0,0 +1,35 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache; + +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpClientSecurityManagerTestSuite; + +@EnabledForJreRange(max = JRE.JAVA_17) +class ApacheSecurityManagerHttpCallTest extends SdkHttpClientSecurityManagerTestSuite { + + @Override + protected SdkHttpClient createHttpClient() { + return ApacheHttpClient.builder().build(); + } + + @Override + protected String getPolicyFileUrl() { + return getClass().getResource("security-manager-test.policy").toExternalForm(); + } +} diff --git a/http-clients/apache-client/src/test/resources/software/amazon/awssdk/http/apache/security-manager-test.policy b/http-clients/apache-client/src/test/resources/software/amazon/awssdk/http/apache/security-manager-test.policy new file mode 100644 index 000000000000..361f250aef9a --- /dev/null +++ b/http-clients/apache-client/src/test/resources/software/amazon/awssdk/http/apache/security-manager-test.policy @@ -0,0 +1,8 @@ +grant { + permission java.util.PropertyPermission "*", "read,write"; + permission java.lang.RuntimePermission "modifyThread"; + permission java.lang.RuntimePermission "setContextClassLoader"; + permission java.lang.RuntimePermission "setSecurityManager"; + permission java.lang.reflect.ReflectPermission "suppressAccessChecks"; + permission java.net.SocketPermission "*", "connect,accept"; +}; diff --git a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/Apache5HttpClient.java b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/Apache5HttpClient.java index 6a4b738fdac7..963413a4b32b 100644 --- a/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/Apache5HttpClient.java +++ b/http-clients/apache5-client/src/main/java/software/amazon/awssdk/http/apache5/Apache5HttpClient.java @@ -28,9 +28,11 @@ import java.net.InetAddress; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; +import java.security.Permission; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.time.Duration; +import java.util.Arrays; import java.util.Iterator; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -101,6 +103,7 @@ import software.amazon.awssdk.metrics.MetricCollector; import software.amazon.awssdk.metrics.NoOpMetricCollector; import software.amazon.awssdk.utils.AttributeMap; +import software.amazon.awssdk.utils.ClassLoaderHelper; import software.amazon.awssdk.utils.Logger; import software.amazon.awssdk.utils.Validate; @@ -543,6 +546,12 @@ public interface Builder extends SdkHttpClient.Builder authSchemeRegistry; private ProxyConfiguration proxyConfiguration = ProxyConfiguration.builder().build(); @@ -744,8 +753,46 @@ public void setAuthSchemeProviderRegistry(Registry authScheme public SdkHttpClient buildWithDefaults(AttributeMap serviceDefaults) { AttributeMap resolvedOptions = standardOptions.build().merge(serviceDefaults).merge( SdkHttpConfigurationOption.GLOBAL_HTTP_DEFAULTS); + checkTcpSocketOptionPermissions(); return new Apache5HttpClient(this, resolvedOptions); } + + /** + * Fails fast if a SecurityManager is active but denies the {@code jdk.net.NetworkPermission} entries + * that Apache HC5 requires for its default TCP keepalive socket options. + * No-op when no SecurityManager is installed (including Java 24+). + */ + private static void checkTcpSocketOptionPermissions() { + SecurityManager sm = System.getSecurityManager(); + if (sm == null) { + return; + } + + try { + Class permClass = ClassLoaderHelper.loadClass("jdk.net.NetworkPermission", Apache5HttpClient.class); + for (String permName : REQUIRED_TCP_SOCKET_OPTION_PERMISSIONS) { + Permission perm = (Permission) permClass.getConstructor(String.class).newInstance(permName); + sm.checkPermission(perm); + } + } catch (SecurityException e) { + if (isTcpSocketOptionPermissionDenied(e)) { + throw new IllegalStateException( + "Apache5HttpClient requires jdk.net.NetworkPermission for \"" + + String.join("\", \"", REQUIRED_TCP_SOCKET_OPTION_PERMISSIONS) + + "\" when a SecurityManager is active.", e); + } + log.debug(() -> "SecurityManager denied a non-TCP socket option permission during verification: " + + e.getMessage(), e); + } catch (Exception e) { + log.debug(() -> "Could not verify jdk.net.NetworkPermission for TCP socket options: " + e.getMessage(), e); + } + } + + private static boolean isTcpSocketOptionPermissionDenied(SecurityException securityException) { + String message = securityException.getMessage(); + return message != null && Arrays.stream(REQUIRED_TCP_SOCKET_OPTION_PERMISSIONS).anyMatch(message::contains); + } + } private static class ApacheConnectionManagerFactory { diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5SecurityManagerClientCreationTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5SecurityManagerClientCreationTest.java new file mode 100644 index 000000000000..7c27e677e59b --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5SecurityManagerClientCreationTest.java @@ -0,0 +1,150 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.security.Permission; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Stream; +import org.apache.logging.log4j.Level; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.testutils.LogCaptor; + +/** + * Tests that Apache5HttpClient fails fast at construction time when a SecurityManager + * denies jdk.net.NetworkPermission for TCP keepalive extended options. + */ +@EnabledForJreRange(max = JRE.JAVA_17) +class Apache5SecurityManagerClientCreationTest { + + @AfterEach + void tearDown() { + System.setSecurityManager(null); + System.clearProperty("java.security.policy"); + java.security.Policy.getPolicy().refresh(); + } + + @Test + void buildWithDefaults_whenStandardPermissionsGrantedButNetworkPermissionMissing_shouldThrowIllegalStateException() { + System.setProperty("java.security.policy", "=" + getPolicyUrl()); + java.security.Policy.getPolicy().refresh(); + System.setSecurityManager(new SecurityManager()); + + assertThatThrownBy(() -> Apache5HttpClient.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("jdk.net.NetworkPermission"); + } + + private String getPolicyUrl() { + return getClass().getResource("security-manager-test.policy").toExternalForm(); + } + + @Test + void buildWithDefaults_whenUnrelatedSecurityExceptionThrown_shouldNotThrow() { + System.setSecurityManager(new SecurityManager() { + @Override + public void checkPermission(Permission perm) { + if ("jdk.net.NetworkPermission".equals(perm.getClass().getName())) { + throw new SecurityException("access denied: some.unrelated.permission"); + } + } + }); + + try (LogCaptor logCaptor = LogCaptor.create(Level.DEBUG)) { + assertThatNoException().isThrownBy(() -> { + Apache5HttpClient.builder().build().close(); + }); + assertThat(logCaptor.loggedEvents()).anySatisfy(logEvent -> { + assertThat(logEvent.getLevel()).isEqualTo(Level.DEBUG); + assertThat(logEvent.getMessage().getFormattedMessage()) + .contains("SecurityManager denied a non-TCP socket option permission"); + }); + } + } + + @ParameterizedTest + @MethodSource("partiallyGrantedPermissions") + void buildWithDefaults_whenNotAllPermissionsGranted_shouldThrowIllegalStateException(Set grantedPermissions) { + System.setSecurityManager(new GrantOnlyNetworkPermissionSecurityManager(grantedPermissions)); + + assertThatThrownBy(() -> Apache5HttpClient.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("jdk.net.NetworkPermission"); + } + + @Test + void buildWithDefaults_whenAllPermissionsGranted_shouldSucceed() { + Set allGranted = new HashSet<>(Arrays.asList( + "setOption.TCP_KEEPIDLE", "setOption.TCP_KEEPINTERVAL", "setOption.TCP_KEEPCOUNT")); + System.setSecurityManager(new GrantOnlyNetworkPermissionSecurityManager(allGranted)); + assertThatNoException().isThrownBy(() -> { + Apache5HttpClient.builder().build().close(); + }); + } + + @Test + void buildWithDefaults_whenNoSecurityManager_shouldSucceed() { + assertThatNoException().isThrownBy(() -> { + Apache5HttpClient.builder().build().close(); + }); + } + + static Stream partiallyGrantedPermissions() { + return Stream.of( + // 0 out of 3 granted + Arguments.of(new HashSet<>()), + // 1 out of 3 granted + Arguments.of(new HashSet<>(Arrays.asList("setOption.TCP_KEEPIDLE"))), + Arguments.of(new HashSet<>(Arrays.asList("setOption.TCP_KEEPINTERVAL"))), + Arguments.of(new HashSet<>(Arrays.asList("setOption.TCP_KEEPCOUNT"))), + // 2 out of 3 granted + Arguments.of(new HashSet<>(Arrays.asList("setOption.TCP_KEEPIDLE", "setOption.TCP_KEEPINTERVAL"))), + Arguments.of(new HashSet<>(Arrays.asList("setOption.TCP_KEEPIDLE", "setOption.TCP_KEEPCOUNT"))), + Arguments.of(new HashSet<>(Arrays.asList("setOption.TCP_KEEPINTERVAL", "setOption.TCP_KEEPCOUNT"))) + ); + } + + /** + * SecurityManager that only grants specific jdk.net.NetworkPermission entries and denies the rest. + */ + private static class GrantOnlyNetworkPermissionSecurityManager extends SecurityManager { + private final Set grantedPermissions; + + GrantOnlyNetworkPermissionSecurityManager(Set grantedPermissions) { + this.grantedPermissions = grantedPermissions; + } + + @Override + public void checkPermission(Permission perm) { + if ("jdk.net.NetworkPermission".equals(perm.getClass().getName()) + && !grantedPermissions.contains(perm.getName())) { + throw new SecurityException("Denied: " + perm.getName()); + } + } + } + +} diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5SecurityManagerHttpCallTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5SecurityManagerHttpCallTest.java new file mode 100644 index 000000000000..1d105321a5d2 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5SecurityManagerHttpCallTest.java @@ -0,0 +1,35 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.apache5; + +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpClientSecurityManagerTestSuite; + +@EnabledForJreRange(max = JRE.JAVA_17) +class Apache5SecurityManagerHttpCallTest extends SdkHttpClientSecurityManagerTestSuite { + + @Override + protected SdkHttpClient createHttpClient() { + return Apache5HttpClient.builder().build(); + } + + @Override + protected String getPolicyFileUrl() { + return getClass().getResource("security-manager-test-with-http-call.policy").toExternalForm(); + } +} diff --git a/http-clients/apache5-client/src/test/resources/software/amazon/awssdk/http/apache5/security-manager-test-with-http-call.policy b/http-clients/apache5-client/src/test/resources/software/amazon/awssdk/http/apache5/security-manager-test-with-http-call.policy new file mode 100644 index 000000000000..d0964d39fd5d --- /dev/null +++ b/http-clients/apache5-client/src/test/resources/software/amazon/awssdk/http/apache5/security-manager-test-with-http-call.policy @@ -0,0 +1,13 @@ +grant { + permission java.util.PropertyPermission "*", "read,write"; + permission java.lang.RuntimePermission "modifyThread"; + permission java.lang.RuntimePermission "setContextClassLoader"; + permission java.lang.RuntimePermission "setSecurityManager"; + permission java.lang.reflect.ReflectPermission "suppressAccessChecks"; + permission java.net.SocketPermission "*", "connect,accept"; + + // Required by Apache HC5 for TCP socket options (not needed by Apache HC4) + permission jdk.net.NetworkPermission "setOption.TCP_KEEPIDLE"; + permission jdk.net.NetworkPermission "setOption.TCP_KEEPINTERVAL"; + permission jdk.net.NetworkPermission "setOption.TCP_KEEPCOUNT"; +}; diff --git a/http-clients/apache5-client/src/test/resources/software/amazon/awssdk/http/apache5/security-manager-test.policy b/http-clients/apache5-client/src/test/resources/software/amazon/awssdk/http/apache5/security-manager-test.policy new file mode 100644 index 000000000000..39f450d6d442 --- /dev/null +++ b/http-clients/apache5-client/src/test/resources/software/amazon/awssdk/http/apache5/security-manager-test.policy @@ -0,0 +1,15 @@ +grant { + permission java.util.PropertyPermission "*", "read,write"; + permission java.io.FilePermission "<>", "read,write"; + permission java.lang.RuntimePermission "getenv.*"; + permission "java.lang.RuntimePermission" "accessDeclaredMembers"; + permission "javax.net.ssl.SSLPermission" "setDefaultSSLContext"; + permission "java.net.SocketPermission" "*", "connect,resolve"; + + // Needed for test to remove the security manager + permission java.lang.RuntimePermission "setSecurityManager"; + + // jdk.net.NetworkPermission for setOption.TCP_KEEPIDLE, setOption.TCP_KEEPINTERVAL, + // setOption.TCP_KEEPCOUNT is explicitly NOT granted to test that Apache5HttpClient + // fails fast when these permissions are missing. +}; diff --git a/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientSecurityManagerTestSuite.java b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientSecurityManagerTestSuite.java new file mode 100644 index 000000000000..7a0da421f232 --- /dev/null +++ b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientSecurityManagerTestSuite.java @@ -0,0 +1,95 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.net.URI; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; + +/** + * Base test suite that verifies an HTTP client can construct and execute requests + * under a SecurityManager with the appropriate permissions granted via a policy file. + * + *

Subclasses provide the HTTP client implementation and a policy file path. + * The policy file for Apache 4.x does not need jdk.net.NetworkPermission entries, + * while Apache 5.x requires them for TCP_KEEPIDLE/KEEPINTERVAL/KEEPCOUNT.

+ */ +@EnabledForJreRange(max = JRE.JAVA_17) +public abstract class SdkHttpClientSecurityManagerTestSuite { + + private WireMockServer server; + + @BeforeEach + void setUpServer() { + server = new WireMockServer(wireMockConfig().dynamicPort()); + server.start(); + server.stubFor(get(urlPathEqualTo("/")) + .willReturn(aResponse().withStatus(200).withBody("ok"))); + } + + @AfterEach + void tearDownServer() { + System.setSecurityManager(null); + System.clearProperty("java.security.policy"); + java.security.Policy.getPolicy().refresh(); + server.stop(); + } + + /** + * Creates the HTTP client to test. + */ + protected abstract SdkHttpClient createHttpClient(); + + /** + * Returns the policy file URL to use. Subclasses load from their own resource path. + */ + protected abstract String getPolicyFileUrl(); + + @Test + void httpCallSucceedsWhenSecurityManagerActiveWithCorrectPermissions() throws Exception { + System.setProperty("java.security.policy", "=" + getPolicyFileUrl()); + java.security.Policy.getPolicy().refresh(); + System.setSecurityManager(new SecurityManager()); + + SdkHttpClient client = createHttpClient(); + try { + SdkHttpFullRequest request = SdkHttpFullRequest.builder() + .uri(URI.create("http://localhost:" + server.port() + "/")) + .method(SdkHttpMethod.GET) + .build(); + HttpExecuteResponse response = client.prepareRequest( + HttpExecuteRequest.builder().request(request).build()).call(); + + assertThat(response.httpResponse().statusCode()).isEqualTo(200); + } finally { + client.close(); + } + } + + protected int serverPort() { + return server.port(); + } +}