VYPR
Medium severity5.3NVD Advisory· Published Jun 1, 2026· Updated Jun 1, 2026

CVE-2026-49328

CVE-2026-49328

Description

Server-Side Request Forgery in Apache Fesod's UrlImageConverter allows attackers to probe internal networks via crafted image URLs; fixed in 2.0.2-incubating.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Server-Side Request Forgery in Apache Fesod's UrlImageConverter allows attackers to probe internal networks via crafted image URLs; fixed in 2.0.2-incubating.

Vulnerability

A Server-Side Request Forgery (SSRF) vulnerability exists in the UrlImageConverter component of Apache Fesod (Incubating) fesod-sheet before version 2.0.2-incubating. The component fetches user-supplied image URLs without enforcing any restrictions on the target host or scheme, allowing an attacker to force the server to make outbound HTTP requests to internal or otherwise restricted network resources [1].

Exploitation

An attacker can exploit this vulnerability by providing a malicious image URL (e.g., via a spreadsheet import or API call) that points to an internal IP address (such as http://169.254.169.254/ for cloud metadata) or a restricted service. No authentication is required if the vulnerable endpoint is exposed. The server will then initiate an outbound request to the attacker-controlled target, potentially leaking sensitive information or enabling further reconnaissance [1].

Impact

Successful exploitation allows an attacker to probe internal network services, access cloud instance metadata, or interact with other internal resources that are not intended to be exposed. This can lead to information disclosure and may serve as a stepping stone for more severe attacks. The attacker gains the ability to read responses from internal services, but does not achieve code execution or direct file write [1].

Mitigation

Users should upgrade to version 2.0.2-incubating or later, which includes a configurable URL image fetching policy with scheme restrictions, private-network CIDR checks, redirect limits, response size limits, and image-type validation [1][2]. No workaround is documented; upgrading is the recommended action.

AI Insight generated on Jun 1, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

3

Patches

1
ea6687ff9da3

feat: implement URL scheme policy for image fetching (#917)

https://github.com/apache/fesodShuxin PanMay 14, 2026Fixed in 2.0.2-incubatingvia body-scan
14 files changed · +751 57
  • fesod-bom/pom.xml+1 1 modified
    @@ -25,7 +25,7 @@
         <parent>
             <groupId>org.apache.fesod</groupId>
             <artifactId>fesod-parent</artifactId>
    -        <version>2.0.1-incubating</version>
    +        <version>2.0.2-incubating</version>
         </parent>
     
         <artifactId>fesod-bom</artifactId>
    
  • fesod-common/pom.xml+1 1 modified
    @@ -26,7 +26,7 @@
         <parent>
             <groupId>org.apache.fesod</groupId>
             <artifactId>fesod-parent</artifactId>
    -        <version>2.0.1-incubating</version>
    +        <version>2.0.2-incubating</version>
         </parent>
     
         <artifactId>fesod-common</artifactId>
    
  • fesod-examples/fesod-sheet-examples/pom.xml+1 1 modified
    @@ -26,7 +26,7 @@
         <parent>
             <groupId>org.apache.fesod</groupId>
             <artifactId>fesod-examples</artifactId>
    -        <version>2.0.1-incubating</version>
    +        <version>2.0.2-incubating</version>
         </parent>
     
         <artifactId>fesod-sheet-examples</artifactId>
    
  • fesod-examples/fesod-sheet-examples/src/test/java/org/apache/fesod/sheet/temp/bug/ExcelCreat.java+1 0 modified
    @@ -25,6 +25,7 @@
     import java.util.List;
     import org.apache.fesod.sheet.ExcelWriter;
     import org.apache.fesod.sheet.FastExcel;
    +import org.apache.fesod.sheet.temp.data.HeadType;
     import org.apache.fesod.sheet.write.metadata.WriteSheet;
     
     /**
    
  • fesod-examples/pom.xml+1 1 modified
    @@ -25,7 +25,7 @@
         <parent>
             <groupId>org.apache.fesod</groupId>
             <artifactId>fesod-parent</artifactId>
    -        <version>2.0.1-incubating</version>
    +        <version>2.0.2-incubating</version>
         </parent>
     
         <artifactId>fesod-examples</artifactId>
    
  • fesod-shaded/pom.xml+1 1 modified
    @@ -26,7 +26,7 @@
         <parent>
             <groupId>org.apache.fesod</groupId>
             <artifactId>fesod-parent</artifactId>
    -        <version>2.0.1-incubating</version>
    +        <version>2.0.2-incubating</version>
         </parent>
     
         <artifactId>fesod-shaded</artifactId>
    
  • fesod-sheet/pom.xml+1 1 modified
    @@ -26,7 +26,7 @@
         <parent>
             <groupId>org.apache.fesod</groupId>
             <artifactId>fesod-parent</artifactId>
    -        <version>2.0.1-incubating</version>
    +        <version>2.0.2-incubating</version>
         </parent>
     
         <artifactId>fesod-sheet</artifactId>
    
  • fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/CidrBlock.java+107 0 added
    @@ -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;
    +    }
    +}
    
  • fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/SchemePolicy.java+14 17 renamed
    @@ -17,29 +17,26 @@
      * under the License.
      */
     
    -package org.apache.fesod.sheet.temp.bug;
    +package org.apache.fesod.sheet.converters.url;
     
    -import lombok.EqualsAndHashCode;
    +import java.util.Arrays;
    +import java.util.Collections;
    +import java.util.HashSet;
    +import java.util.Set;
     import lombok.Getter;
    -import lombok.Setter;
    -import org.apache.fesod.sheet.annotation.ExcelProperty;
     
     /**
    + * URL scheme policy for URL image fetching.
      */
     @Getter
    -@Setter
    -@EqualsAndHashCode
    -public class HeadType {
    +public enum SchemePolicy {
    +    HTTP(Collections.singleton("http")),
    +    HTTPS(Collections.singleton("https")),
    +    HTTP_OR_HTTPS(new HashSet<>(Arrays.asList("http", "https")));
     
    -    /**
    -     * 任务id
    -     */
    -    @ExcelProperty("任务ID")
    -    private Integer id;
    +    private final Set<String> schemes;
     
    -    @ExcelProperty(value = "备注1")
    -    private String firstRemark;
    -
    -    @ExcelProperty(value = "备注2")
    -    private String secRemark;
    +    SchemePolicy(Set<String> schemes) {
    +        this.schemes = Collections.unmodifiableSet(schemes);
    +    }
     }
    
  • fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/UrlImageConverter.java+185 12 modified
    @@ -19,15 +19,22 @@
     
     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
    @@ -38,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;
    @@ -47,18 +71,167 @@ public Class<?> supportJavaTypeKey() {
         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();
         }
     }
    
  • fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/url/UrlImageFetchPolicy.java+155 0 added
    @@ -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);
    +        }
    +    }
    +}
    
  • fesod-sheet/src/main/java/org/apache/fesod/sheet/util/FileTypeUtils.java+59 20 modified
    @@ -19,9 +19,10 @@
     
     package org.apache.fesod.sheet.util;
     
    -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
    @@ -30,21 +31,25 @@
      */
     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
          */
         public static ImageData.ImageType defaultImageType = ImageData.ImageType.PICTURE_TYPE_PNG;
     
         static {
    -        FILE_TYPE_MAP = new HashMap<>();
    -        FILE_TYPE_MAP.put("ffd8ff", ImageData.ImageType.PICTURE_TYPE_JPEG);
    -        FILE_TYPE_MAP.put("89504e47", ImageData.ImageType.PICTURE_TYPE_PNG);
    +        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) {
    @@ -56,22 +61,56 @@ public static int getImageTypeFormat(byte[] image) {
         }
     
         public static ImageData.ImageType getImageType(byte[] image) {
    -        if (image == null || image.length <= IMAGE_TYPE_MARK_LENGTH) {
    +        if (image == null || image.length < IMAGE_TYPE_MARK_MIN_LENGTH) {
                 return null;
             }
    -        byte[] typeMarkByte = new byte[IMAGE_TYPE_MARK_LENGTH];
    -        System.arraycopy(image, 0, typeMarkByte, 0, IMAGE_TYPE_MARK_LENGTH);
    -        return FILE_TYPE_MAP.get(encodeHexStr(typeMarkByte));
    +        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 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 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 new String(out);
    +        return true;
    +    }
    +
    +    private static boolean isDib(byte[] image) {
    +        if (image.length < 4) {
    +            return false;
    +        }
    +        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);
         }
     }
    
  • fesod-sheet/src/test/java/org/apache/fesod/sheet/converter/UrlImageConverterTest.java+222 0 added
    @@ -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() {}
    +    }
    +}
    
  • pom.xml+2 2 modified
    @@ -35,7 +35,7 @@
     
         <groupId>org.apache.fesod</groupId>
         <artifactId>fesod-parent</artifactId>
    -    <version>2.0.1-incubating</version>
    +    <version>2.0.2-incubating</version>
     
         <packaging>pom</packaging>
         <name>fesod-parent</name>
    @@ -81,7 +81,7 @@
         </modules>
     
         <properties>
    -        <revision>2.0.1-incubating</revision>
    +        <revision>2.0.2-incubating</revision>
             <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
             <java.version>1.8</java.version>
             <maven.compiler.target>1.8</maven.compiler.target>
    

