This is an automated email from the ASF dual-hosted git repository.

gyfora pushed a commit to branch release-1.16
in repository https://gitbox.apache.org/repos/asf/flink-kubernetes-operator.git

commit f4f45824dbbe15f551add54adc059a61c2b3cf39
Author: Purushottam Sinha <[email protected]>
AuthorDate: Fri Aug 21 13:17:18 2026 +0530

    [FLINK-40402] Harden job artifact fetching against slow-host 
reconcile-thread exhaustion (#1186)
    
    * [FLINK-40402] Harden job artifact fetching against slow-host 
reconcile-thread exhaustion.
    
    Add connect/read/total timeouts and a size cap to HttpArtifactFetcher so a
    slow or unresponsive FlinkSessionJob jarURI host can no longer pin a
    reconcile thread indefinitely.
    
    Generated-by: Claude Code
    
    * [FLINK-40402] Make artifact-fetch failure messages consistent and 
traceable.
    
    The size-cap and total-timeout errors thrown while streaming the body did 
not
    name the offending jarURI or the configured value, unlike the declared-size
    and redirect-timeout errors thrown earlier in the fetch. Pass the URI and 
the
    total timeout into copyBounded so every fetch failure names the artifact, 
and
    unify the wording: both size errors now read "exceeds the configured limit"
    and both timeout errors read "Timed out (> <duration>) while fetching 
artifact
    from '<uri>'".
    
    Generated-by: Claude Code
    
    * [FLINK-40402] Add regression tests for the connect and read fetch 
timeouts.
    
    The existing coverage only exercised the overall (total) fetch timeout via a
    slow trickle. Add two tests that isolate the per-connection socket timeouts,
    each with a much longer total timeout so it is unambiguously the socket
    timeout that bounds them:
      - connect timeout: fetching from a blackholed address (TEST-NET-1) whose
        SYNs are dropped, so the TCP connect never completes;
      - read timeout: a server that sends response headers then stalls without
        sending the body, so a client read blocks.
    
    Generated-by: Claude Code
---
 docs/content.zh/docs/deployment/security.md        |   6 +
 docs/content/docs/deployment/security.md           |   6 +
 .../kubernetes_operator_config_configuration.html  |  18 ++
 .../generated/system_reconcile_section.html        |  18 ++
 .../operator/artifact/ArtifactManager.java         |  16 +-
 .../operator/artifact/HttpArtifactFetcher.java     | 114 +++++++-
 .../config/FlinkOperatorConfiguration.java         |  18 +-
 .../config/KubernetesOperatorConfigOptions.java    |  34 +++
 .../operator/artifact/ArtifactManagerTest.java     | 311 +++++++++++++++++++++
 9 files changed, 515 insertions(+), 26 deletions(-)

diff --git a/docs/content.zh/docs/deployment/security.md 
b/docs/content.zh/docs/deployment/security.md
index e1dd778d..9236604b 100644
--- a/docs/content.zh/docs/deployment/security.md
+++ b/docs/content.zh/docs/deployment/security.md
@@ -66,6 +66,12 @@ For a `FlinkSessionJob`, the operator itself downloads the 
job artifact referenc
 - `kubernetes.operator.user.artifacts.disallow-restricted-hosts` (default 
`true`): rejects `http` and `https` URIs whose host resolves to a loopback, 
link-local, site-local, wildcard, or multicast address, so a session job cannot 
point the operator at cluster-internal endpoints.
 - `kubernetes.operator.user.artifacts.http.header`: custom HTTP headers sent 
when fetching artifacts over `http` and `https`, typically carrying the 
credentials of the artifact store.
 
+The fetch itself runs synchronously on the reconcile thread, so a slow or 
unresponsive artifact host could otherwise pin that thread and, with enough 
concurrent session jobs, exhaust the bounded reconcile pool 
(`kubernetes.operator.reconcile.parallelism`). Two settings bound the reconcile 
thread's exposure to such a host, and one bounds the response size:
+
+- `kubernetes.operator.user.artifacts.http.socket-timeout` (default `30 s`): 
the connect and per-read socket timeout for the underlying HTTP connection.
+- `kubernetes.operator.user.artifacts.http.total-timeout` (default `5 min`): 
the overall wall-clock budget for the whole fetch, covering all redirects and 
the full body transfer. This is what bounds a host that trickles data just 
slowly enough to keep individual reads under the socket timeout without the 
transfer ever completing.
+- `kubernetes.operator.user.artifacts.max-size` (default `1 gb`): the maximum 
artifact size. The download is rejected once it exceeds this, whether or not 
the server declares a `Content-Length` up front.
+
 Application-mode deployments are not affected: their `jarURI` is resolved 
inside the job's own cluster, not by the operator.
 
 ## Secrets in Configuration
diff --git a/docs/content/docs/deployment/security.md 
b/docs/content/docs/deployment/security.md
index e1dd778d..9236604b 100644
--- a/docs/content/docs/deployment/security.md
+++ b/docs/content/docs/deployment/security.md
@@ -66,6 +66,12 @@ For a `FlinkSessionJob`, the operator itself downloads the 
job artifact referenc
 - `kubernetes.operator.user.artifacts.disallow-restricted-hosts` (default 
`true`): rejects `http` and `https` URIs whose host resolves to a loopback, 
link-local, site-local, wildcard, or multicast address, so a session job cannot 
point the operator at cluster-internal endpoints.
 - `kubernetes.operator.user.artifacts.http.header`: custom HTTP headers sent 
when fetching artifacts over `http` and `https`, typically carrying the 
credentials of the artifact store.
 
+The fetch itself runs synchronously on the reconcile thread, so a slow or 
unresponsive artifact host could otherwise pin that thread and, with enough 
concurrent session jobs, exhaust the bounded reconcile pool 
(`kubernetes.operator.reconcile.parallelism`). Two settings bound the reconcile 
thread's exposure to such a host, and one bounds the response size:
+
+- `kubernetes.operator.user.artifacts.http.socket-timeout` (default `30 s`): 
the connect and per-read socket timeout for the underlying HTTP connection.
+- `kubernetes.operator.user.artifacts.http.total-timeout` (default `5 min`): 
the overall wall-clock budget for the whole fetch, covering all redirects and 
the full body transfer. This is what bounds a host that trickles data just 
slowly enough to keep individual reads under the socket timeout without the 
transfer ever completing.
+- `kubernetes.operator.user.artifacts.max-size` (default `1 gb`): the maximum 
artifact size. The download is rejected once it exceeds this, whether or not 
the server declares a `Content-Length` up front.
+
 Application-mode deployments are not affected: their `jarURI` is resolved 
inside the job's own cluster, not by the operator.
 
 ## Secrets in Configuration
diff --git 
a/docs/layouts/shortcodes/generated/kubernetes_operator_config_configuration.html
 
b/docs/layouts/shortcodes/generated/kubernetes_operator_config_configuration.html
index cd8513bf..502cedb2 100644
--- 
a/docs/layouts/shortcodes/generated/kubernetes_operator_config_configuration.html
+++ 
b/docs/layouts/shortcodes/generated/kubernetes_operator_config_configuration.html
@@ -500,6 +500,24 @@
             <td>Map</td>
             <td>Custom HTTP header for HttpArtifactFetcher. The header will be 
applied when getting the session job artifacts. Expected format: 
headerKey1:headerValue1,headerKey2:headerValue2.</td>
         </tr>
+        <tr>
+            
<td><h5>kubernetes.operator.user.artifacts.http.socket-timeout</h5></td>
+            <td style="word-wrap: break-word;">30 s</td>
+            <td>Duration</td>
+            <td>The connect and socket read timeout for downloading a 
FlinkSessionJob jarURI over http(s). Bounds how long the reconcile thread can 
be blocked establishing the connection, or waiting for the next byte, from an 
unresponsive artifact host.</td>
+        </tr>
+        <tr>
+            
<td><h5>kubernetes.operator.user.artifacts.http.total-timeout</h5></td>
+            <td style="word-wrap: break-word;">5 min</td>
+            <td>Duration</td>
+            <td>The total wall-clock budget for downloading a FlinkSessionJob 
jarURI over http(s), covering all redirects and the full body transfer. Unlike 
the socket timeout, this bounds the overall download even against a slow host 
that keeps trickling data fast enough to avoid tripping it.</td>
+        </tr>
+        <tr>
+            <td><h5>kubernetes.operator.user.artifacts.max-size</h5></td>
+            <td style="word-wrap: break-word;">1 gb</td>
+            <td>MemorySize</td>
+            <td>The maximum size of a FlinkSessionJob jarURI artifact fetched 
over http(s). The download is rejected once it exceeds this size, whether or 
not the server declares a Content-Length up front.</td>
+        </tr>
         <tr>
             <td><h5>kubernetes.operator.watched.namespaces</h5></td>
             <td style="word-wrap: break-word;">"JOSDK_ALL_NAMESPACES"</td>
diff --git a/docs/layouts/shortcodes/generated/system_reconcile_section.html 
b/docs/layouts/shortcodes/generated/system_reconcile_section.html
index bb2769c4..5633ace9 100644
--- a/docs/layouts/shortcodes/generated/system_reconcile_section.html
+++ b/docs/layouts/shortcodes/generated/system_reconcile_section.html
@@ -80,5 +80,23 @@
             <td>Boolean</td>
             <td>If enabled, FlinkSessionJob jarURI hosts that resolve to 
loopback, link-local, site-local, wildcard or multicast addresses are rejected 
during validation. Disable only if the operator legitimately needs to fetch 
from such addresses.</td>
         </tr>
+        <tr>
+            
<td><h5>kubernetes.operator.user.artifacts.http.socket-timeout</h5></td>
+            <td style="word-wrap: break-word;">30 s</td>
+            <td>Duration</td>
+            <td>The connect and socket read timeout for downloading a 
FlinkSessionJob jarURI over http(s). Bounds how long the reconcile thread can 
be blocked establishing the connection, or waiting for the next byte, from an 
unresponsive artifact host.</td>
+        </tr>
+        <tr>
+            
<td><h5>kubernetes.operator.user.artifacts.http.total-timeout</h5></td>
+            <td style="word-wrap: break-word;">5 min</td>
+            <td>Duration</td>
+            <td>The total wall-clock budget for downloading a FlinkSessionJob 
jarURI over http(s), covering all redirects and the full body transfer. Unlike 
the socket timeout, this bounds the overall download even against a slow host 
that keeps trickling data fast enough to avoid tripping it.</td>
+        </tr>
+        <tr>
+            <td><h5>kubernetes.operator.user.artifacts.max-size</h5></td>
+            <td style="word-wrap: break-word;">1 gb</td>
+            <td>MemorySize</td>
+            <td>The maximum size of a FlinkSessionJob jarURI artifact fetched 
over http(s). The download is rejected once it exceeds this size, whether or 
not the server declares a Content-Length up front.</td>
+        </tr>
     </tbody>
 </table>
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/artifact/ArtifactManager.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/artifact/ArtifactManager.java
index 7e229573..d755a14b 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/artifact/ArtifactManager.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/artifact/ArtifactManager.java
@@ -20,7 +20,6 @@ package org.apache.flink.kubernetes.operator.artifact;
 import org.apache.flink.configuration.Configuration;
 import org.apache.flink.kubernetes.operator.api.spec.FlinkSessionJobSpec;
 import org.apache.flink.kubernetes.operator.config.FlinkConfigManager;
-import 
org.apache.flink.kubernetes.operator.config.KubernetesOperatorConfigOptions;
 import org.apache.flink.util.FlinkRuntimeException;
 
 import io.fabric8.kubernetes.api.model.ObjectMeta;
@@ -59,17 +58,12 @@ public class ArtifactManager {
         createIfNotExists(targetDir);
         URI uri = new URI(jarURI);
         if ("http".equals(uri.getScheme()) || "https".equals(uri.getScheme())) 
{
-            // Take the scheme/host policy from the operator config (matching 
DefaultValidator);
-            // clone so the caller's config is not mutated.
+            // The scheme/host policy, fetch timeouts and size cap come from 
the operator config
+            // (matching DefaultValidator), not the tenant-influenced 
flinkConfiguration, so it's
+            // passed to the fetcher separately rather than merged into 
flinkConfiguration.
             var operatorConfig = configManager.getOperatorConfiguration();
-            var fetchConfig = flinkConfiguration.clone();
-            fetchConfig.set(
-                    KubernetesOperatorConfigOptions.JAR_URI_ALLOWED_SCHEMES,
-                    operatorConfig.getJarUriAllowedSchemes());
-            fetchConfig.set(
-                    
KubernetesOperatorConfigOptions.JAR_URI_DISALLOW_RESTRICTED_HOSTS,
-                    operatorConfig.isJarUriDisallowRestrictedHosts());
-            return HttpArtifactFetcher.INSTANCE.fetch(jarURI, fetchConfig, 
targetDir);
+            return HttpArtifactFetcher.INSTANCE.fetch(
+                    jarURI, flinkConfiguration, operatorConfig, targetDir);
         } else {
             return FileSystemBasedArtifactFetcher.INSTANCE.fetch(
                     jarURI, flinkConfiguration, targetDir);
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/artifact/HttpArtifactFetcher.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/artifact/HttpArtifactFetcher.java
index 09e517c6..3a7a8b74 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/artifact/HttpArtifactFetcher.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/artifact/HttpArtifactFetcher.java
@@ -18,6 +18,7 @@
 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 org.apache.flink.kubernetes.operator.utils.JarUriValidationUtils;
 
@@ -27,18 +28,22 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.File;
+import java.io.FileOutputStream;
 import java.io.IOException;
+import java.io.InputStream;
 import java.net.HttpURLConnection;
 import java.net.MalformedURLException;
 import java.net.URL;
+import java.time.Duration;
 import java.util.Map;
 
 /**
- * Download the jar from the http resource. The scheme allowlist and 
restricted-host policy are read
- * from the given configuration; {@link ArtifactManager} sets them from the 
operator configuration
- * before calling.
+ * Download the jar from the http resource. The scheme allowlist, 
restricted-host policy, fetch
+ * timeouts and size cap come from the trusted operator configuration passed 
to {@link #fetch}, not
+ * the (possibly tenant-influenced) {@code flinkConfiguration}, which only 
supplies the HTTP
+ * headers.
  */
-public class HttpArtifactFetcher implements ArtifactFetcher {
+public class HttpArtifactFetcher {
 
     public static final Logger LOG = 
LoggerFactory.getLogger(HttpArtifactFetcher.class);
     public static final HttpArtifactFetcher INSTANCE = new 
HttpArtifactFetcher();
@@ -46,18 +51,26 @@ public class HttpArtifactFetcher implements ArtifactFetcher 
{
     // Maximum number of redirects to follow before giving up.
     private static final int MAX_REDIRECTS = 5;
 
-    @Override
-    public File fetch(String uri, Configuration flinkConfiguration, File 
targetDir)
+    // Chunk size used when streaming the response body to disk.
+    private static final int COPY_BUFFER_SIZE = 8 * 1024;
+
+    public File fetch(
+            String uri,
+            Configuration flinkConfiguration,
+            FlinkOperatorConfiguration operatorConfig,
+            File targetDir)
             throws Exception {
         var start = System.currentTimeMillis();
 
-        // Scheme allowlist and restricted-host policy, set by ArtifactManager 
from the operator
-        // configuration.
-        var allowedSchemes =
-                
flinkConfiguration.get(KubernetesOperatorConfigOptions.JAR_URI_ALLOWED_SCHEMES);
-        var disallowRestrictedHosts =
-                flinkConfiguration.get(
-                        
KubernetesOperatorConfigOptions.JAR_URI_DISALLOW_RESTRICTED_HOSTS);
+        var allowedSchemes = operatorConfig.getJarUriAllowedSchemes();
+        var disallowRestrictedHosts = 
operatorConfig.isJarUriDisallowRestrictedHosts();
+        var socketTimeoutMillis = (int) 
operatorConfig.getJarFetchSocketTimeout().toMillis();
+        var totalTimeout = operatorConfig.getJarFetchTotalTimeout();
+        var maxArtifactSize = 
operatorConfig.getJarArtifactMaxSize().getBytes();
+        // Overall wall-clock deadline for the whole fetch (all redirects + 
the body transfer).
+        // This bounds the reconcile thread even against a host that keeps 
trickling data slowly
+        // enough to never trip the socket timeout on its own.
+        var deadline = start + totalTimeout.toMillis();
 
         // merged session job level header and cluster level header, session 
job level header take
         // precedence.
@@ -72,6 +85,14 @@ public class HttpArtifactFetcher implements ArtifactFetcher {
         HttpURLConnection conn;
         int redirects = 0;
         while (true) {
+            if (System.currentTimeMillis() > deadline) {
+                throw new IOException(
+                        "Timed out (> "
+                                + totalTimeout
+                                + ") while fetching artifact from '"
+                                + uri
+                                + "'");
+            }
             var validationError =
                     JarUriValidationUtils.validateJarURI(
                             currentUri, allowedSchemes, 
disallowRestrictedHosts);
@@ -89,6 +110,8 @@ public class HttpArtifactFetcher implements ArtifactFetcher {
             }
             conn = (HttpURLConnection) currentUrl.openConnection();
             conn.setInstanceFollowRedirects(false);
+            conn.setConnectTimeout(socketTimeoutMillis);
+            conn.setReadTimeout(socketTimeoutMillis);
             // Only send the configured headers to the original host; drop 
them on a cross-host
             // redirect.
             if (headers != null && 
originalUrl.getHost().equalsIgnoreCase(currentUrl.getHost())) {
@@ -154,12 +177,30 @@ public class HttpArtifactFetcher implements 
ArtifactFetcher {
             }
         }
 
+        // Fail fast if the server declares a size beyond the cap; a 
malicious/misconfigured
+        // server can still lie about this, so the copy below enforces the cap 
regardless.
+        long declaredLength = conn.getContentLengthLong();
+        if (declaredLength > maxArtifactSize) {
+            conn.disconnect();
+            throw new IOException(
+                    "Refusing to fetch artifact from '"
+                            + uri
+                            + "': declared size "
+                            + declaredLength
+                            + " bytes exceeds the configured limit of "
+                            + maxArtifactSize
+                            + " bytes");
+        }
+
         // Name the file from the original jarURI, not the redirect target, so 
a redirect can't
         // change it (e.g. drop the .jar extension the JobManager upload 
requires).
         String fileName = FilenameUtils.getName(originalUrl.getPath());
         File targetFile = new File(targetDir, fileName);
         try (var inputStream = conn.getInputStream()) {
-            FileUtils.copyToFile(inputStream, targetFile);
+            copyBounded(inputStream, targetFile, maxArtifactSize, deadline, 
uri, totalTimeout);
+        } catch (Exception e) {
+            targetFile.delete();
+            throw e;
         } finally {
             conn.disconnect();
         }
@@ -171,6 +212,51 @@ public class HttpArtifactFetcher implements 
ArtifactFetcher {
         return targetFile;
     }
 
+    /**
+     * Streams {@code inputStream} to {@code targetFile}, aborting if the 
total bytes written exceed
+     * {@code maxBytes} or {@code deadline} (wall-clock millis) passes. Each 
individual {@link
+     * InputStream#read} is already bounded by the connection's read timeout, 
so the deadline check
+     * here is what catches a host that trickles data just fast enough to keep 
each read below that
+     * timeout without ever finishing.
+     */
+    private static void copyBounded(
+            InputStream inputStream,
+            File targetFile,
+            long maxBytes,
+            long deadline,
+            String uri,
+            Duration totalTimeout)
+            throws IOException {
+        FileUtils.forceMkdirParent(targetFile);
+        byte[] buffer = new byte[COPY_BUFFER_SIZE];
+        long total = 0;
+        try (var outputStream = new FileOutputStream(targetFile)) {
+            int read;
+            while ((read = inputStream.read(buffer)) != -1) {
+                total += read;
+                if (total > maxBytes) {
+                    throw new IOException(
+                            "Refusing to fetch artifact from '"
+                                    + uri
+                                    + "': downloaded size "
+                                    + total
+                                    + " bytes exceeds the configured limit of "
+                                    + maxBytes
+                                    + " bytes");
+                }
+                if (System.currentTimeMillis() > deadline) {
+                    throw new IOException(
+                            "Timed out (> "
+                                    + totalTimeout
+                                    + ") while fetching artifact from '"
+                                    + uri
+                                    + "'");
+                }
+                outputStream.write(buffer, 0, read);
+            }
+        }
+    }
+
     private static boolean isRedirect(int status) {
         return status == HttpURLConnection.HTTP_MOVED_PERM
                 || status == HttpURLConnection.HTTP_MOVED_TEMP
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/config/FlinkOperatorConfiguration.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/config/FlinkOperatorConfiguration.java
index 14da8747..f70198f6 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/config/FlinkOperatorConfiguration.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/config/FlinkOperatorConfiguration.java
@@ -20,6 +20,7 @@ package org.apache.flink.kubernetes.operator.config;
 
 import org.apache.flink.configuration.Configuration;
 import org.apache.flink.configuration.IllegalConfigurationException;
+import org.apache.flink.configuration.MemorySize;
 import 
org.apache.flink.kubernetes.operator.metrics.KubernetesOperatorMetricOptions;
 import org.apache.flink.kubernetes.operator.utils.EnvUtils;
 
@@ -84,6 +85,9 @@ public class FlinkOperatorConfiguration {
     Duration jobSubmissionTimeout;
     List<String> jarUriAllowedSchemes;
     boolean jarUriDisallowRestrictedHosts;
+    Duration jarFetchSocketTimeout;
+    Duration jarFetchTotalTimeout;
+    MemorySize jarArtifactMaxSize;
 
     public static FlinkOperatorConfiguration fromConfiguration(Configuration 
operatorConfig) {
         Duration reconcileInterval =
@@ -221,6 +225,15 @@ public class FlinkOperatorConfiguration {
                 operatorConfig.get(
                         
KubernetesOperatorConfigOptions.JAR_URI_DISALLOW_RESTRICTED_HOSTS);
 
+        Duration jarFetchSocketTimeout =
+                
operatorConfig.get(KubernetesOperatorConfigOptions.JAR_FETCH_SOCKET_TIMEOUT);
+
+        Duration jarFetchTotalTimeout =
+                
operatorConfig.get(KubernetesOperatorConfigOptions.JAR_FETCH_TOTAL_TIMEOUT);
+
+        MemorySize jarArtifactMaxSize =
+                
operatorConfig.get(KubernetesOperatorConfigOptions.JAR_ARTIFACT_MAX_SIZE);
+
         return new FlinkOperatorConfiguration(
                 reconcileInterval,
                 reconcilerMaxParallelism,
@@ -256,7 +269,10 @@ public class FlinkOperatorConfiguration {
                 manageIngress,
                 jobSubmissionTimeout,
                 jarUriAllowedSchemes,
-                jarUriDisallowRestrictedHosts);
+                jarUriDisallowRestrictedHosts,
+                jarFetchSocketTimeout,
+                jarFetchTotalTimeout,
+                jarArtifactMaxSize);
     }
 
     private static GenericRetry getRetryConfig(Configuration conf) {
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/config/KubernetesOperatorConfigOptions.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/config/KubernetesOperatorConfigOptions.java
index 3a4d10b4..fb98eba9 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/config/KubernetesOperatorConfigOptions.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/config/KubernetesOperatorConfigOptions.java
@@ -21,6 +21,7 @@ package org.apache.flink.kubernetes.operator.config;
 import org.apache.flink.annotation.docs.Documentation;
 import org.apache.flink.configuration.ConfigOption;
 import org.apache.flink.configuration.ConfigOptions;
+import org.apache.flink.configuration.MemorySize;
 import org.apache.flink.core.execution.SavepointFormatType;
 import org.apache.flink.kubernetes.operator.api.status.CheckpointType;
 
@@ -397,6 +398,39 @@ public class KubernetesOperatorConfigOptions {
                                     + "site-local, wildcard or multicast 
addresses are rejected during validation. "
                                     + "Disable only if the operator 
legitimately needs to fetch from such addresses.");
 
+    @Documentation.Section(SECTION_SYSTEM_RECONCILE)
+    public static final ConfigOption<Duration> JAR_FETCH_SOCKET_TIMEOUT =
+            operatorConfig("user.artifacts.http.socket-timeout")
+                    .durationType()
+                    .defaultValue(Duration.ofSeconds(30))
+                    .withDescription(
+                            "The connect and socket read timeout for 
downloading a FlinkSessionJob jarURI "
+                                    + "over http(s). Bounds how long the 
reconcile thread can be blocked "
+                                    + "establishing the connection, or waiting 
for the next byte, from an "
+                                    + "unresponsive artifact host.");
+
+    @Documentation.Section(SECTION_SYSTEM_RECONCILE)
+    public static final ConfigOption<Duration> JAR_FETCH_TOTAL_TIMEOUT =
+            operatorConfig("user.artifacts.http.total-timeout")
+                    .durationType()
+                    .defaultValue(Duration.ofMinutes(5))
+                    .withDescription(
+                            "The total wall-clock budget for downloading a 
FlinkSessionJob jarURI over "
+                                    + "http(s), covering all redirects and the 
full body transfer. Unlike "
+                                    + "the socket timeout, this bounds the 
overall download even against "
+                                    + "a slow host that keeps trickling data 
fast enough to avoid tripping "
+                                    + "it.");
+
+    @Documentation.Section(SECTION_SYSTEM_RECONCILE)
+    public static final ConfigOption<MemorySize> JAR_ARTIFACT_MAX_SIZE =
+            operatorConfig("user.artifacts.max-size")
+                    .memoryType()
+                    .defaultValue(MemorySize.ofMebiBytes(1024))
+                    .withDescription(
+                            "The maximum size of a FlinkSessionJob jarURI 
artifact fetched over http(s). "
+                                    + "The download is rejected once it 
exceeds this size, whether or not the "
+                                    + "server declares a Content-Length up 
front.");
+
     @Documentation.Section(SECTION_DYNAMIC)
     public static final ConfigOption<Boolean> SNAPSHOT_RESOURCE_ENABLED =
             operatorConfig("snapshot.resource.enabled")
diff --git 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/artifact/ArtifactManagerTest.java
 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/artifact/ArtifactManagerTest.java
index 1b785784..87a6f8e7 100644
--- 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/artifact/ArtifactManagerTest.java
+++ 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/artifact/ArtifactManagerTest.java
@@ -18,6 +18,7 @@
 package org.apache.flink.kubernetes.operator.artifact;
 
 import org.apache.flink.configuration.Configuration;
+import org.apache.flink.configuration.MemorySize;
 import org.apache.flink.kubernetes.operator.TestUtils;
 import org.apache.flink.kubernetes.operator.config.FlinkConfigManager;
 import 
org.apache.flink.kubernetes.operator.config.KubernetesOperatorConfigOptions;
@@ -42,6 +43,7 @@ import java.net.HttpURLConnection;
 import java.net.InetSocketAddress;
 import java.net.URL;
 import java.nio.file.Path;
+import java.time.Duration;
 import java.util.List;
 import java.util.Map;
 
@@ -71,6 +73,34 @@ public class ArtifactManagerTest {
         return new ArtifactManager(new FlinkConfigManager(configuration));
     }
 
+    private ArtifactManager artifactManagerWithFetchLimits(
+            Duration totalTimeout, long maxArtifactSizeBytes) {
+        Configuration configuration = new Configuration();
+        configuration.setString(
+                
KubernetesOperatorConfigOptions.OPERATOR_USER_ARTIFACTS_BASE_DIR,
+                tempDir.toAbsolutePath().toString());
+        
configuration.set(KubernetesOperatorConfigOptions.JAR_URI_ALLOWED_SCHEMES, 
List.of("http"));
+        
configuration.set(KubernetesOperatorConfigOptions.JAR_URI_DISALLOW_RESTRICTED_HOSTS,
 false);
+        
configuration.set(KubernetesOperatorConfigOptions.JAR_FETCH_TOTAL_TIMEOUT, 
totalTimeout);
+        configuration.set(
+                KubernetesOperatorConfigOptions.JAR_ARTIFACT_MAX_SIZE,
+                new MemorySize(maxArtifactSizeBytes));
+        return new ArtifactManager(new FlinkConfigManager(configuration));
+    }
+
+    private ArtifactManager artifactManagerWithSocketTimeout(
+            Duration socketTimeout, Duration totalTimeout) {
+        Configuration configuration = new Configuration();
+        configuration.setString(
+                
KubernetesOperatorConfigOptions.OPERATOR_USER_ARTIFACTS_BASE_DIR,
+                tempDir.toAbsolutePath().toString());
+        
configuration.set(KubernetesOperatorConfigOptions.JAR_URI_ALLOWED_SCHEMES, 
List.of("http"));
+        
configuration.set(KubernetesOperatorConfigOptions.JAR_URI_DISALLOW_RESTRICTED_HOSTS,
 false);
+        
configuration.set(KubernetesOperatorConfigOptions.JAR_FETCH_SOCKET_TIMEOUT, 
socketTimeout);
+        
configuration.set(KubernetesOperatorConfigOptions.JAR_FETCH_TOTAL_TIMEOUT, 
totalTimeout);
+        return new ArtifactManager(new FlinkConfigManager(configuration));
+    }
+
     @Test
     public void testGenerateJarDir() {
         var sessionJob = TestUtils.buildSessionJob();
@@ -128,6 +158,36 @@ public class ArtifactManagerTest {
         }
     }
 
+    @Test
+    public void testHttpFetchCreatesNestedNonExistentTargetDir() throws 
Exception {
+        // The real call site (uploadJar -> generateJarDir) targets a nested 
per-job directory
+        // (base/namespace/deployment/job) that doesn't exist yet; unlike the 
other tests here,
+        // don't reuse the JUnit-provided tempDir directly so a missing 
intermediate directory
+        // actually gets exercised.
+        var nestedTargetDir = 
tempDir.resolve("ns").resolve("deployment").resolve("job");
+        Assertions.assertFalse(nestedTargetDir.toFile().exists());
+        HttpServer httpServer = null;
+        try {
+            httpServer = startHttpServer();
+            var sourceFile = mockTheJarFile();
+            httpServer.createContext("/download/file.jar", new 
DownloadFileHttpHandler(sourceFile));
+
+            var file =
+                    artifactManager.fetch(
+                            String.format(
+                                    "http://127.0.0.1:%d/download/file.jar";,
+                                    httpServer.getAddress().getPort()),
+                            new Configuration(),
+                            nestedTargetDir.toString());
+            Assertions.assertTrue(file.exists());
+            Assertions.assertEquals(nestedTargetDir.toString(), 
file.getParent());
+        } finally {
+            if (httpServer != null) {
+                httpServer.stop(0);
+            }
+        }
+    }
+
     @Test
     public void testHttpFetchFollowsRedirectToAllowedTarget() throws Exception 
{
         HttpServer httpServer = null;
@@ -238,6 +298,191 @@ public class ArtifactManagerTest {
         }
     }
 
+    @Test
+    public void testHttpFetchRejectsOversizedDeclaredContentLength() throws 
Exception {
+        // The server declares a Content-Length beyond the cap; the fetch must 
fail fast without
+        // reading the body.
+        var strictManager = 
artifactManagerWithFetchLimits(Duration.ofSeconds(30), 10);
+        HttpServer httpServer = null;
+        try {
+            httpServer = startHttpServer();
+            var port = httpServer.getAddress().getPort();
+            var sourceFile = mockTheJarFile();
+            Assertions.assertTrue(sourceFile.length() > 10);
+            httpServer.createContext("/download/file.jar", new 
DownloadFileHttpHandler(sourceFile));
+
+            var ex =
+                    Assertions.assertThrows(
+                            IOException.class,
+                            () ->
+                                    strictManager.fetch(
+                                            String.format(
+                                                    
"http://127.0.0.1:%d/download/file.jar";, port),
+                                            new Configuration(),
+                                            tempDir.toString()));
+            Assertions.assertTrue(ex.getMessage().contains("exceeds the 
configured limit"));
+        } finally {
+            if (httpServer != null) {
+                httpServer.stop(0);
+            }
+        }
+    }
+
+    @Test
+    public void testHttpFetchRejectsOversizedActualBody() throws Exception {
+        // The server does not declare a Content-Length (chunked transfer), so 
the cap must be
+        // enforced while streaming the body rather than up front.
+        var strictManager = 
artifactManagerWithFetchLimits(Duration.ofSeconds(30), 10);
+        HttpServer httpServer = null;
+        try {
+            httpServer = startHttpServer();
+            var port = httpServer.getAddress().getPort();
+            httpServer.createContext("/download/file.jar", new 
ChunkedOversizedHttpHandler());
+
+            var ex =
+                    Assertions.assertThrows(
+                            IOException.class,
+                            () ->
+                                    strictManager.fetch(
+                                            String.format(
+                                                    
"http://127.0.0.1:%d/download/file.jar";, port),
+                                            new Configuration(),
+                                            tempDir.toString()));
+            Assertions.assertTrue(ex.getMessage().contains("exceeds the 
configured limit"));
+            Assertions.assertTrue(ex.getMessage().contains("downloaded size"), 
ex.getMessage());
+            Assertions.assertFalse(new File(tempDir.toFile(), 
"file.jar").exists());
+        } finally {
+            if (httpServer != null) {
+                httpServer.stop(0);
+            }
+        }
+    }
+
+    @Test
+    public void testHttpFetchTimesOutOnSlowTrickle() throws Exception {
+        // The server sends a byte at a time, each well within the read 
timeout, so only the
+        // overall fetch timeout can bound the reconcile thread here.
+        var strictManager = 
artifactManagerWithFetchLimits(Duration.ofMillis(300), 10_000_000);
+        HttpServer httpServer = null;
+        try {
+            httpServer = startHttpServer();
+            var port = httpServer.getAddress().getPort();
+            httpServer.createContext("/download/file.jar", new 
SlowTrickleHttpHandler());
+
+            var fetchStart = System.currentTimeMillis();
+            var ex =
+                    Assertions.assertThrows(
+                            IOException.class,
+                            () ->
+                                    strictManager.fetch(
+                                            String.format(
+                                                    
"http://127.0.0.1:%d/download/file.jar";, port),
+                                            new Configuration(),
+                                            tempDir.toString()));
+            var elapsed = System.currentTimeMillis() - fetchStart;
+            Assertions.assertTrue(ex.getMessage().contains("Timed out"), 
ex.getMessage());
+            // Bounded well below the many seconds the slow trickle would 
otherwise take to finish.
+            Assertions.assertTrue(elapsed < 10_000, "fetch took " + elapsed + 
" ms");
+        } finally {
+            if (httpServer != null) {
+                httpServer.stop(0);
+            }
+        }
+    }
+
+    @Test
+    public void testHttpFetchTimesOutOnConnectToUnreachableHost() {
+        // Connect to a blackholed address (TEST-NET-1, RFC 5737) whose SYNs 
are dropped, so the
+        // TCP connect never completes. The 1s connect timeout must bound it 
far below the 60s
+        // total timeout, proving the per-connection timeout (not just the 
total) is in effect.
+        var strictManager =
+                artifactManagerWithSocketTimeout(Duration.ofSeconds(1), 
Duration.ofSeconds(60));
+
+        var fetchStart = System.currentTimeMillis();
+        var ex =
+                Assertions.assertThrows(
+                        IOException.class,
+                        () ->
+                                strictManager.fetch(
+                                        "http://192.0.2.1:81/job.jar";,
+                                        new Configuration(),
+                                        tempDir.toString()));
+        var elapsed = System.currentTimeMillis() - fetchStart;
+        Assertions.assertTrue(
+                ex.getMessage().toLowerCase().contains("connect timed out"), 
ex.getMessage());
+        Assertions.assertTrue(elapsed < 30_000, "fetch took " + elapsed + " 
ms");
+    }
+
+    @Test
+    public void testHttpFetchTimesOutOnReadWhenServerStallsAfterHeaders() 
throws Exception {
+        // The server sends response headers then never sends the body, so a 
client read blocks.
+        // The 1s read timeout must bound the stalled read far below the 60s 
total timeout,
+        // proving the per-read socket timeout (not just the total) is in 
effect.
+        var strictManager =
+                artifactManagerWithSocketTimeout(Duration.ofSeconds(1), 
Duration.ofSeconds(60));
+        HttpServer httpServer = null;
+        try {
+            httpServer = startHttpServer();
+            var port = httpServer.getAddress().getPort();
+            httpServer.createContext("/download/file.jar", new 
StallAfterHeadersHttpHandler());
+
+            var fetchStart = System.currentTimeMillis();
+            var ex =
+                    Assertions.assertThrows(
+                            IOException.class,
+                            () ->
+                                    strictManager.fetch(
+                                            String.format(
+                                                    
"http://127.0.0.1:%d/download/file.jar";, port),
+                                            new Configuration(),
+                                            tempDir.toString()));
+            var elapsed = System.currentTimeMillis() - fetchStart;
+            Assertions.assertTrue(
+                    ex.getMessage().toLowerCase().contains("read timed out"), 
ex.getMessage());
+            Assertions.assertTrue(elapsed < 30_000, "fetch took " + elapsed + 
" ms");
+        } finally {
+            if (httpServer != null) {
+                httpServer.stop(0);
+            }
+        }
+    }
+
+    @Test
+    public void testOperatorConfigControlsFetchLimits() throws Exception {
+        // The size-cap policy comes from the operator config; a value set in 
the per-job config
+        // does not override it.
+        var strictManager = 
artifactManagerWithFetchLimits(Duration.ofSeconds(30), 10);
+        var jobConfig =
+                new Configuration()
+                        .set(
+                                
KubernetesOperatorConfigOptions.JAR_ARTIFACT_MAX_SIZE,
+                                MemorySize.ofMebiBytes(1024));
+
+        HttpServer httpServer = null;
+        try {
+            httpServer = startHttpServer();
+            var port = httpServer.getAddress().getPort();
+            var sourceFile = mockTheJarFile();
+            Assertions.assertTrue(sourceFile.length() > 10);
+            httpServer.createContext("/download/file.jar", new 
DownloadFileHttpHandler(sourceFile));
+
+            var ex =
+                    Assertions.assertThrows(
+                            IOException.class,
+                            () ->
+                                    strictManager.fetch(
+                                            String.format(
+                                                    
"http://127.0.0.1:%d/download/file.jar";, port),
+                                            jobConfig,
+                                            tempDir.toString()));
+            Assertions.assertTrue(ex.getMessage().contains("exceeds the 
configured limit"));
+        } finally {
+            if (httpServer != null) {
+                httpServer.stop(0);
+            }
+        }
+    }
+
     @Test
     public void testOperatorConfigControlsRestrictedHostPolicy() {
         // The restricted-host policy comes from the operator config; a value 
set in the per-job
@@ -342,4 +587,70 @@ public class ArtifactManagerTest {
             exchange.close();
         }
     }
+
+    /**
+     * Handler that streams more bytes than any reasonable test cap without 
ever declaring a
+     * Content-Length (chunked transfer), so the size cap must be enforced 
while streaming.
+     */
+    public static class ChunkedOversizedHttpHandler implements HttpHandler {
+
+        @Override
+        public void handle(HttpExchange exchange) throws IOException {
+            // -1 body length tells the JDK HTTP server to use chunked 
transfer encoding.
+            exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, 0);
+            var body = exchange.getResponseBody();
+            byte[] chunk = new byte[1024];
+            for (int i = 0; i < 100; i++) {
+                body.write(chunk);
+            }
+            exchange.close();
+        }
+    }
+
+    /**
+     * Handler that dribbles a single byte at a time with a short pause 
between each, simulating a
+     * slow-loris style host: each individual read completes quickly, but the 
transfer as a whole
+     * never finishes on any reasonable timescale.
+     */
+    public static class SlowTrickleHttpHandler implements HttpHandler {
+
+        @Override
+        public void handle(HttpExchange exchange) throws IOException {
+            // 0 body length with no prior Content-Length header tells the JDK 
HTTP server to use
+            // chunked transfer encoding, so the client never sees a declared 
size to fail fast on.
+            exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, 0);
+            var body = exchange.getResponseBody();
+            try {
+                while (true) {
+                    body.write(0);
+                    body.flush();
+                    Thread.sleep(50);
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            } finally {
+                exchange.close();
+            }
+        }
+    }
+
+    /**
+     * Handler that sends response headers declaring a body, then stalls 
without sending any body
+     * bytes, so a client read blocks until the socket read timeout fires.
+     */
+    public static class StallAfterHeadersHttpHandler implements HttpHandler {
+
+        @Override
+        public void handle(HttpExchange exchange) throws IOException {
+            // Declare a fixed-length body but never write it; the client's 
read blocks on the body.
+            exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, 1024);
+            try {
+                Thread.sleep(5_000);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            } finally {
+                exchange.close();
+            }
+        }
+    }
 }

Reply via email to