wenjin272 commented on code in PR #1005:
URL: https://github.com/apache/flink-agents/pull/1005#discussion_r3861263507


##########
api/src/main/java/org/apache/flink/agents/api/skills/Skills.java:
##########
@@ -92,17 +94,94 @@ public static Skills fromLocalDir(String... paths) {
     }
 
     /**
-     * Create a {@link Skills} resource from one or more http(s) URLs.
+     * Create a {@link Skills} resource from one or more HTTPS URLs.
      *
      * <p>Each URL must point to a {@code .zip} whose top level is the baseDir.
      */
     public static Skills fromUrl(String... urls) {
         return new Skills(
                 Arrays.stream(urls)
-                        .map(u -> new SkillSourceSpec("url", Map.of("url", u)))
+                        .map(
+                                u -> {
+                                    requireUrl(u, false);
+                                    return new SkillSourceSpec("url", 
Map.of("url", u));
+                                })
                         .collect(Collectors.toList()));
     }
 
+    /**
+     * Create a {@link Skills} resource from an HTTPS URL pinned to a SHA-256 
digest.
+     *
+     * <p>The digest is verified against the downloaded archive before 
extraction.
+     */
+    public static Skills fromUrlWithSha256(String url, String sha256) {
+        return urlSource(url, sha256, false);
+    }
+
+    /**
+     * Create a {@link Skills} resource that explicitly permits plain HTTP 
transport.
+     *
+     * <p>This compatibility escape hatch should be used only on trusted 
networks. Prefer {@link
+     * #fromUrl(String...)} with HTTPS.
+     */
+    public static Skills fromUrlUnsafe(String... urls) {
+        return new Skills(
+                Arrays.stream(urls)
+                        .map(
+                                u -> {
+                                    requireUrl(u, true);
+                                    return new SkillSourceSpec(
+                                            "url", Map.of("url", u, 
"allow_insecure_http", "true"));
+                                })
+                        .collect(Collectors.toList()));
+    }
+
+    /**
+     * Create a digest-pinned {@link Skills} resource that explicitly permits 
plain HTTP transport.
+     */
+    public static Skills fromUrlUnsafeWithSha256(String url, String sha256) {
+        return urlSource(url, sha256, true);
+    }
+
+    private static Skills urlSource(String url, String sha256, boolean 
allowInsecureHttp) {
+        requireUrl(url, allowInsecureHttp);
+        if (sha256 == null || !sha256.matches("[0-9a-fA-F]{64}")) {
+            throw new IllegalArgumentException(
+                    "sha256 must contain exactly 64 hexadecimal characters");
+        }
+        Map<String, String> params =
+                allowInsecureHttp
+                        ? Map.of("url", url, "sha256", sha256, 
"allow_insecure_http", "true")
+                        : Map.of("url", url, "sha256", sha256);
+        return new Skills(List.of(new SkillSourceSpec("url", params)));
+    }
+
+    private static void requireUrl(String url, boolean allowInsecureHttp) {
+        if (url == null) {
+            throw new IllegalArgumentException("skill URL must not be null");
+        }
+        URI uri;
+        try {
+            uri = URI.create(url);
+        } catch (IllegalArgumentException e) {
+            throw new IllegalArgumentException("Invalid skill URL: " + url, e);
+        }
+        String scheme = uri.getScheme();
+        scheme = scheme == null ? "" : scheme.toLowerCase(Locale.ROOT);
+        if (!(scheme.equals("http") || scheme.equals("https"))) {
+            throw new IllegalArgumentException("Only HTTP(S) skill URLs are 
supported: " + url);
+        }
+        if (scheme.equals("http") && !allowInsecureHttp) {
+            throw new IllegalArgumentException(
+                    "Plain HTTP skill URLs are disabled by default; use HTTPS 
or explicitly allow"
+                            + " insecure HTTP for this source: "
+                            + url);
+        }
+        if (uri.getRawAuthority() == null || uri.getRawAuthority().isEmpty()) {

Review Comment:
   `getRawAuthority()` only checks that the authority string is non-empty, so 
URLs like `https://:443/x.zip` and `https://example.com:bad/x.zip` are 
accepted. Python's `parsed.netloc` check has the same issue. Could we validate 
the actual hostname and port in both implementations?



##########
runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/URLSkillRepository.java:
##########
@@ -18,43 +18,119 @@
 
 package org.apache.flink.agents.runtime.skill.repository;
 
+import javax.annotation.Nullable;
+
 import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Locale;
+import java.util.regex.Pattern;
 
 /**
- * Skill repository backed by an http(s) URL pointing to a zip.
+ * Skill repository backed by an HTTPS URL pointing to a zip.
  *
  * <p>The zip is downloaded to a temp file and extracted into a process-local 
temp directory. The
  * downloaded zip itself is removed once extraction completes; the extracted 
directory is released
  * via {@link #close()} (cascaded through {@code SkillManager} → {@code 
ResourceContextImpl} →
  * {@code ResourceCache} on operator close). A JVM shutdown hook acts as 
fallback cleanup if {@code
- * close()} is never called.
+ * close()} is never called. Plain HTTP is rejected unless the caller 
explicitly opts in, and an
+ * optional SHA-256 digest is verified before extraction.
  */
 public final class URLSkillRepository extends 
AbstractMaterializedSkillRepository {
 
     private static final int REQUEST_TIMEOUT_MS = 90_000;
+    private static final Pattern SHA256_PATTERN = 
Pattern.compile("[0-9a-fA-F]{64}");
 
     private final String url;
 
     public URLSkillRepository(String url) throws IOException {
-        super(materialize(url));
+        this(url, null, false);
+    }
+
+    public URLSkillRepository(String url, @Nullable String sha256, boolean 
allowInsecureHttp)
+            throws IOException {
+        super(materialize(url, sha256, allowInsecureHttp));
         this.url = url;
     }
 
     public String getUrl() {
         return url;
     }
 
-    private static SkillMaterializer.Materialized materialize(String url) 
throws IOException {
-        if (!(url.startsWith("http://";) || url.startsWith("https://";))) {
-            throw new IllegalArgumentException("Only http(s) URLs are 
supported: " + url);
+    private static SkillMaterializer.Materialized materialize(
+            String url, @Nullable String sha256, boolean allowInsecureHttp) 
throws IOException {
+        URI uri;
+        if (url == null) {
+            throw new IllegalArgumentException("skill URL must not be null");
         }
-        Path tmpZip = SkillMaterializer.downloadToTempFile(url, 
REQUEST_TIMEOUT_MS);
         try {
+            uri = new URI(url);
+        } catch (URISyntaxException e) {
+            throw new IllegalArgumentException("Invalid skill URL: " + url, e);
+        }
+        String scheme = uri.getScheme();
+        scheme = scheme == null ? "" : scheme.toLowerCase(Locale.ROOT);
+        if (!(scheme.equals("http") || scheme.equals("https"))) {
+            throw new IllegalArgumentException("Only HTTP(S) URLs are 
supported: " + url);
+        }
+        if (scheme.equals("http") && !allowInsecureHttp) {
+            throw new IllegalArgumentException(
+                    "Plain HTTP skill URLs are disabled by default; use HTTPS 
or explicitly allow"
+                            + " insecure HTTP for this source: "
+                            + url);
+        }
+        if (uri.getRawAuthority() == null || uri.getRawAuthority().isEmpty()) {
+            throw new IllegalArgumentException("Skill URL must include a host: 
" + url);
+        }
+        String normalizedSha256 = sha256 == null ? null : 
sha256.toLowerCase(Locale.ROOT);
+        if (normalizedSha256 != null && 
!SHA256_PATTERN.matcher(normalizedSha256).matches()) {
+            throw new IllegalArgumentException(
+                    "sha256 must contain exactly 64 hexadecimal characters");
+        }
+        Path tmpZip =
+                SkillMaterializer.downloadToTempFile(url, REQUEST_TIMEOUT_MS, 
allowInsecureHttp);
+        try {
+            if (normalizedSha256 != null) {
+                String actual = sha256(tmpZip);
+                if (!actual.equals(normalizedSha256)) {
+                    throw new IllegalArgumentException(
+                            "SHA-256 mismatch for skill archive "
+                                    + url

Review Comment:
   This error includes the full configured URL, so signed URLs may expose query 
credentials through logs or exception reports. Python has the same behavior, 
and both `SkillManager` implementations also include the raw `spec.params`. 
Could we sanitize or omit URLs in these error paths?



-- 
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