Vulnerability mechanics

Root cause

"Missing URL scheme policy and private-network validation in UrlImageConverter allows SSRF via user-supplied image URLs."

Attack vector

An attacker provides a crafted image URL (e.g., using `file://`, `http://169.254.169.254/`, or a redirect chain) to the `UrlImageConverter` component. Because no URL scheme policy or private-network check existed, the converter would make outbound HTTP requests to arbitrary hosts, including internal or cloud-metadata endpoints. The attacker can thus probe or exfiltrate data from otherwise restricted internal resources [ref_id=1].

Affected code

The vulnerability resides in `UrlImageConverter.java` within the `fesod-sheet` module. The class lacked any URL scheme policy, private-network checks, redirect limits, response size limits, or image-type validation when fetching user-supplied image URLs. The patch introduces `SchemePolicy`, `UrlImageFetchPolicy`, and `CidrBlock` classes to enforce these restrictions [ref_id=1].

What the fix does

The patch adds a configurable `UrlImageFetchPolicy` that enforces scheme restrictions (e.g., only `http`/`https`), resolves the host to IP addresses and checks them against private CIDR blocks via `CidrBlock`, limits redirects, caps response size, and validates the returned bytes are a supported image type via `FileTypeUtils`. These changes prevent SSRF by blocking requests to internal/private IP ranges and non-image content [ref_id=1].

Preconditions

  • configThe application must use the UrlImageConverter component to fetch user-supplied image URLs.
  • configNo URL fetch policy (scheme restrictions, private-network checks) is configured — the default policy before the fix allowed unrestricted outbound requests.

Generated on Jun 1, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.