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


##########
runtime/src/main/java/org/apache/flink/agents/runtime/skill/repository/SkillMaterializer.java:
##########
@@ -58,6 +61,104 @@ public final class SkillMaterializer {
 
     private static final int JAR_URL_PREFIX_LEN = "jar:".length();
 
+    // --- Size caps for download and extraction (issue #1072) ---
+
+    /**
+     * All four resource limits for a single materializer operation, grouped 
so tests can inject
+     * small thresholds without touching production defaults and so future 
config wiring has a
+     * single object to populate from {@code SkillMaterializerOptions}.
+     *
+     * <p>Production callers use {@link #DEFAULT}; tests construct a small 
instance and pass it to
+     * the overloads of {@link SkillMaterializer#downloadToTempFile} and {@link
+     * SkillMaterializer#extractZipSafely} that accept a {@code Limits} 
argument.
+     */
+    public static final class Limits {
+        /** Maximum number of bytes accepted from a single HTTP download. */
+        public final long maxDownloadBytes;
+
+        /**
+         * Maximum uncompressed size of any single entry during zip 
extraction. Enforced against
+         * actual bytes written, not the attacker-controlled declared size.
+         */
+        public final long maxExtractEntryBytes;
+
+        /**
+         * Maximum cumulative uncompressed bytes written across all entries 
during a single zip
+         * extraction. Enforced against actual bytes written.
+         */
+        public final long maxExtractTotalBytes;
+
+        /** Maximum number of entries permitted in a single zip archive. */
+        public final int maxExtractEntries;
+
+        /**
+         * Conservative production defaults. These are intentionally lower 
than the original issue
+         * #1072 values to limit disk consumption per materialization, 
especially when multiple
+         * skills are materialized concurrently. Deployments that need larger 
archives should raise
+         * them explicitly via a {@code Limits} instance.
+         *
+         * <p>TODO: wire these through {@code AgentConfigOptions} / {@code 
SkillMaterializerOptions}
+         * so deployments can override them from the YAML config without code 
changes (follow-up
+         * PR).
+         */
+        public static final Limits DEFAULT =
+                new Limits(
+                        64L * 1024 * 1024, // 64 MiB download
+                        64L * 1024 * 1024, // 64 MiB per entry
+                        256L * 1024 * 1024, // 256 MiB total extraction
+                        1_000); // entries
+
+        /**
+         * Construct a {@code Limits} instance. All values must be strictly 
positive.
+         *
+         * @throws IllegalArgumentException if any value is not strictly 
positive.
+         */
+        public Limits(
+                long maxDownloadBytes,
+                long maxExtractEntryBytes,
+                long maxExtractTotalBytes,
+                int maxExtractEntries) {
+            if (maxDownloadBytes <= 0
+                    || maxExtractEntryBytes <= 0
+                    || maxExtractTotalBytes <= 0
+                    || maxExtractEntries <= 0) {
+                throw new IllegalArgumentException("All Limits values must be 
strictly positive");
+            }
+            this.maxDownloadBytes = maxDownloadBytes;
+            this.maxExtractEntryBytes = maxExtractEntryBytes;
+            this.maxExtractTotalBytes = maxExtractTotalBytes;
+            this.maxExtractEntries = maxExtractEntries;
+        }
+    }
+
+    /**
+     * Backward-compatible alias for {@link Limits#DEFAULT#maxDownloadBytes}.
+     *
+     * @deprecated Use {@link Limits#DEFAULT} or inject a {@link Limits} 
instance.
+     */
+    public static final long MAX_DOWNLOAD_BYTES = 
Limits.DEFAULT.maxDownloadBytes;

Review Comment:
   Thanks for lowering the defaults and grouping the limits into one object. 
However, `Limits` / `MaterializerLimits` currently only provides an injection 
point for tests: all production repository paths still use the defaults, while 
the actual configuration wiring is deferred by the TODO. I think this 
configuration surface needs to be completed in this PR, with the four positive 
limits exposed consistently through the Java, Python, and YAML APIs and 
propagated through the real materialization path. Also, the `MAX_*` aliases 
were introduced in this still-unmerged PR, so there is no released API 
compatibility to preserve. Could we remove these already-deprecated public 
constants and use the configured limits object directly? This would also 
eliminate the current compiler warnings caused by Javadoc `@deprecated` tags 
without corresponding `@Deprecated` annotations.



##########
python/flink_agents/runtime/skill/repository/_materialize.py:
##########
@@ -160,37 +218,179 @@ def copy_dir_to_temp(src_dir: Path) -> Materialized:
     return materialized
 
 
-def extract_zip_safely(zip_path: Path) -> Materialized:
+def extract_zip_safely(
+    zip_path: Path, *, limits: MaterializerLimits = DEFAULT_LIMITS
+) -> Materialized:
     """Extract a zip into a fresh temp dir, returning a :class:`Materialized`.
 
     Each entry is validated against zip-slip. ``close()`` the returned handle
     to free the dir eagerly; an atexit cleanup is the fallback.
 
     Args:
         zip_path: Path to the zip file to extract.
+        limits: Resource limits to enforce during extraction. Defaults to
+            :data:`DEFAULT_LIMITS`. Pass a small :class:`MaterializerLimits`
+            instance in tests to avoid allocating large fixtures.
 
     Returns:
         A :class:`Materialized` handle owning the extraction directory.
 
     Raises:
-        ValueError: if any zip entry resolves outside the extraction directory.
+        ValueError: if any zip entry resolves outside the extraction directory,
+            or if any size or entry-count limit is exceeded.
     """
     extract_dir = Path(tempfile.mkdtemp(prefix=_TEMP_DIR_PREFIX)).resolve()
     # Construct the handle before validation so the (empty) tempdir is always 
reclaimed,
     # even if validation raises.
     materialized = Materialized(extract_dir)
+    try:
+        _extract_zip_to_dir(zip_path, extract_dir, limits)
+    except Exception:
+        materialized.close()
+        raise
+    return materialized
+
+def _validate_zip_members(
+    members: list, extract_dir: Path, limits: MaterializerLimits
+) -> None:
+    if len(members) > limits.max_extract_entries:
+        msg = (
+            f"Skill archive contains {len(members)} entries, "
+            f"exceeding the limit of {limits.max_extract_entries}"
+        )
+        raise ValueError(msg)
+
+    for member in members:
+        target = (extract_dir / member.filename).resolve()
+        if not target.is_relative_to(extract_dir):
+            msg = f"Unsafe zip entry: {member.filename}"
+            raise ValueError(msg)
+
+    total_declared = 0
+    for member in members:
+        if member.is_dir():
+            continue
+        declared = member.file_size
+        if declared > limits.max_extract_entry_bytes:
+            msg = (
+                f"Skill archive entry '{member.filename}' declared size 
{declared} "
+                f"exceeds the per-entry limit of 
{limits.max_extract_entry_bytes} bytes"
+            )
+            raise ValueError(msg)
+        if declared > 0:
+            total_declared += declared
+    if total_declared > limits.max_extract_total_bytes:
+        msg = (
+            f"Skill archive declared total uncompressed size {total_declared} "
+            f"exceeds the limit of {limits.max_extract_total_bytes} bytes"
+        )
+        raise ValueError(msg)
+
+def _open_entry_stream(zip_path: Path, member: zipfile.ZipInfo) -> 
io.RawIOBase:
+    with zip_path.open("rb") as f:
+        f.seek(member.header_offset)
+        f.read(4)  # local file header signature
+        f.read(2)  # version needed
+        f.read(2)  # general purpose bit flag
+        f.read(2)  # compression method
+        f.read(2)  # last mod file time
+        f.read(2)  # last mod file date
+        f.read(4)  # crc-32
+        f.read(4)  # compressed size
+        f.read(4)  # uncompressed size
+        fn_len = struct.unpack("<H", f.read(2))[0]
+        ex_len = struct.unpack("<H", f.read(2))[0]
+        f.read(fn_len + ex_len)  # filename + extra field
+        compressed = f.read(member.compress_size)
+
+        if member.compress_type == zipfile.ZIP_STORED:
+            return io.BytesIO(compressed)
+        if member.compress_type == zipfile.ZIP_DEFLATED:
+            return io.BytesIO(zlib.decompress(compressed, -15))

Review Comment:
   `zlib.decompress()` expands the entire entry in memory before the size 
checks run, so a zip bomb can cause OOM before being rejected. It also bypasses 
CRC validation. Could we keep `zf.open(member)` for streaming extraction and 
test the byte counter through a separate bounded-copy helper?



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