Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,22 @@ private void validateUrl(URL value, UrlImageFetchPolicy policy) throws IOExcepti
if (host == null || host.trim().isEmpty()) {
throw new IOException("URL image host is required");
}
if (value.getUserInfo() != null) {
throw new IOException("URL image user info is not allowed");
}

String normalizedHost;
try {
normalizedHost = UrlImageFetchPolicy.normalizeHost(host);
} catch (IllegalArgumentException e) {
throw new IOException("URL image host is invalid", e);
}
if (policy.getAllowedHosts().isEmpty()) {
throw new IOException("Remote URL image fetching is disabled");
}
if (!policy.getAllowedHosts().contains(normalizedHost)) {
throw new IOException("URL image host is not allowlisted");
}

InetAddress[] addresses = InetAddress.getAllByName(normalizedHost);
if (addresses.length == 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
package org.apache.fesod.sheet.converters.url;

import java.net.IDN;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
Expand All @@ -42,6 +47,7 @@ public final class UrlImageFetchPolicy {

private static final UrlImageFetchPolicy DEFAULT = builder().build();

private final Set<String> allowedHosts;
private final boolean allowPrivateNetwork;
private final Set<String> allowedPrivateHosts;
private final List<CidrBlock> allowedPrivateCidrs;
Expand All @@ -50,6 +56,7 @@ public final class UrlImageFetchPolicy {
private final int maxImageBytes;

private UrlImageFetchPolicy(Builder builder) {
this.allowedHosts = Collections.unmodifiableSet(normalizeAllowedHosts(builder.allowedHosts));
this.allowPrivateNetwork = builder.allowPrivateNetwork;
this.allowedPrivateHosts = Collections.unmodifiableSet(normalizeHosts(builder.allowedPrivateHosts));
this.allowedPrivateCidrs = Collections.unmodifiableList(new ArrayList<>(builder.allowedPrivateCidrs));
Expand Down Expand Up @@ -82,6 +89,9 @@ private static Set<String> normalizeHosts(Collection<String> hosts) {

static String normalizeHost(String host) {
String normalized = host.trim().toLowerCase(Locale.ROOT);
if (normalized.indexOf(':') >= 0 || normalized.indexOf('[') >= 0 || normalized.indexOf(']') >= 0) {
return normalizeIpv6Host(normalized);
}
while (normalized.endsWith(".")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
Expand All @@ -91,7 +101,81 @@ static String normalizeHost(String host) {
return IDN.toASCII(normalized);
}

private static String normalizeIpv6Host(String host) {
boolean startsWithBracket = host.startsWith("[");
boolean endsWithBracket = host.endsWith("]");
if (startsWithBracket != endsWithBracket) {
throw new IllegalArgumentException("IPv6 host brackets are invalid");
}

String literal = startsWithBracket ? host.substring(1, host.length() - 1) : host;
if (literal.isEmpty() || literal.indexOf('[') >= 0 || literal.indexOf(']') >= 0 || literal.indexOf('%') >= 0) {
throw new IllegalArgumentException("IPv6 host is invalid");
}

try {
URI uri = new URI("http://[" + literal + "]/");
if (uri.getHost() == null) {
throw new IllegalArgumentException("IPv6 host is invalid");
}
InetAddress address = InetAddress.getByName(literal);
if (!(address instanceof Inet6Address)) {
throw new IllegalArgumentException("IPv6 host is invalid");
}
return address.getHostAddress().toLowerCase(Locale.ROOT);
} catch (URISyntaxException | UnknownHostException e) {
throw new IllegalArgumentException("IPv6 host is invalid", e);
}
}

private static Set<String> normalizeAllowedHosts(Collection<String> hosts) {
Set<String> result = new HashSet<>();
for (String host : hosts) {
if (host == null) {
throw new IllegalArgumentException("Allowed host can not be null");
}
String normalized = normalizeAllowedHost(host);
result.add(normalized);
}
return result;
}

private static String normalizeAllowedHost(String host) {
String candidate = host.trim();
if (candidate.isEmpty()) {
throw new IllegalArgumentException("Allowed host can not be blank");
}
if (candidate.indexOf('*') >= 0) {
throw new IllegalArgumentException("Allowed host wildcards are not supported");
}
if (candidate.indexOf('/') >= 0
|| candidate.indexOf('\\') >= 0
|| candidate.indexOf('@') >= 0
|| candidate.indexOf('?') >= 0
|| candidate.indexOf('#') >= 0) {
throw new IllegalArgumentException("Allowed host must not contain URL components");
}
for (int i = 0; i < candidate.length(); i++) {
char character = candidate.charAt(i);
if (Character.isWhitespace(character)
|| Character.isSpaceChar(character)
|| Character.isISOControl(character)) {
throw new IllegalArgumentException("Allowed host must not contain whitespace or control characters");
}
}
try {
String normalized = normalizeHost(candidate);
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Allowed host can not be blank");
}
return normalized;
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Allowed host is invalid", e);
}
}

public static final class Builder {
private Set<String> allowedHosts = Collections.emptySet();
private boolean allowPrivateNetwork;
private Set<String> allowedPrivateHosts = Collections.emptySet();
private List<CidrBlock> allowedPrivateCidrs = Collections.emptyList();
Expand All @@ -101,6 +185,21 @@ public static final class Builder {

private Builder() {}

/**
* Allows remote image fetching only for the configured exact hosts.
*
* @param allowedHosts exact host names or IP literals without scheme, port, path, or wildcard; URL ports are
* not evaluated
* @return this builder
*/
public Builder allowedHosts(Collection<String> allowedHosts) {
if (allowedHosts == null) {
throw new IllegalArgumentException("Allowed hosts can not be null");
}
this.allowedHosts = new HashSet<>(allowedHosts);
return this;
}

public Builder allowPrivateNetwork(boolean allowPrivateNetwork) {
this.allowPrivateNetwork = allowPrivateNetwork;
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@ void test_rejectNullSchemePolicy() {
@Test
void test_httpsOnlyPolicyRejectsHttpUrl() throws Exception {
URL url = startServer(HttpStatus.OK, PNG_BYTES, "image/png");
UrlImageConverter.setFetchPolicy(
UrlImageFetchPolicy.builder().allowedSchemes(SchemePolicy.HTTPS).build());
UrlImageConverter.setFetchPolicy(UrlImageFetchPolicy.builder()
.allowedHosts(Collections.singleton("127.0.0.1"))
.allowedSchemes(SchemePolicy.HTTPS)
.build());

IOException exception = Assertions.assertThrows(IOException.class, () -> convert(url));

Expand All @@ -96,19 +98,47 @@ void test_httpsOnlyPolicyRejectsHttpUrl() throws Exception {
}

@Test
void test_rejectLoopbackByDefault() throws Exception {
void test_remoteFetchIsDisabledByDefault() throws Exception {
URL url = startServer(HttpStatus.OK, PNG_BYTES, "image/png");

IOException exception = Assertions.assertThrows(IOException.class, () -> convert(url));

Assertions.assertTrue(exception.getMessage().contains("disabled"));
Assertions.assertEquals(0, requestCount.get());
}

@Test
void test_rejectNonAllowlistedHostBeforeConnection() throws Exception {
URL url = startServer(HttpStatus.OK, PNG_BYTES, "image/png");
UrlImageConverter.setFetchPolicy(UrlImageFetchPolicy.builder()
.allowedHosts(Collections.singleton("images.example.com"))
.build());

IOException exception = Assertions.assertThrows(IOException.class, () -> convert(url));

Assertions.assertTrue(exception.getMessage().contains("allowlisted"));
Assertions.assertEquals(0, requestCount.get());
}

@Test
void test_privateHostAllowlistDoesNotEnableRemoteFetching() 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());

IOException exception = Assertions.assertThrows(IOException.class, () -> convert(url));

Assertions.assertTrue(exception.getMessage().contains("restricted address"));
Assertions.assertTrue(exception.getMessage().contains("disabled"));
Assertions.assertEquals(0, requestCount.get());
}

@Test
void test_allowPrivateHostWhenExplicitlyAllowlisted() throws Exception {
void test_allowPrivateHostOnAnyPortWhenExplicitlyAllowlisted() throws Exception {
URL url = startServer(HttpStatus.OK, PNG_BYTES, "image/png");
UrlImageConverter.setFetchPolicy(UrlImageFetchPolicy.builder()
.allowedHosts(Collections.singleton("127.0.0.1"))
.allowPrivateNetwork(true)
.allowedPrivateHosts(Collections.singleton("127.0.0.1"))
.build());
Expand All @@ -124,6 +154,7 @@ void test_allowPrivateHostWhenExplicitlyAllowlisted() throws Exception {
void test_allowPrivateCidrWhenExplicitlyAllowlisted() throws Exception {
URL url = startServer(HttpStatus.OK, PNG_BYTES, "image/png");
UrlImageConverter.setFetchPolicy(UrlImageFetchPolicy.builder()
.allowedHosts(Collections.singleton("127.0.0.1"))
.allowPrivateNetwork(true)
.allowedPrivateCidrs(Collections.singleton(CidrBlock.parse("127.0.0.0/8")))
.build());
Expand All @@ -134,6 +165,18 @@ void test_allowPrivateCidrWhenExplicitlyAllowlisted() throws Exception {
PNG_BYTES, cellData.getImageDataList().get(0).getImage());
}

@Test
void test_rejectUrlUserInfoBeforeConnection() throws Exception {
URL serverUrl = startServer(HttpStatus.OK, PNG_BYTES, "image/png");
URL url = new URL("http://user:password@127.0.0.1:" + serverUrl.getPort() + "/image.png");
UrlImageConverter.setFetchPolicy(allowLoopbackPolicy());

IOException exception = Assertions.assertThrows(IOException.class, () -> convert(url));

Assertions.assertTrue(exception.getMessage().contains("user info"));
Assertions.assertEquals(0, requestCount.get());
}

@Test
void test_rejectNonImageResponse() throws Exception {
URL url = startServer(HttpStatus.OK, "root:x:0:0".getBytes("UTF-8"), "text/plain");
Expand All @@ -145,13 +188,13 @@ void test_rejectNonImageResponse() throws Exception {
}

@Test
void test_rejectRedirectToNonAllowlistedPrivateHost() throws Exception {
void test_rejectRedirectToNonAllowlistedHost() 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.assertTrue(exception.getMessage().contains("allowlisted"));
Assertions.assertEquals(1, requestCount.get());
}

Expand All @@ -160,6 +203,7 @@ 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()
.allowedHosts(Collections.singleton("127.0.0.1"))
.allowPrivateNetwork(true)
.allowedPrivateHosts(Collections.singleton("127.0.0.1"))
.maxImageBytes(PNG_BYTES.length)
Expand All @@ -172,6 +216,7 @@ void test_rejectImageLargerThanPolicyLimit() throws Exception {

private UrlImageFetchPolicy allowLoopbackPolicy() {
return UrlImageFetchPolicy.builder()
.allowedHosts(Collections.singleton("127.0.0.1"))
.allowPrivateNetwork(true)
.allowedPrivateHosts(Collections.singleton("127.0.0.1"))
.build();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* 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 java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import org.apache.fesod.sheet.converters.url.SchemePolicy;
import org.apache.fesod.sheet.converters.url.UrlImageFetchPolicy;
import org.apache.fesod.sheet.testkit.Tags;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

/**
* Tests {@link UrlImageFetchPolicy}.
*/
@Tag(Tags.UNIT)
class UrlImageFetchPolicyTest {

@Test
void test_defaultPolicyDisablesRemoteFetchingAndSupportsHttpConfiguration() {
UrlImageFetchPolicy policy = UrlImageFetchPolicy.defaultPolicy();

Assertions.assertTrue(policy.getAllowedHosts().isEmpty());
Assertions.assertTrue(policy.getAllowedSchemes().containsAll(SchemePolicy.HTTP_OR_HTTPS.getSchemes()));
}

@Test
void test_normalizeAllowedHostsForExactMatching() {
UrlImageFetchPolicy policy = UrlImageFetchPolicy.builder()
.allowedHosts(Arrays.asList(
" Images.Example.COM. ",
"images.example.com",
"BÜCHER.example",
"IMAGE_SERVICE.internal",
"[2001:db8::1]",
"2001:0DB8:0:0:0:0:0:1",
"::1"))
.build();

Assertions.assertEquals(
new HashSet<>(Arrays.asList(
"images.example.com",
"xn--bcher-kva.example",
"image_service.internal",
"2001:db8:0:0:0:0:0:1",
"0:0:0:0:0:0:0:1")),
policy.getAllowedHosts());
Assertions.assertFalse(policy.getAllowedHosts().contains("cdn.images.example.com"));
Assertions.assertFalse(policy.getAllowedHosts().contains("images.example.com.attacker.test"));
}

@Test
void test_rejectNullAllowedHosts() {
Assertions.assertThrows(IllegalArgumentException.class, () -> UrlImageFetchPolicy.builder()
.allowedHosts(null));
Assertions.assertThrows(IllegalArgumentException.class, () -> UrlImageFetchPolicy.builder()
.allowedHosts(Collections.singleton(null))
.build());
}

@Test
void test_rejectInvalidAllowedHosts() {
for (String host : Arrays.asList(
"",
"*.example.com",
"https://images.example.com",
"user@images.example.com",
"images.example.com:80",
"[::1]:80",
"image service.internal",
"image\tservice.internal")) {
Assertions.assertThrows(IllegalArgumentException.class, () -> UrlImageFetchPolicy.builder()
.allowedHosts(Collections.singleton(host))
.build());
}
}
}
Loading
Loading