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

bengbengbalabalabeng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fesod.git


The following commit(s) were added to refs/heads/main by this push:
     new 853acf49 chore: merge 2.0.2-incubating into main branch (#930)
853acf49 is described below

commit 853acf498c763099cd71135045f2868f43619c44
Author: Shuxin Pan <[email protected]>
AuthorDate: Wed Jun 10 19:39:00 2026 +0800

    chore: merge 2.0.2-incubating into main branch (#930)
    
    * chore: merge 2.0.2-incubating into main branch
    
    * chore: merge 2.0.2-incubating into main branch
---
 .../fesod/sheet/converters/url/CidrBlock.java      | 107 ++++++++++
 .../fesod/sheet/converters/url/SchemePolicy.java   |  28 +--
 .../sheet/converters/url/UrlImageConverter.java    | 203 +++++++++++++++++--
 .../sheet/converters/url/UrlImageFetchPolicy.java  | 155 ++++++++++++++
 .../org/apache/fesod/sheet/util/FileTypeUtils.java |  86 +++++---
 .../sheet/converter/UrlImageConverterTest.java     | 222 +++++++++++++++++++++
 6 files changed, 740 insertions(+), 61 deletions(-)

diff --git 
a/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/CidrBlock.java
 
b/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/CidrBlock.java
new file mode 100644
index 00000000..4165ca26
--- /dev/null
+++ 
b/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/CidrBlock.java
@@ -0,0 +1,107 @@
+/*
+ * 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.fesod.sheet.converters.url;
+
+import java.math.BigInteger;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.Arrays;
+import lombok.EqualsAndHashCode;
+
+/**
+ * CIDR block matcher for URL image fetch allowlists.
+ */
+@EqualsAndHashCode
+public final class CidrBlock {
+
+    private final String value;
+    private final byte[] networkAddress;
+    private final int prefixLength;
+
+    private CidrBlock(String value, byte[] networkAddress, int prefixLength) {
+        this.value = value;
+        this.networkAddress = Arrays.copyOf(networkAddress, 
networkAddress.length);
+        this.prefixLength = prefixLength;
+    }
+
+    public String getValue() {
+        return value;
+    }
+
+    public byte[] getNetworkAddress() {
+        return Arrays.copyOf(networkAddress, networkAddress.length);
+    }
+
+    public int getPrefixLength() {
+        return prefixLength;
+    }
+
+    public static CidrBlock parse(String value) {
+        if (value == null) {
+            throw new IllegalArgumentException("CIDR block can not be null");
+        }
+        String[] parts = value.trim().split("/", -1);
+        if (parts.length != 2) {
+            throw new IllegalArgumentException("CIDR block must use 
address/prefix format");
+        }
+
+        try {
+            InetAddress address = InetAddress.getByName(parts[0]);
+            byte[] addressBytes = address.getAddress();
+            int maxPrefixLength = addressBytes.length * Byte.SIZE;
+            int prefixLength = Integer.parseInt(parts[1]);
+            if (prefixLength < 0 || prefixLength > maxPrefixLength) {
+                throw new IllegalArgumentException("CIDR prefix length is out 
of range");
+            }
+            return new CidrBlock(value.trim(), mask(addressBytes, 
prefixLength), prefixLength);
+        } catch (UnknownHostException e) {
+            throw new IllegalArgumentException("CIDR address is invalid", e);
+        } catch (NumberFormatException e) {
+            throw new IllegalArgumentException("CIDR prefix length is 
invalid", e);
+        }
+    }
+
+    public boolean contains(InetAddress address) {
+        byte[] addressBytes = address.getAddress();
+        if (addressBytes.length != networkAddress.length) {
+            return false;
+        }
+        return Arrays.equals(mask(addressBytes, prefixLength), networkAddress);
+    }
+
+    private static byte[] mask(byte[] addressBytes, int prefixLength) {
+        int bitLength = addressBytes.length * Byte.SIZE;
+        BigInteger address = new BigInteger(1, addressBytes);
+        BigInteger mask = BigInteger.ONE
+                .shiftLeft(bitLength)
+                .subtract(BigInteger.ONE)
+                .shiftRight(bitLength - prefixLength)
+                .shiftLeft(bitLength - prefixLength);
+        byte[] maskedBytes = address.and(mask).toByteArray();
+        return toFixedLength(maskedBytes, addressBytes.length);
+    }
+
+    private static byte[] toFixedLength(byte[] bytes, int length) {
+        byte[] result = new byte[length];
+        int copyLength = Math.min(bytes.length, length);
+        System.arraycopy(bytes, bytes.length - copyLength, result, length - 
copyLength, copyLength);
+        return result;
+    }
+}
diff --git 
a/fesod-examples/fesod-sheet-examples/src/test/java/org/apache/fesod/sheet/examples/read/BasicReadExampleITCase.java
 
b/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/SchemePolicy.java
similarity index 60%
rename from 
fesod-examples/fesod-sheet-examples/src/test/java/org/apache/fesod/sheet/examples/read/BasicReadExampleITCase.java
rename to 
fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/SchemePolicy.java
index 6e09edc0..94bea6a2 100644
--- 
a/fesod-examples/fesod-sheet-examples/src/test/java/org/apache/fesod/sheet/examples/read/BasicReadExampleITCase.java
+++ 
b/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/SchemePolicy.java
@@ -17,22 +17,26 @@
  * under the License.
  */
 
-package org.apache.fesod.sheet.examples.read;
+package org.apache.fesod.sheet.converters.url;
 
-import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
-import org.apache.fesod.sheet.examples.ExampleTestBase;
-import org.junit.jupiter.api.Test;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import lombok.Getter;
 
 /**
- * Test for {@link BasicReadExample}.
- *
- * <p>Verifies the basic read example which reads {@code demo.xlsx} using a 
typed
- * {@code DemoDataListener} to process each row.
+ * URL scheme policy for URL image fetching.
  */
-class BasicReadExampleITCase extends ExampleTestBase {
+@Getter
+public enum SchemePolicy {
+    HTTP(Collections.singleton("http")),
+    HTTPS(Collections.singleton("https")),
+    HTTP_OR_HTTPS(new HashSet<>(Arrays.asList("http", "https")));
+
+    private final Set<String> schemes;
 
-    @Test
-    void testBasicRead() {
-        assertDoesNotThrow(BasicReadExample::basicRead);
+    SchemePolicy(Set<String> schemes) {
+        this.schemes = Collections.unmodifiableSet(schemes);
     }
 }
diff --git 
a/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/UrlImageConverter.java
 
b/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/UrlImageConverter.java
index 8aa5ffd9..7456d16c 100644
--- 
a/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/UrlImageConverter.java
+++ 
b/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/UrlImageConverter.java
@@ -17,23 +17,24 @@
  * under the License.
  */
 
-/*
- * This file is part of the Apache Fesod (Incubating) project, which was 
derived from Alibaba EasyExcel.
- *
- * Copyright (C) 2018-2024 Alibaba Group Holding Ltd.
- */
-
 package org.apache.fesod.sheet.converters.url;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.MalformedURLException;
 import java.net.URL;
-import java.net.URLConnection;
-import org.apache.fesod.common.util.IoUtils;
+import java.util.Locale;
 import org.apache.fesod.sheet.converters.Converter;
 import org.apache.fesod.sheet.metadata.GlobalConfiguration;
+import org.apache.fesod.sheet.metadata.data.ImageData;
 import org.apache.fesod.sheet.metadata.data.WriteCellData;
 import org.apache.fesod.sheet.metadata.property.ExcelContentProperty;
+import org.apache.fesod.sheet.util.FileTypeUtils;
 
 /**
  * Url and image converter
@@ -44,6 +45,23 @@ public class UrlImageConverter implements Converter<URL> {
     public static int urlConnectTimeout = 1000;
     public static int urlReadTimeout = 5000;
 
+    private static volatile UrlImageFetchPolicy fetchPolicy = 
UrlImageFetchPolicy.defaultPolicy();
+
+    public static UrlImageFetchPolicy getFetchPolicy() {
+        return fetchPolicy;
+    }
+
+    public static void setFetchPolicy(UrlImageFetchPolicy fetchPolicy) {
+        if (fetchPolicy == null) {
+            throw new IllegalArgumentException("Fetch policy can not be null");
+        }
+        UrlImageConverter.fetchPolicy = fetchPolicy;
+    }
+
+    public static void resetFetchPolicy() {
+        fetchPolicy = UrlImageFetchPolicy.defaultPolicy();
+    }
+
     @Override
     public Class<?> supportJavaTypeKey() {
         return URL.class;
@@ -53,18 +71,167 @@ public class UrlImageConverter implements Converter<URL> {
     public WriteCellData<?> convertToExcelData(
             URL value, ExcelContentProperty contentProperty, 
GlobalConfiguration globalConfiguration)
             throws IOException {
-        InputStream inputStream = null;
+        byte[] bytes = readImage(value, fetchPolicy);
+        ImageData.ImageType imageType = FileTypeUtils.getImageType(bytes);
+        if (imageType == null) {
+            throw new IOException("URL image data is not a supported image 
type");
+        }
+        return new WriteCellData<>(bytes);
+    }
+
+    private byte[] readImage(URL value, UrlImageFetchPolicy policy) throws 
IOException {
+        URL currentUrl = value;
+        for (int redirectCount = 0; redirectCount <= policy.getMaxRedirects(); 
redirectCount++) {
+            validateUrl(currentUrl, policy);
+            HttpURLConnection connection = openConnection(currentUrl);
+            try {
+                int responseCode = connection.getResponseCode();
+                if (isRedirect(responseCode)) {
+                    if (redirectCount == policy.getMaxRedirects()) {
+                        throw new IOException("URL image request exceeded 
redirect limit");
+                    }
+                    currentUrl = resolveRedirect(currentUrl, 
connection.getHeaderField("Location"));
+                    continue;
+                }
+                if (responseCode < HttpURLConnection.HTTP_OK || responseCode 
>= HttpURLConnection.HTTP_MULT_CHOICE) {
+                    throw new IOException("URL image request failed with HTTP 
status " + responseCode);
+                }
+                int contentLength = connection.getContentLength();
+                if (contentLength > policy.getMaxImageBytes()) {
+                    throw new IOException("URL image data exceeds maximum 
size");
+                }
+                try (InputStream inputStream = connection.getInputStream()) {
+                    return readLimited(inputStream, policy.getMaxImageBytes());
+                }
+            } finally {
+                connection.disconnect();
+            }
+        }
+        throw new IOException("URL image request exceeded redirect limit");
+    }
+
+    private HttpURLConnection openConnection(URL value) throws IOException {
+        HttpURLConnection connection = (HttpURLConnection) 
value.openConnection();
+        connection.setConnectTimeout(urlConnectTimeout);
+        connection.setReadTimeout(urlReadTimeout);
+        connection.setInstanceFollowRedirects(false);
+        return connection;
+    }
+
+    private void validateUrl(URL value, UrlImageFetchPolicy policy) throws 
IOException {
+        String protocol = value.getProtocol();
+        if (protocol == null || 
!policy.getAllowedSchemes().contains(protocol.toLowerCase(Locale.ROOT))) {
+            throw new IOException("URL image protocol is not allowed");
+        }
+        String host = value.getHost();
+        if (host == null || host.trim().isEmpty()) {
+            throw new IOException("URL image host is required");
+        }
+
+        String normalizedHost;
+        try {
+            normalizedHost = UrlImageFetchPolicy.normalizeHost(host);
+        } catch (IllegalArgumentException e) {
+            throw new IOException("URL image host is invalid", e);
+        }
+
+        InetAddress[] addresses = InetAddress.getAllByName(normalizedHost);
+        if (addresses.length == 0) {
+            throw new IOException("URL image host can not be resolved");
+        }
+        for (InetAddress address : addresses) {
+            if (isRestrictedAddress(address) && 
!isAllowedPrivateAddress(normalizedHost, address, policy)) {
+                throw new IOException("URL image host resolves to a restricted 
address");
+            }
+        }
+    }
+
+    private boolean isAllowedPrivateAddress(String normalizedHost, InetAddress 
address, UrlImageFetchPolicy policy) {
+        if (!policy.isAllowPrivateNetwork()) {
+            return false;
+        }
+        if (policy.getAllowedPrivateHosts().contains(normalizedHost)) {
+            return true;
+        }
+        for (CidrBlock cidrBlock : policy.getAllowedPrivateCidrs()) {
+            if (cidrBlock.contains(address)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean isRestrictedAddress(InetAddress address) {
+        return address.isAnyLocalAddress()
+                || address.isLoopbackAddress()
+                || address.isLinkLocalAddress()
+                || address.isSiteLocalAddress()
+                || address.isMulticastAddress()
+                || isRestrictedIpv4Address(address)
+                || isRestrictedIpv6Address(address);
+    }
+
+    private boolean isRestrictedIpv4Address(InetAddress address) {
+        if (!(address instanceof Inet4Address)) {
+            return false;
+        }
+        byte[] bytes = address.getAddress();
+        int first = bytes[0] & 0xFF;
+        int second = bytes[1] & 0xFF;
+        return first == 0
+                || first == 10
+                || first == 127
+                || (first == 100 && second >= 64 && second <= 127)
+                || (first == 169 && second == 254)
+                || (first == 172 && second >= 16 && second <= 31)
+                || (first == 192 && second == 168)
+                || first >= 224;
+    }
+
+    private boolean isRestrictedIpv6Address(InetAddress address) {
+        if (!(address instanceof Inet6Address)) {
+            return false;
+        }
+        byte[] bytes = address.getAddress();
+        int first = bytes[0] & 0xFF;
+        int second = bytes[1] & 0xFF;
+        return first == 0
+                || (first == 0xFC || first == 0xFD)
+                || (first == 0xFE && (second & 0xC0) == 0x80)
+                || first == 0xFF;
+    }
+
+    private boolean isRedirect(int responseCode) {
+        return responseCode == HttpURLConnection.HTTP_MOVED_PERM
+                || responseCode == HttpURLConnection.HTTP_MOVED_TEMP
+                || responseCode == HttpURLConnection.HTTP_SEE_OTHER
+                || responseCode == 307
+                || responseCode == 308;
+    }
+
+    private URL resolveRedirect(URL currentUrl, String location) throws 
IOException {
+        if (location == null || location.trim().isEmpty()) {
+            throw new IOException("URL image redirect location is missing");
+        }
         try {
-            URLConnection urlConnection = value.openConnection();
-            urlConnection.setConnectTimeout(urlConnectTimeout);
-            urlConnection.setReadTimeout(urlReadTimeout);
-            inputStream = urlConnection.getInputStream();
-            byte[] bytes = IoUtils.toByteArray(inputStream);
-            return new WriteCellData<>(bytes);
-        } finally {
-            if (inputStream != null) {
-                inputStream.close();
+            return new URL(currentUrl, location);
+        } catch (MalformedURLException e) {
+            throw new IOException("URL image redirect location is invalid", e);
+        }
+    }
+
+    private byte[] readLimited(InputStream inputStream, int maxBytes) throws 
IOException {
+        ByteArrayOutputStream outputStream = new 
ByteArrayOutputStream(Math.min(maxBytes, 8192));
+        byte[] buffer = new byte[8192];
+        int total = 0;
+        int read;
+        while ((read = inputStream.read(buffer)) != -1) {
+            total += read;
+            if (total > maxBytes) {
+                throw new IOException("URL image data exceeds maximum size");
             }
+            outputStream.write(buffer, 0, read);
         }
+        return outputStream.toByteArray();
     }
 }
diff --git 
a/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/UrlImageFetchPolicy.java
 
b/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/UrlImageFetchPolicy.java
new file mode 100644
index 00000000..6d46eab3
--- /dev/null
+++ 
b/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/UrlImageFetchPolicy.java
@@ -0,0 +1,155 @@
+/*
+ * 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.fesod.sheet.converters.url;
+
+import java.net.IDN;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+/**
+ * Security policy for fetching images from URL values.
+ */
+@Getter
+@EqualsAndHashCode
+public final class UrlImageFetchPolicy {
+
+    public static final int DEFAULT_MAX_REDIRECTS = 3;
+    public static final int DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
+
+    private static final UrlImageFetchPolicy DEFAULT = builder().build();
+
+    private final boolean allowPrivateNetwork;
+    private final Set<String> allowedPrivateHosts;
+    private final List<CidrBlock> allowedPrivateCidrs;
+    private final Set<String> allowedSchemes;
+    private final int maxRedirects;
+    private final int maxImageBytes;
+
+    private UrlImageFetchPolicy(Builder builder) {
+        this.allowPrivateNetwork = builder.allowPrivateNetwork;
+        this.allowedPrivateHosts = 
Collections.unmodifiableSet(normalizeHosts(builder.allowedPrivateHosts));
+        this.allowedPrivateCidrs = Collections.unmodifiableList(new 
ArrayList<>(builder.allowedPrivateCidrs));
+        this.allowedSchemes = Collections.unmodifiableSet(new 
HashSet<>(builder.schemePolicy.getSchemes()));
+        this.maxRedirects = builder.maxRedirects;
+        this.maxImageBytes = builder.maxImageBytes;
+    }
+
+    public static UrlImageFetchPolicy defaultPolicy() {
+        return DEFAULT;
+    }
+
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    private static Set<String> normalizeHosts(Collection<String> hosts) {
+        Set<String> result = new HashSet<>();
+        for (String host : hosts) {
+            if (host == null) {
+                continue;
+            }
+            String normalized = normalizeHost(host);
+            if (!normalized.isEmpty()) {
+                result.add(normalized);
+            }
+        }
+        return result;
+    }
+
+    static String normalizeHost(String host) {
+        String normalized = host.trim().toLowerCase(Locale.ROOT);
+        while (normalized.endsWith(".")) {
+            normalized = normalized.substring(0, normalized.length() - 1);
+        }
+        if (normalized.isEmpty()) {
+            return normalized;
+        }
+        return IDN.toASCII(normalized);
+    }
+
+    public static final class Builder {
+        private boolean allowPrivateNetwork;
+        private Set<String> allowedPrivateHosts = Collections.emptySet();
+        private List<CidrBlock> allowedPrivateCidrs = Collections.emptyList();
+        private SchemePolicy schemePolicy = SchemePolicy.HTTP_OR_HTTPS;
+        private int maxRedirects = DEFAULT_MAX_REDIRECTS;
+        private int maxImageBytes = DEFAULT_MAX_IMAGE_BYTES;
+
+        private Builder() {}
+
+        public Builder allowPrivateNetwork(boolean allowPrivateNetwork) {
+            this.allowPrivateNetwork = allowPrivateNetwork;
+            return this;
+        }
+
+        public Builder allowedPrivateHosts(Collection<String> 
allowedPrivateHosts) {
+            if (allowedPrivateHosts == null) {
+                this.allowedPrivateHosts = Collections.emptySet();
+            } else {
+                this.allowedPrivateHosts = new HashSet<>(allowedPrivateHosts);
+            }
+            return this;
+        }
+
+        public Builder allowedPrivateCidrs(Collection<CidrBlock> 
allowedPrivateCidrs) {
+            if (allowedPrivateCidrs == null) {
+                this.allowedPrivateCidrs = Collections.emptyList();
+            } else {
+                this.allowedPrivateCidrs = new 
ArrayList<>(allowedPrivateCidrs);
+            }
+            return this;
+        }
+
+        public Builder allowedSchemes(SchemePolicy schemePolicy) {
+            if (schemePolicy == null) {
+                throw new IllegalArgumentException("Scheme policy can not be 
null");
+            }
+            this.schemePolicy = schemePolicy;
+            return this;
+        }
+
+        public Builder maxRedirects(int maxRedirects) {
+            this.maxRedirects = maxRedirects;
+            return this;
+        }
+
+        public Builder maxImageBytes(int maxImageBytes) {
+            this.maxImageBytes = maxImageBytes;
+            return this;
+        }
+
+        public UrlImageFetchPolicy build() {
+            if (maxRedirects < 0) {
+                throw new IllegalArgumentException("Max redirects can not be 
negative");
+            }
+            if (maxImageBytes <= 0) {
+                throw new IllegalArgumentException("Max image bytes must be 
positive");
+            }
+            return new UrlImageFetchPolicy(this);
+        }
+    }
+}
diff --git 
a/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/FileTypeUtils.java 
b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/FileTypeUtils.java
index 918ab272..a3786289 100644
--- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/FileTypeUtils.java
+++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/FileTypeUtils.java
@@ -25,10 +25,10 @@
 
 package org.apache.fesod.sheet.util;
 
-import java.util.Comparator;
-import java.util.HashMap;
+import java.util.EnumMap;
 import java.util.Map;
 import org.apache.fesod.sheet.metadata.data.ImageData;
+import org.apache.poi.poifs.filesystem.FileMagic;
 
 /**
  * file type utils
@@ -37,12 +37,12 @@ import org.apache.fesod.sheet.metadata.data.ImageData;
  */
 public class FileTypeUtils {
 
-    private static final char[] DIGITS = {'0', '1', '2', '3', '4', '5', '6', 
'7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
-    };
-    private static final int IMAGE_TYPE_MARK_LENGTH = 28;
     private static final int IMAGE_TYPE_MARK_MIN_LENGTH = 3;
 
-    private static final Map<String, ImageData.ImageType> FILE_TYPE_MAP;
+    private static final byte[] JPEG_SIGNATURE = {(byte) 0xFF, (byte) 0xD8, 
(byte) 0xFF};
+    private static final byte[] PNG_SIGNATURE = {(byte) 0x89, 0x50, 0x4E, 
0x47};
+
+    private static final Map<FileMagic, ImageData.ImageType> FILE_TYPE_MAP;
 
     /**
      * Default image type
@@ -50,9 +50,12 @@ public class FileTypeUtils {
     public static ImageData.ImageType defaultImageType = 
ImageData.ImageType.PICTURE_TYPE_PNG;
 
     static {
-        FILE_TYPE_MAP = new HashMap<>();
-        FILE_TYPE_MAP.put("89504e47", ImageData.ImageType.PICTURE_TYPE_PNG);
-        FILE_TYPE_MAP.put("ffd8ff", ImageData.ImageType.PICTURE_TYPE_JPEG);
+        FILE_TYPE_MAP = new EnumMap<>(FileMagic.class);
+        FILE_TYPE_MAP.put(FileMagic.JPEG, 
ImageData.ImageType.PICTURE_TYPE_JPEG);
+        FILE_TYPE_MAP.put(FileMagic.PNG, ImageData.ImageType.PICTURE_TYPE_PNG);
+        FILE_TYPE_MAP.put(FileMagic.WMF, ImageData.ImageType.PICTURE_TYPE_WMF);
+        FILE_TYPE_MAP.put(FileMagic.EMF, ImageData.ImageType.PICTURE_TYPE_EMF);
+        FILE_TYPE_MAP.put(FileMagic.BMP, ImageData.ImageType.PICTURE_TYPE_DIB);
     }
 
     public static int getImageTypeFormat(byte[] image) {
@@ -67,32 +70,53 @@ public class FileTypeUtils {
         if (image == null || image.length < IMAGE_TYPE_MARK_MIN_LENGTH) {
             return null;
         }
-        int lengthToCopy = Math.min(image.length, IMAGE_TYPE_MARK_LENGTH);
-        byte[] typeMarkByte = new byte[lengthToCopy];
-        System.arraycopy(image, 0, typeMarkByte, 0, lengthToCopy);
-
-        String hexString = encodeHexStr(typeMarkByte);
-
-        return FILE_TYPE_MAP.entrySet().stream()
-                .sorted(longestPrefixFirst())
-                .filter(e -> hexString.startsWith(e.getKey()))
-                .findFirst()
-                .map(Map.Entry::getValue)
-                .orElse(null);
+        if (startsWith(image, JPEG_SIGNATURE)) {
+            return ImageData.ImageType.PICTURE_TYPE_JPEG;
+        }
+        if (startsWith(image, PNG_SIGNATURE)) {
+            return ImageData.ImageType.PICTURE_TYPE_PNG;
+        }
+        ImageData.ImageType imageType = 
FILE_TYPE_MAP.get(FileMagic.valueOf(image));
+        if (imageType != null) {
+            return imageType;
+        }
+        if (isDib(image)) {
+            return ImageData.ImageType.PICTURE_TYPE_DIB;
+        }
+        return null;
     }
 
-    private static Comparator<Map.Entry<String, ImageData.ImageType>> 
longestPrefixFirst() {
-        return (a, b) -> b.getKey().length() - a.getKey().length();
+    private static boolean startsWith(byte[] image, byte[] signature) {
+        if (image.length < signature.length) {
+            return false;
+        }
+        for (int i = 0; i < signature.length; i++) {
+            if (image[i] != signature[i]) {
+                return false;
+            }
+        }
+        return true;
     }
 
-    private static String encodeHexStr(byte[] data) {
-        final int len = data.length;
-        final char[] out = new char[len << 1];
-        // two characters from the hex value.
-        for (int i = 0, j = 0; i < len; i++) {
-            out[j++] = DIGITS[(0xF0 & data[i]) >>> 4];
-            out[j++] = DIGITS[0x0F & data[i]];
+    private static boolean isDib(byte[] image) {
+        if (image.length < 4) {
+            return false;
         }
-        return new String(out);
+        int headerSize = readLittleEndianInt(image);
+        switch (headerSize) {
+            case 12:
+            case 40:
+            case 52:
+            case 56:
+            case 108:
+            case 124:
+                return image.length >= headerSize;
+            default:
+                return false;
+        }
+    }
+
+    private static int readLittleEndianInt(byte[] data) {
+        return (data[0] & 0xFF) | ((data[1] & 0xFF) << 8) | ((data[2] & 0xFF) 
<< 16) | ((data[3] & 0xFF) << 24);
     }
 }
diff --git 
a/fesod-sheet/src/test/java/org/apache/fesod/sheet/converter/UrlImageConverterTest.java
 
b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converter/UrlImageConverterTest.java
new file mode 100644
index 00000000..aa1e98ca
--- /dev/null
+++ 
b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converter/UrlImageConverterTest.java
@@ -0,0 +1,222 @@
+/*
+ * 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.fesod.sheet.converter;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.fesod.sheet.converters.url.CidrBlock;
+import org.apache.fesod.sheet.converters.url.SchemePolicy;
+import org.apache.fesod.sheet.converters.url.UrlImageConverter;
+import org.apache.fesod.sheet.converters.url.UrlImageFetchPolicy;
+import org.apache.fesod.sheet.metadata.GlobalConfiguration;
+import org.apache.fesod.sheet.metadata.data.WriteCellData;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link UrlImageConverter}.
+ */
+class UrlImageConverterTest {
+
+    private static final byte[] PNG_BYTES =
+            new byte[] {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 
0x00, 0x00, 0x00, 0x0D};
+
+    private final UrlImageConverter converter = new UrlImageConverter();
+    private HttpServer server;
+    private AtomicInteger requestCount;
+
+    @BeforeEach
+    void beforeEach() {
+        UrlImageConverter.resetFetchPolicy();
+        requestCount = new AtomicInteger();
+    }
+
+    @AfterEach
+    void afterEach() {
+        UrlImageConverter.resetFetchPolicy();
+        if (server != null) {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    void test_rejectFileProtocol() {
+        IOException exception =
+                Assertions.assertThrows(IOException.class, () -> convert(new 
URL("file:///etc/passwd")));
+
+        Assertions.assertTrue(exception.getMessage().contains("protocol"));
+    }
+
+    @Test
+    void test_rejectNullSchemePolicy() {
+        Assertions.assertThrows(
+                IllegalArgumentException.class,
+                () -> 
UrlImageFetchPolicy.builder().allowedSchemes(null).build());
+    }
+
+    @Test
+    void test_httpsOnlyPolicyRejectsHttpUrl() throws Exception {
+        URL url = startServer(HttpStatus.OK, PNG_BYTES, "image/png");
+        UrlImageConverter.setFetchPolicy(
+                
UrlImageFetchPolicy.builder().allowedSchemes(SchemePolicy.HTTPS).build());
+
+        IOException exception = Assertions.assertThrows(IOException.class, () 
-> convert(url));
+
+        Assertions.assertTrue(exception.getMessage().contains("protocol"));
+        Assertions.assertEquals(0, requestCount.get());
+    }
+
+    @Test
+    void test_rejectLoopbackByDefault() throws Exception {
+        URL url = startServer(HttpStatus.OK, PNG_BYTES, "image/png");
+
+        IOException exception = Assertions.assertThrows(IOException.class, () 
-> convert(url));
+
+        Assertions.assertTrue(exception.getMessage().contains("restricted 
address"));
+        Assertions.assertEquals(0, requestCount.get());
+    }
+
+    @Test
+    void test_allowPrivateHostWhenExplicitlyAllowlisted() throws Exception {
+        URL url = startServer(HttpStatus.OK, PNG_BYTES, "image/png");
+        UrlImageConverter.setFetchPolicy(UrlImageFetchPolicy.builder()
+                .allowPrivateNetwork(true)
+                .allowedPrivateHosts(Collections.singleton("127.0.0.1"))
+                .build());
+
+        WriteCellData<?> cellData = convert(url);
+
+        Assertions.assertArrayEquals(
+                PNG_BYTES, cellData.getImageDataList().get(0).getImage());
+        Assertions.assertEquals(1, requestCount.get());
+    }
+
+    @Test
+    void test_allowPrivateCidrWhenExplicitlyAllowlisted() throws Exception {
+        URL url = startServer(HttpStatus.OK, PNG_BYTES, "image/png");
+        UrlImageConverter.setFetchPolicy(UrlImageFetchPolicy.builder()
+                .allowPrivateNetwork(true)
+                
.allowedPrivateCidrs(Collections.singleton(CidrBlock.parse("127.0.0.0/8")))
+                .build());
+
+        WriteCellData<?> cellData = convert(url);
+
+        Assertions.assertArrayEquals(
+                PNG_BYTES, cellData.getImageDataList().get(0).getImage());
+    }
+
+    @Test
+    void test_rejectNonImageResponse() throws Exception {
+        URL url = startServer(HttpStatus.OK, "root:x:0:0".getBytes("UTF-8"), 
"text/plain");
+        UrlImageConverter.setFetchPolicy(allowLoopbackPolicy());
+
+        IOException exception = Assertions.assertThrows(IOException.class, () 
-> convert(url));
+
+        Assertions.assertTrue(exception.getMessage().contains("supported image 
type"));
+    }
+
+    @Test
+    void test_rejectRedirectToNonAllowlistedPrivateHost() throws Exception {
+        URL url = startRedirectServer("http://localhost:8080/image.png";);
+        UrlImageConverter.setFetchPolicy(allowLoopbackPolicy());
+
+        IOException exception = Assertions.assertThrows(IOException.class, () 
-> convert(url));
+
+        Assertions.assertTrue(exception.getMessage().contains("restricted 
address"));
+        Assertions.assertEquals(1, requestCount.get());
+    }
+
+    @Test
+    void test_rejectImageLargerThanPolicyLimit() throws Exception {
+        byte[] body = Arrays.copyOf(PNG_BYTES, PNG_BYTES.length + 20);
+        URL url = startServer(HttpStatus.OK, body, "image/png");
+        UrlImageConverter.setFetchPolicy(UrlImageFetchPolicy.builder()
+                .allowPrivateNetwork(true)
+                .allowedPrivateHosts(Collections.singleton("127.0.0.1"))
+                .maxImageBytes(PNG_BYTES.length)
+                .build());
+
+        IOException exception = Assertions.assertThrows(IOException.class, () 
-> convert(url));
+
+        Assertions.assertTrue(exception.getMessage().contains("maximum size"));
+    }
+
+    private UrlImageFetchPolicy allowLoopbackPolicy() {
+        return UrlImageFetchPolicy.builder()
+                .allowPrivateNetwork(true)
+                .allowedPrivateHosts(Collections.singleton("127.0.0.1"))
+                .build();
+    }
+
+    private WriteCellData<?> convert(URL url) throws IOException {
+        return converter.convertToExcelData(url, null, new 
GlobalConfiguration());
+    }
+
+    private URL startServer(int status, byte[] body, String contentType) 
throws IOException {
+        server = HttpServer.create(new 
InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
+        server.createContext("/", exchange -> {
+            requestCount.incrementAndGet();
+            send(exchange, status, body, contentType);
+        });
+        server.start();
+        return serverUrl("/");
+    }
+
+    private URL startRedirectServer(String location) throws IOException {
+        server = HttpServer.create(new 
InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
+        server.createContext("/", exchange -> {
+            requestCount.incrementAndGet();
+            exchange.getResponseHeaders().set("Location", location);
+            exchange.sendResponseHeaders(HttpStatus.FOUND, -1);
+            exchange.close();
+        });
+        server.start();
+        return serverUrl("/");
+    }
+
+    private URL serverUrl(String path) throws IOException {
+        return new URL("http://127.0.0.1:"; + server.getAddress().getPort() + 
path);
+    }
+
+    private void send(HttpExchange exchange, int status, byte[] body, String 
contentType) throws IOException {
+        if (contentType != null) {
+            exchange.getResponseHeaders().set("Content-Type", contentType);
+        }
+        exchange.sendResponseHeaders(status, body.length);
+        exchange.getResponseBody().write(body);
+        exchange.close();
+    }
+
+    private static final class HttpStatus {
+        private static final int OK = 200;
+        private static final int FOUND = 302;
+
+        private HttpStatus() {}
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to