From f519aa039f34e20ae808a37fdde0b16298c5ccc9 Mon Sep 17 00:00:00 2001 From: Athou Date: Fri, 14 Feb 2025 11:49:55 +0100 Subject: [PATCH] block local addresses to prevent SSRF attacks --- commafeed-server/doc/commafeed.md | 23 +++++++ .../com/commafeed/CommaFeedConfiguration.java | 10 +++ .../com/commafeed/backend/HttpGetter.java | 60 +++++++++++++++- .../favicon/YoutubeFaviconFetcher.java | 9 ++- .../commafeed/backend/feed/FeedFetcher.java | 6 +- .../src/main/resources/application.properties | 1 + .../com/commafeed/backend/HttpGetterTest.java | 69 +++++++++++++++++-- .../java/com/commafeed/e2e/ReadingIT.java | 1 + 8 files changed, 166 insertions(+), 13 deletions(-) diff --git a/commafeed-server/doc/commafeed.md b/commafeed-server/doc/commafeed.md index e6a2ca05..c6d8f44c 100644 --- a/commafeed-server/doc/commafeed.md +++ b/commafeed-server/doc/commafeed.md @@ -287,6 +287,29 @@ MemorySize [🛈](#memory-size-note-anchor) `5M` + + + +`commafeed.http-client.block-local-addresses` + +Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal +resources. + +You may want to disable this if you subscribe to feeds that are only available on your local network and you trust all users of +your CommaFeed instance. + + + +Environment variable: `COMMAFEED_HTTP_CLIENT_BLOCK_LOCAL_ADDRESSES` + + +boolean + + + +`true` + + diff --git a/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java b/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java index 7fafb4fe..99a01216 100644 --- a/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java +++ b/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java @@ -138,6 +138,16 @@ public interface CommaFeedConfiguration { @WithDefault("5M") MemorySize maxResponseSize(); + /** + * Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal + * resources. + * + * You may want to disable this if you subscribe to feeds that are only available on your local network and you trust all users of + * your CommaFeed instance. + */ + @WithDefault("true") + boolean blockLocalAddresses(); + /** * HTTP client cache configuration */ diff --git a/commafeed-server/src/main/java/com/commafeed/backend/HttpGetter.java b/commafeed-server/src/main/java/com/commafeed/backend/HttpGetter.java index e4df949a..5ac061de 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/HttpGetter.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/HttpGetter.java @@ -2,7 +2,9 @@ package com.commafeed.backend; import java.io.IOException; import java.io.InputStream; +import java.net.InetAddress; import java.net.URI; +import java.net.UnknownHostException; import java.time.Duration; import java.time.Instant; import java.time.InstantSource; @@ -10,8 +12,11 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.concurrent.ExecutionException; +import java.util.stream.Stream; import org.apache.commons.lang3.StringUtils; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.config.TlsConfig; @@ -66,6 +71,7 @@ public class HttpGetter { private final InstantSource instantSource; private final CloseableHttpClient client; private final Cache cache; + private final DnsResolver dnsResolver = SystemDefaultDnsResolver.INSTANCE; public HttpGetter(CommaFeedConfiguration config, InstantSource instantSource, CommaFeedVersion version, MetricRegistry metrics) { this.config = config; @@ -89,11 +95,20 @@ public class HttpGetter { () -> cache == null ? 0 : cache.asMap().values().stream().mapToInt(e -> e.content != null ? e.content.length : 0).sum()); } - public HttpResult get(String url) throws IOException, NotModifiedException, TooManyRequestsException { + public HttpResult get(String url) + throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException { return get(HttpRequest.builder(url).build()); } - public HttpResult get(HttpRequest request) throws IOException, NotModifiedException, TooManyRequestsException { + public HttpResult get(HttpRequest request) + throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException { + URI uri = URI.create(request.getUrl()); + ensureHttpScheme(uri.getScheme()); + + if (config.httpClient().blockLocalAddresses()) { + ensurePublicAddress(uri.getHost()); + } + final HttpResponse response; if (cache == null) { response = invoke(request); @@ -141,6 +156,28 @@ public class HttpGetter { response.getUrlAfterRedirect(), validFor); } + private void ensureHttpScheme(String scheme) throws SchemeNotAllowedException { + if (!"http".equals(scheme) && !"https".equals(scheme)) { + throw new SchemeNotAllowedException(scheme); + } + } + + private void ensurePublicAddress(String host) throws HostNotAllowedException, UnknownHostException { + if (host == null) { + throw new HostNotAllowedException(null); + } + + InetAddress[] addresses = dnsResolver.resolve(host); + if (Stream.of(addresses).anyMatch(this::isPrivateAddress)) { + throw new HostNotAllowedException(host); + } + } + + private boolean isPrivateAddress(InetAddress address) { + return address.isSiteLocalAddress() || address.isAnyLocalAddress() || address.isLinkLocalAddress() || address.isLoopbackAddress() + || address.isMulticastAddress(); + } + private HttpResponse invoke(HttpRequest request) throws IOException { log.debug("fetching {}", request.getUrl()); @@ -229,7 +266,7 @@ public class HttpGetter { } } - private static PoolingHttpClientConnectionManager newConnectionManager(CommaFeedConfiguration config) { + private PoolingHttpClientConnectionManager newConnectionManager(CommaFeedConfiguration config) { SSLFactory sslFactory = SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build(); int poolSize = config.feedRefresh().httpThreads(); @@ -243,6 +280,7 @@ public class HttpGetter { .setDefaultTlsConfig(TlsConfig.custom().setHandshakeTimeout(Timeout.of(config.httpClient().sslHandshakeTimeout())).build()) .setMaxConnPerRoute(poolSize) .setMaxConnTotal(poolSize) + .setDnsResolver(dnsResolver) .build(); } @@ -279,6 +317,22 @@ public class HttpGetter { .build(); } + public static class SchemeNotAllowedException extends Exception { + private static final long serialVersionUID = 1L; + + public SchemeNotAllowedException(String scheme) { + super("Scheme not allowed: " + scheme); + } + } + + public static class HostNotAllowedException extends Exception { + private static final long serialVersionUID = 1L; + + public HostNotAllowedException(String host) { + super("Host not allowed: " + host); + } + } + @Getter public static class NotModifiedException extends Exception { private static final long serialVersionUID = 1L; diff --git a/commafeed-server/src/main/java/com/commafeed/backend/favicon/YoutubeFaviconFetcher.java b/commafeed-server/src/main/java/com/commafeed/backend/favicon/YoutubeFaviconFetcher.java index 42cedd85..caa2a1e5 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/favicon/YoutubeFaviconFetcher.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/favicon/YoutubeFaviconFetcher.java @@ -11,8 +11,10 @@ import org.apache.hc.core5.net.URIBuilder; import com.commafeed.CommaFeedConfiguration; import com.commafeed.backend.HttpGetter; +import com.commafeed.backend.HttpGetter.HostNotAllowedException; import com.commafeed.backend.HttpGetter.HttpResult; import com.commafeed.backend.HttpGetter.NotModifiedException; +import com.commafeed.backend.HttpGetter.SchemeNotAllowedException; import com.commafeed.backend.HttpGetter.TooManyRequestsException; import com.commafeed.backend.model.Feed; import com.fasterxml.jackson.core.JsonPointer; @@ -92,7 +94,8 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher { return new Favicon(bytes, contentType); } - private byte[] fetchForUser(String googleAuthKey, String userId) throws IOException, NotModifiedException, TooManyRequestsException { + private byte[] fetchForUser(String googleAuthKey, String userId) + throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException { URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels") .queryParam("part", "snippet") .queryParam("key", googleAuthKey) @@ -102,7 +105,7 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher { } private byte[] fetchForChannel(String googleAuthKey, String channelId) - throws IOException, NotModifiedException, TooManyRequestsException { + throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException { URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels") .queryParam("part", "snippet") .queryParam("key", googleAuthKey) @@ -112,7 +115,7 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher { } private byte[] fetchForPlaylist(String googleAuthKey, String playlistId) - throws IOException, NotModifiedException, TooManyRequestsException { + throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException { URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/playlists") .queryParam("part", "snippet") .queryParam("key", googleAuthKey) diff --git a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedFetcher.java b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedFetcher.java index 1d88f47a..bb456c23 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedFetcher.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedFetcher.java @@ -10,9 +10,11 @@ import org.apache.commons.lang3.StringUtils; import com.commafeed.backend.Digests; import com.commafeed.backend.HttpGetter; +import com.commafeed.backend.HttpGetter.HostNotAllowedException; import com.commafeed.backend.HttpGetter.HttpRequest; import com.commafeed.backend.HttpGetter.HttpResult; import com.commafeed.backend.HttpGetter.NotModifiedException; +import com.commafeed.backend.HttpGetter.SchemeNotAllowedException; import com.commafeed.backend.HttpGetter.TooManyRequestsException; import com.commafeed.backend.feed.parser.FeedParser; import com.commafeed.backend.feed.parser.FeedParserResult; @@ -41,8 +43,8 @@ public class FeedFetcher { } public FeedFetcherResult fetch(String feedUrl, boolean extractFeedUrlFromHtml, String lastModified, String eTag, - Instant lastPublishedDate, String lastContentHash) - throws FeedException, IOException, NotModifiedException, TooManyRequestsException { + Instant lastPublishedDate, String lastContentHash) throws FeedException, IOException, NotModifiedException, + TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException { log.debug("Fetching feed {}", feedUrl); HttpResult result = getter.get(HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build()); diff --git a/commafeed-server/src/main/resources/application.properties b/commafeed-server/src/main/resources/application.properties index e6059e45..a90aeff7 100644 --- a/commafeed-server/src/main/resources/application.properties +++ b/commafeed-server/src/main/resources/application.properties @@ -50,6 +50,7 @@ quarkus.native.add-all-charsets=true %test.commafeed.users.allow-registrations=true %test.commafeed.password-recovery-enabled=true %test.commafeed.http-client.cache.enabled=false +%test.commafeed.http-client.block-local-addresses=false %test.commafeed.database.cleanup.entries-max-age=0 %test.commafeed.feed-refresh.force-refresh-cooldown-duration=1m diff --git a/commafeed-server/src/test/java/com/commafeed/backend/HttpGetterTest.java b/commafeed-server/src/test/java/com/commafeed/backend/HttpGetterTest.java index ededc315..a06541cd 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/HttpGetterTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/HttpGetterTest.java @@ -236,7 +236,7 @@ class HttpGetterTest { } @Test - void cacheSubsequentCalls() throws IOException, NotModifiedException, TooManyRequestsException { + void cacheSubsequentCalls() throws Exception { AtomicInteger calls = new AtomicInteger(); this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(req -> { @@ -302,17 +302,16 @@ class HttpGetterTest { class Compression { @Test - void deflate() throws IOException, NotModifiedException, TooManyRequestsException { + void deflate() throws Exception { supportsCompression("deflate", DeflaterOutputStream::new); } @Test - void gzip() throws IOException, NotModifiedException, TooManyRequestsException { + void gzip() throws Exception { supportsCompression("gzip", GZIPOutputStream::new); } - void supportsCompression(String encoding, CompressionOutputStreamFunction compressionOutputStreamFunction) - throws IOException, NotModifiedException, TooManyRequestsException { + void supportsCompression(String encoding, CompressionOutputStreamFunction compressionOutputStreamFunction) throws Exception { String body = "my body"; HttpGetterTest.this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(req -> { @@ -340,4 +339,64 @@ class HttpGetterTest { } + @Nested + class SchemeNotAllowed { + @Test + void file() { + Assertions.assertThrows(HttpGetter.SchemeNotAllowedException.class, () -> getter.get("file://localhost")); + } + + @Test + void ftp() { + Assertions.assertThrows(HttpGetter.SchemeNotAllowedException.class, () -> getter.get("ftp://localhost")); + } + } + + @Nested + class HostNotAllowed { + + @BeforeEach + void init() { + Mockito.when(config.httpClient().blockLocalAddresses()).thenReturn(true); + getter = new HttpGetter(config, () -> NOW, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class)); + } + + @Test + void localhost() { + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://localhost")); + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://127.0.0.1")); + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://2130706433")); + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://0x7F.0x00.0x00.0X01")); + } + + @Test + void zero() { + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://0.0.0.0")); + } + + @Test + void linkLocal() { + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://169.254.12.34")); + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://169.254.169.254")); + } + + @Test + void multicast() { + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://224.2.3.4")); + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://239.255.255.254")); + } + + @Test + void privateIpv4Ranges() { + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://10.0.0.1")); + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://172.16.0.1")); + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://192.168.0.1")); + } + + @Test + void privateIpv6Ranges() { + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://fd12:3456:789a:1::1")); + } + } + } \ No newline at end of file diff --git a/commafeed-server/src/test/java/com/commafeed/e2e/ReadingIT.java b/commafeed-server/src/test/java/com/commafeed/e2e/ReadingIT.java index 02e1568c..f2dc9803 100644 --- a/commafeed-server/src/test/java/com/commafeed/e2e/ReadingIT.java +++ b/commafeed-server/src/test/java/com/commafeed/e2e/ReadingIT.java @@ -3,6 +3,7 @@ package com.commafeed.e2e; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; import org.apache.commons.io.IOUtils; import org.apache.hc.core5.http.HttpStatus;