1010import io .netty .util .concurrent .Promise ;
1111import io .netty .util .internal .SocketUtils ;
1212import lombok .extern .slf4j .Slf4j ;
13- import org .apache .commons .validator .routines .InetAddressValidator ;
1413import org .springframework .http .client .reactive .ReactorClientHttpConnector ;
1514import org .springframework .util .StringUtils ;
1615import org .springframework .web .reactive .function .client .ClientRequest ;
2120import reactor .netty .http .client .HttpClient ;
2221import reactor .netty .resources .ConnectionProvider ;
2322
24- import java .net .Inet6Address ;
2523import java .net .InetAddress ;
2624import java .net .InetSocketAddress ;
2725import java .net .UnknownHostException ;
2826import java .time .Duration ;
2927import java .util .Arrays ;
30- import java .util .Collections ;
31- import java .util .HashSet ;
3228import java .util .List ;
33- import java .util .Locale ;
34- import java .util .Optional ;
35- import java .util .Set ;
3629
30+ /**
31+ * Factory for {@link WebClient} instances pre-wired with the SSRF host filter
32+ * ({@link RestrictedHostFilter}). The filter is enforced at two layers:
33+ *
34+ * <ol>
35+ * <li>A request-stage {@link ExchangeFilterFunction} ({@link #IP_CHECK_FILTER}) that
36+ * rejects literal/canonical hosts on the deny set before DNS even runs.</li>
37+ * <li>A custom Netty {@link AddressResolver} ({@link ResolverGroup}) that runs DNS
38+ * itself and rejects when any resolved address lands on the deny set or matches a
39+ * non-routable address class.</li>
40+ * </ol>
41+ *
42+ * <p>Host-filter logic lives in {@link RestrictedHostFilter} so non-HTTP plugins (Redis,
43+ * SMTP, the Elasticsearch HttpAsyncClient hook, etc.) can call into the same denylist
44+ * without depending on Spring/Netty.
45+ */
3746@ Slf4j
3847public class WebClientUtils {
3948
40- private static final InetAddressValidator inetAddressValidator = InetAddressValidator .getInstance ();
41-
42- private static final Set <String > DISALLOWED_HOSTS = computeDisallowedHosts ();
43-
44- public static final String HOST_NOT_ALLOWED = "Host not allowed." ;
45-
4649 private static final int MAX_IN_MEMORY_SIZE_IN_BYTES = 16 * 1024 * 1024 ;
4750
4851 public static final ExchangeFilterFunction IP_CHECK_FILTER =
@@ -66,37 +69,6 @@ public class WebClientUtils {
6669
6770 private WebClientUtils () {}
6871
69- private static Set <String > computeDisallowedHosts () {
70- final Set <String > hosts = new HashSet <>();
71- addDisallowedHosts (
72- hosts ,
73- "169.254.169.254" ,
74- "168.63.129.16" ,
75- "fd00:ec2::254" ,
76- "fd20:ce::254" ,
77- "100.100.100.200" ,
78- "169.254.10.10" ,
79- "169.254.170.2" ,
80- "metadata.google.internal" ,
81- "metadata.tencentyun.com" );
82-
83- if ("1" .equals (System .getenv ("IN_DOCKER" ))) {
84- addDisallowedHosts (hosts , "127.0.0.1" , "::1" );
85- }
86-
87- return Collections .unmodifiableSet (hosts );
88- }
89-
90- private static void addDisallowedHosts (Set <String > hosts , String ... hostCandidates ) {
91- for (String hostCandidate : hostCandidates ) {
92- try {
93- hosts .add (normalizeHostForComparison (hostCandidate ));
94- } catch (UnknownHostException e ) {
95- throw new IllegalStateException ("Invalid disallowed host configured: " + hostCandidate , e );
96- }
97- }
98- }
99-
10072 public static WebClient create () {
10173 return builder ().build ();
10274 }
@@ -199,58 +171,6 @@ protected AddressResolver<InetSocketAddress> newResolver(EventExecutor executor)
199171 }
200172 }
201173
202- /**
203- * Resolves a hostname and validates that none of its addresses are disallowed for
204- * outbound connections from non-HTTP paths (e.g. SMTP via JavaMail). Checks against
205- * the cloud-metadata denylist, loopback, link-local, any-local, multicast, and IPv6
206- * Unique Local Addresses (fc00::/7). Returns the first validated resolved address so
207- * callers can connect to it directly, preventing DNS-rebinding TOCTOU bypasses.
208- *
209- * <p>RFC 1918 site-local ranges (10/8, 172.16/12, 192.168/16) are intentionally
210- * allowed because legitimate SMTP servers frequently reside on private networks.
211- *
212- * @return the resolved {@link InetAddress} if the host is allowed, or empty if blocked
213- */
214- public static Optional <InetAddress > resolveIfAllowed (String host ) {
215- if (!StringUtils .hasText (host )) {
216- return Optional .empty ();
217- }
218-
219- final String canonicalHost = normalizeHostForComparisonQuietly (host );
220-
221- if (DISALLOWED_HOSTS .contains (canonicalHost )) {
222- return Optional .empty ();
223- }
224-
225- final InetAddress [] resolved ;
226- try {
227- resolved = InetAddress .getAllByName (host );
228- } catch (UnknownHostException e ) {
229- return Optional .empty ();
230- }
231-
232- for (InetAddress addr : resolved ) {
233- if (DISALLOWED_HOSTS .contains (normalizeHostForComparisonQuietly (addr .getHostAddress ()))
234- || matchesBlockedAddressClass (addr )) {
235- return Optional .empty ();
236- }
237- }
238-
239- return Optional .of (resolved [0 ]);
240- }
241-
242- public static boolean isDisallowedAndFail (String host , Promise <?> promise ) {
243- final String canonicalHost = normalizeHostForComparisonQuietly (host );
244- if (DISALLOWED_HOSTS .contains (canonicalHost ) || isBlockedAddressClassInDocker (canonicalHost )) {
245- log .warn ("Host {} is disallowed. Failing the request." , host );
246- if (promise != null ) {
247- promise .setFailure (new UnknownHostException (HOST_NOT_ALLOWED ));
248- }
249- return true ;
250- }
251- return false ;
252- }
253-
254174 private static Mono <ClientRequest > requestFilterFn (ClientRequest request ) {
255175 final String host = request .url ().getHost ();
256176
@@ -259,119 +179,11 @@ private static Mono<ClientRequest> requestFilterFn(ClientRequest request) {
259179 AppsmithPluginError .PLUGIN_DATASOURCE_ARGUMENT_ERROR , "Requested url host is null or empty" ));
260180 }
261181
262- final String canonicalHost ;
263- try {
264- canonicalHost = normalizeHostForComparison (host );
265- } catch (UnknownHostException e ) {
266- // This exception is thrown, if the given host couldn't be resolved to an IP address. But, since we only
267- // canonicalize after ensuring that `host` is a valid IP address, this exception should never occur.
268- return Mono .error (new AppsmithPluginException (
269- AppsmithPluginError .PLUGIN_DATASOURCE_ARGUMENT_ERROR , "IP Address resolution is invalid" ));
270- }
271-
272- return (DISALLOWED_HOSTS .contains (canonicalHost ) || isBlockedAddressClassInDocker (canonicalHost ))
273- ? Mono .error (new UnknownHostException (HOST_NOT_ALLOWED ))
182+ return RestrictedHostFilter .isLiteralBlocked (host )
183+ ? Mono .error (new UnknownHostException (RestrictedHostFilter .HOST_NOT_ALLOWED ))
274184 : Mono .just (request );
275185 }
276186
277- static boolean isBlockedIpAddressClass (String canonicalHost ) {
278- if (!isValidIpAddress (canonicalHost )) {
279- return false ;
280- }
281- try {
282- return matchesBlockedAddressClass (InetAddress .getByName (canonicalHost ));
283- } catch (UnknownHostException e ) {
284- return false ;
285- }
286- }
287-
288- private static boolean matchesBlockedAddressClass (InetAddress address ) {
289- if (address .isLoopbackAddress ()
290- || address .isAnyLocalAddress ()
291- || address .isLinkLocalAddress ()
292- || address .isMulticastAddress ()) {
293- return true ;
294- }
295- if (address instanceof Inet6Address ) {
296- // fc00::/7 — IPv6 Unique Local Addresses
297- byte firstByte = address .getAddress ()[0 ];
298- return (firstByte & (byte ) 0xFE ) == (byte ) 0xFC ;
299- }
300- return false ;
301- }
302-
303- private static boolean isBlockedAddressClassInDocker (String canonicalHost ) {
304- return "1" .equals (System .getenv ("IN_DOCKER" )) && isBlockedIpAddressClass (canonicalHost );
305- }
306-
307- private static boolean isValidIpAddress (String host ) {
308- if (!StringUtils .hasText (host )) {
309- return false ;
310- }
311- host = stripHostDecorators (host );
312- return inetAddressValidator .isValid (host );
313- }
314-
315- private static String normalizeHostForComparison (String host ) throws UnknownHostException {
316- if (!StringUtils .hasText (host )) {
317- return host ;
318- }
319-
320- final String normalizedHost = stripHostDecorators (host .trim ().toLowerCase (Locale .ROOT ));
321- return isValidIpAddress (normalizedHost ) ? normalizeIpAddress (normalizedHost ) : normalizedHost ;
322- }
323-
324- private static String normalizeHostForComparisonQuietly (String host ) {
325- try {
326- return normalizeHostForComparison (host );
327- } catch (UnknownHostException e ) {
328- return StringUtils .hasText (host ) ? stripHostDecorators (host .trim ().toLowerCase (Locale .ROOT )) : host ;
329- }
330- }
331-
332- private static String stripHostDecorators (String host ) {
333- String sanitizedHost = host ;
334- while (sanitizedHost .endsWith ("." )) {
335- sanitizedHost = sanitizedHost .substring (0 , sanitizedHost .length () - 1 );
336- }
337- if (sanitizedHost .startsWith ("[" ) && sanitizedHost .endsWith ("]" )) {
338- sanitizedHost = sanitizedHost .substring (1 , sanitizedHost .length () - 1 );
339- }
340- return sanitizedHost ;
341- }
342-
343- private static String normalizeIpAddress (String host ) throws UnknownHostException {
344- final InetAddress address = InetAddress .getByName (host );
345-
346- if (address instanceof Inet6Address ) {
347- final byte [] addressBytes = address .getAddress ();
348- // Normalize IPv4-compatible and IPv4-mapped IPv6 literals back to the embedded IPv4 address so a single
349- // denylist entry blocks equivalent literal representations such as `100.100.100.200` and
350- // `[::100.100.100.200]`.
351- if (isIpv4CompatibleOrMapped (addressBytes )) {
352- return InetAddress .getByAddress (Arrays .copyOfRange (addressBytes , 12 , 16 ))
353- .getHostAddress ();
354- }
355- }
356-
357- return address .getHostAddress ();
358- }
359-
360- private static boolean isIpv4CompatibleOrMapped (byte [] addressBytes ) {
361- if (addressBytes .length != 16 ) {
362- return false ;
363- }
364-
365- for (int i = 0 ; i < 10 ; i ++) {
366- if (addressBytes [i ] != 0 ) {
367- return false ;
368- }
369- }
370-
371- return (addressBytes [10 ] == 0 && addressBytes [11 ] == 0 )
372- || (addressBytes [10 ] == (byte ) 0xff && addressBytes [11 ] == (byte ) 0xff );
373- }
374-
375187 private static class NameResolver extends InetNameResolver {
376188
377189 public NameResolver (EventExecutor executor ) {
@@ -380,7 +192,7 @@ public NameResolver(EventExecutor executor) {
380192
381193 @ Override
382194 protected void doResolve (String inetHost , Promise <InetAddress > promise ) {
383- if (isDisallowedAndFail (inetHost , promise )) {
195+ if (RestrictedHostFilter . isDisallowedAndFail (inetHost , promise )) {
384196 return ;
385197 }
386198
@@ -392,7 +204,7 @@ protected void doResolve(String inetHost, Promise<InetAddress> promise) {
392204 return ;
393205 }
394206
395- if (isDisallowedAndFail (address .getHostAddress (), promise )) {
207+ if (RestrictedHostFilter . isDisallowedAndFail (address .getHostAddress (), promise )) {
396208 return ;
397209 }
398210
@@ -401,7 +213,7 @@ protected void doResolve(String inetHost, Promise<InetAddress> promise) {
401213
402214 @ Override
403215 protected void doResolveAll (String inetHost , Promise <List <InetAddress >> promise ) {
404- if (isDisallowedAndFail (inetHost , promise )) {
216+ if (RestrictedHostFilter . isDisallowedAndFail (inetHost , promise )) {
405217 return ;
406218 }
407219
@@ -415,7 +227,7 @@ protected void doResolveAll(String inetHost, Promise<List<InetAddress>> promise)
415227
416228 // Even if _one_ of the addresses is disallowed, we fail the request.
417229 for (InetAddress address : addresses ) {
418- if (isDisallowedAndFail (address .getHostAddress (), promise )) {
230+ if (RestrictedHostFilter . isDisallowedAndFail (address .getHostAddress (), promise )) {
419231 return ;
420232 }
421233 }
0 commit comments