Copilot commented on code in PR #1188:
URL: 
https://github.com/apache/flink-kubernetes-operator/pull/1188#discussion_r4530385209


##########
flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/artifact/HttpArtifactFetcher.java:
##########
@@ -264,4 +287,124 @@ private static boolean isRedirect(int status) {
                 || status == 307 // Temporary Redirect
                 || status == 308; // Permanent Redirect
     }
+
+    @VisibleForTesting
+    static Optional<InetAddress> validateHop(
+            String uri, Collection<String> allowedSchemes, boolean 
disallowRestrictedHosts)
+            throws IOException {
+        var validation =
+                JarUriValidationUtils.validateAndResolve(
+                        uri, allowedSchemes, disallowRestrictedHosts);
+        if (validation.getError().isPresent()) {
+            throw new IOException(
+                    "Refusing to fetch artifact from '"
+                            + uri
+                            + "': "
+                            + validation.getError().get());
+        }
+        return validation.getCheckedAddress();
+    }
+
+    private static boolean isDirect(String uri) {
+        var selector = ProxySelector.getDefault();
+        return selector == null
+                || selector.select(URI.create(uri)).stream()
+                        .allMatch(proxy -> proxy.type() == Proxy.Type.DIRECT);

Review Comment:
   When a ProxySelector returns [HTTP proxy, DIRECT], this predicate disables 
pinning for the entire list, but HttpURLConnection can fall back to an unpinned 
direct connection if the proxy fails. That fallback resolves the hostname again 
and leaves the restricted-host policy vulnerable to DNS rebinding. Handle proxy 
attempts explicitly so every DIRECT attempt is pinned (or fail closed), and add 
a regression test for proxy failure followed by DIRECT.



##########
flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/artifact/HttpArtifactFetcherTest.java:
##########
@@ -0,0 +1,422 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+package org.apache.flink.kubernetes.operator.artifact;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.kubernetes.operator.config.FlinkOperatorConfiguration;
+import 
org.apache.flink.kubernetes.operator.config.KubernetesOperatorConfigOptions;
+
+import com.sun.net.httpserver.Headers;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
+import com.sun.net.httpserver.HttpsConfigurator;
+import com.sun.net.httpserver.HttpsExchange;
+import com.sun.net.httpserver.HttpsServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import javax.net.ssl.ExtendedSSLSession;
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SNIHostName;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLHandshakeException;
+import javax.net.ssl.TrustManagerFactory;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.Proxy;
+import java.net.ProxySelector;
+import java.net.ServerSocket;
+import java.net.SocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.security.KeyStore;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link HttpArtifactFetcher}. */
+class HttpArtifactFetcherTest {
+
+    private static final byte[] ARTIFACT = 
"artifact-bytes".getBytes(StandardCharsets.UTF_8);
+    private static final byte[] LOOPBACK_BYTES = {127, 0, 0, 1};
+    private static final char[] KEYSTORE_PASSWORD = "changeit".toCharArray();
+    private static final String REJECTED_HOST = "rejected.invalid";
+
+    @TempDir Path tempDir;
+
+    private final List<RecordedRequest> requests = new 
CopyOnWriteArrayList<>();
+    private final List<HttpServer> servers = new ArrayList<>();
+    private final RecordingValidator validator = new RecordingValidator();
+    private final HttpArtifactFetcher fetcher = new 
HttpArtifactFetcher(validator);
+    private HttpServer server;
+    private int port;
+
+    @BeforeEach
+    void startServer() throws IOException {
+        server = start(HttpServer.create(new InetSocketAddress(loopback(), 0), 
0));
+        server.createContext("/job.jar", serveArtifact(requests));
+        port = server.getAddress().getPort();
+    }
+
+    @AfterEach
+    void stopServers() {
+        servers.forEach(s -> s.stop(0));
+    }
+
+    @Test
+    void connectsToCheckedAddressAndSendsOriginalHost() throws Exception {
+        var file = fetch("http://artifact.invalid:"; + port + "/job.jar", 
Map.of());
+
+        assertThat(file).hasBinaryContent(ARTIFACT);
+        assertThat(requests).hasSize(1);
+        
assertThat(requests.get(0).header("Host")).isEqualTo("artifact.invalid:" + 
port);
+    }
+
+    @Test
+    void 
validatesAndPinsEachRedirectHopAndDropsConfiguredHeadersOnHostChange() throws 
Exception {
+        var start = "http://artifact.invalid:"; + port + "/start";
+        var target = "http://other.invalid:"; + port + "/job.jar";
+        server.createContext("/start", exchange -> redirect(exchange, target));
+
+        fetch(start, Map.of("X-Token", "secret"));
+
+        assertThat(validator.validatedUris).containsExactly(start, target);
+        assertThat(requests).hasSize(2);
+        
assertThat(requests.get(0).header("Host")).isEqualTo("artifact.invalid:" + 
port);
+        assertThat(requests.get(0).header("X-Token")).isEqualTo("secret");
+        assertThat(requests.get(1).header("Host")).isEqualTo("other.invalid:" 
+ port);
+        assertThat(requests.get(1).header("X-Token")).isNull();
+    }
+
+    @Test
+    void refusesRedirectToRejectedHost() {
+        var target = "http://"; + REJECTED_HOST + ":" + port + "/job.jar";
+        server.createContext("/start", exchange -> redirect(exchange, target));
+
+        assertThatThrownBy(() -> fetch("http://artifact.invalid:"; + port + 
"/start", Map.of()))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Refusing to fetch artifact from '" + 
target + "'");
+        assertThat(requests).hasSize(1);
+    }
+
+    @Test
+    void configuredHeadersCannotOverrideHost() throws Exception {
+        fetch(
+                "http://artifact.invalid:"; + port + "/job.jar",
+                Map.of("Host", "other.invalid", "X-Token", "secret"));
+
+        assertThat(requests).hasSize(1);
+        
assertThat(requests.get(0).header("Host")).isEqualTo("artifact.invalid:" + 
port);
+        assertThat(requests.get(0).header("X-Token")).isEqualTo("secret");
+    }
+
+    @Test
+    void verifiesCertificateAndSendsSniForOriginalHostname() throws Exception {
+        var httpsPort = startHttpsServer().getAddress().getPort();
+
+        var file =
+                fetchTrustingTestCertificate("https://artifact.invalid:"; + 
httpsPort + "/job.jar");
+
+        assertThat(file).hasBinaryContent(ARTIFACT);
+        assertThat(requests).hasSize(1);
+        
assertThat(requests.get(0).header("Host")).isEqualTo("artifact.invalid:" + 
httpsPort);
+        
assertThat(requests.get(0).sniHostnames()).containsExactly("artifact.invalid");
+    }
+
+    @Test
+    void rejectsCertificateIssuedForAnotherHostname() throws Exception {
+        var httpsPort = startHttpsServer().getAddress().getPort();
+
+        assertThatThrownBy(
+                        () ->
+                                fetchTrustingTestCertificate(
+                                        "https://other.invalid:"; + httpsPort + 
"/job.jar"))
+                .isInstanceOf(SSLHandshakeException.class)
+                .hasMessageContaining("other.invalid");
+        assertThat(requests).isEmpty();
+    }
+
+    @Test
+    void doesNotPinWhenFetchingThroughProxy() throws Exception {
+        var proxyRequests = new CopyOnWriteArrayList<RecordedRequest>();
+        var proxyServer = start(HttpServer.create(new 
InetSocketAddress(loopback(), 0), 0));
+        proxyServer.createContext("/job.jar", serveArtifact(proxyRequests));
+        var proxy = new Proxy(Proxy.Type.HTTP, proxyServer.getAddress());
+
+        withProxySelector(
+                proxy, () -> fetch("http://artifact.invalid:"; + port + 
"/job.jar", Map.of()));
+
+        assertThat(requests).isEmpty();
+        assertThat(proxyRequests).hasSize(1);
+        assertThat(proxyRequests.get(0).uri())
+                .hasToString("http://artifact.invalid:"; + port + "/job.jar");
+    }
+
+    @Test
+    void doesNotPinHttpsWhenFetchingThroughProxy() throws Exception {
+        try (var proxySocket = new ServerSocket(0, 1, loopback())) {
+            var proxyPort = proxySocket.getLocalPort();
+            var firstLine = CompletableFuture.supplyAsync(() -> 
refuseTunnel(proxySocket));
+            var proxy = new Proxy(Proxy.Type.HTTP, new 
InetSocketAddress(loopback(), proxyPort));
+
+            assertThatThrownBy(
+                            () ->
+                                    withProxySelector(
+                                            proxy,
+                                            () ->
+                                                    fetch(
+                                                            
"https://artifact.invalid:";
+                                                                    + proxyPort
+                                                                    + 
"/job.jar",
+                                                            Map.of())))
+                    .isInstanceOf(IOException.class);
+            assertThat(firstLine.get(10, TimeUnit.SECONDS))
+                    .isEqualTo("CONNECT artifact.invalid:" + proxyPort + " 
HTTP/1.1");
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"http://192.0.2.1/job.jar";, 
"https://192.0.2.1/job.jar"})
+    void pinnedConnectionsHonourConnectTimeout(String uri) {
+        var operatorConfig = new Configuration();
+        operatorConfig.set(
+                KubernetesOperatorConfigOptions.JAR_URI_ALLOWED_SCHEMES, 
List.of("http", "https"));
+        operatorConfig.set(
+                KubernetesOperatorConfigOptions.JAR_FETCH_SOCKET_TIMEOUT, 
Duration.ofSeconds(1));
+        var fetchStart = System.currentTimeMillis();
+
+        assertThatThrownBy(
+                        () ->
+                                HttpArtifactFetcher.INSTANCE.fetch(
+                                        uri,
+                                        new Configuration(),
+                                        
FlinkOperatorConfiguration.fromConfiguration(
+                                                operatorConfig),
+                                        tempDir.toFile()))
+                .isInstanceOf(IOException.class)
+                .satisfies(
+                        e -> 
assertThat(e.getMessage()).containsIgnoringCase("connect timed out"));

Review Comment:
   192.0.2.1 is a documentation address, not a guaranteed TCP black hole. An 
IPv6-only environment or a network that actively rejects this traffic can 
immediately return 'Network is unreachable' or 'Connection refused', failing 
this assertion even when the connect timeout is correctly applied. Please 
replace the external-network dependency with a controllable socket/connector 
fixture that verifies the timeout deterministically.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to