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]