From dea6e34a9863fb9f9419eb75b6080d3049ebe09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Thu, 31 Oct 2024 16:35:52 +0100 Subject: [PATCH 01/34] Initial commit with empyt gradle library project --- .editorconfig | 19 ++ .gitattributes | 9 + .gitignore | 18 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 240 ++++++++++++++++++ gradlew.bat | 91 +++++++ settings.gradle | 11 + ssrf/build.gradle | 30 +++ .../security/web/ssrf/Library.java | 7 + .../security/web/ssrf/LibraryTest.java | 14 + 11 files changed, 444 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 ssrf/build.gradle create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/ssrf/Library.java create mode 100644 ssrf/src/test/java/com/google/springframework/security/web/ssrf/LibraryTest.java diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..b8d5bffd2ff --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true +max_line_length = 120 + +[*.{java,xml}] +indent_style = tab +indent_size = 4 +charset = utf-8 +continuation_indent_size = 8 + +ij_smart_tabs = false +ij_java_align_multiline_parameters = false + +[*.gradle] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..097f9f98d9e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000000..34b38a08f71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +ssrf/build +ssrf/bin + +# IDEA artifacts and output dirs +*.iml +*.ipr +*.iws +.idea +out +test-output +atlassian-ide-plugin.xml + +# VS Code +.vscode/ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000000..f127cfd49d4 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000000..9421adeed9e --- /dev/null +++ b/settings.gradle @@ -0,0 +1,11 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * The settings file is used to specify which projects to include in your build. + * + * Detailed information about configuring a multi-project build in Gradle can be found + * in the user manual at https://docs.gradle.org/7.5.1/userguide/multi_project_builds.html + */ + +rootProject.name = 'goole-ssrf' +include('ssrf') diff --git a/ssrf/build.gradle b/ssrf/build.gradle new file mode 100644 index 00000000000..9aa5e39d289 --- /dev/null +++ b/ssrf/build.gradle @@ -0,0 +1,30 @@ +buildscript { + repositories { + maven { url 'https://plugins.gradle.org/m2/' } + } +} + + +plugins { + // Apply the java-library plugin for API and implementation separation. + id 'java-library' +} + +repositories { + // Use Maven Central for resolving dependencies. + mavenCentral() +} + +dependencies { + // Use JUnit Jupiter for testing. + testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2' + + api 'org.springframework:spring-web:6.1.14' + + api 'org.springframework:spring-core:6.1.14' +} + +tasks.named('test') { + // Use JUnit Platform for unit tests. + useJUnitPlatform() +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/Library.java b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/Library.java new file mode 100644 index 00000000000..a3b2c4ba676 --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/Library.java @@ -0,0 +1,7 @@ +package com.google.springframework.security.web.ssrf; + +public class Library { + public boolean someLibraryMethod() { + return true; + } +} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/LibraryTest.java b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/LibraryTest.java new file mode 100644 index 00000000000..b36737e715a --- /dev/null +++ b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/LibraryTest.java @@ -0,0 +1,14 @@ +/* + * This Java source file was generated by the Gradle 'init' task. + */ +package com.google.springframework.security.web.ssrf; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class LibraryTest { + @Test void someLibraryMethodReturnsTrue() { + Library classUnderTest = new Library(); + assertTrue(classUnderTest.someLibraryMethod(), "someLibraryMethod should return 'true'"); + } +} From b864e002ca1cee995d29c41a29dded03b6255c11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Thu, 31 Oct 2024 17:33:03 +0100 Subject: [PATCH 02/34] Importing code from pervious PRs and starting a scaffolding for the new code --- LICENSE.txt | 202 ++++++++++++++++++ gradle.properties | 1 + ssrf/build.gradle | 10 +- .../security/web/ssrf/CustomDnsResolver.java | 60 ++++++ .../security/web/ssrf/Library.java | 7 - .../web/ssrf/SsrfProtectionConfig.java | 60 ++++++ ...ttpComponentsClientHttpRequestFactory.java | 26 +++ .../client/SecureJdkClientHttpConnector.java | 28 +++ .../web/ssrf/CustomDnsResolverTest.java | 17 ++ .../security/web/ssrf/LibraryTest.java | 14 -- .../SecureJdkClientHttpConnectorTest.java | 15 ++ 11 files changed, 417 insertions(+), 23 deletions(-) create mode 100644 LICENSE.txt create mode 100644 gradle.properties create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/ssrf/CustomDnsResolver.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/ssrf/Library.java create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionConfig.java create mode 100644 ssrf/src/main/java/org/springframework/http/client/SecureHttpComponentsClientHttpRequestFactory.java create mode 100644 ssrf/src/main/java/org/springframework/http/client/SecureJdkClientHttpConnector.java create mode 100644 ssrf/src/test/java/com/google/springframework/security/web/ssrf/CustomDnsResolverTest.java delete mode 100644 ssrf/src/test/java/com/google/springframework/security/web/ssrf/LibraryTest.java create mode 100644 ssrf/src/test/java/org/springframework/http/client/SecureJdkClientHttpConnectorTest.java diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000000..ff773796315 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed 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 + + https://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. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000000..b5c59e277b3 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +springVersion=6.1.4 diff --git a/ssrf/build.gradle b/ssrf/build.gradle index 9aa5e39d289..64caf00733d 100644 --- a/ssrf/build.gradle +++ b/ssrf/build.gradle @@ -19,9 +19,15 @@ dependencies { // Use JUnit Jupiter for testing. testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2' - api 'org.springframework:spring-web:6.1.14' + api 'org.springframework:spring-web:' + springVersion - api 'org.springframework:spring-core:6.1.14' + api 'org.springframework:spring-core:' + springVersion + + + api("org.apache.httpcomponents.client5:httpclient5:5.3.1") + api("org.apache.httpcomponents.core5:httpcore5-reactive:5.2.5") + + implementation 'io.projectreactor:reactor-core:3.6.11' } tasks.named('test') { diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/CustomDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/CustomDnsResolver.java new file mode 100644 index 00000000000..3a1d2ca5a89 --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/CustomDnsResolver.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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 com.google.springframework.security.web.ssrf; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +import org.apache.hc.client5.http.DnsResolver; + +class CustomDnsResolver implements DnsResolver { + + private final SsrfProtectionConfig ssrfProtectionConfig; + + public CustomDnsResolver(SsrfProtectionConfig ssrfProtectionConfig) { + this.ssrfProtectionConfig = ssrfProtectionConfig; + } + + @Override + public InetAddress[] resolve(final String host) throws UnknownHostException { + if (this.ssrfProtectionConfig.getBannedIps().contains(host)) { + throw new UnknownHostException("Blocked access to IP: " + host); + } + + if (!this.ssrfProtectionConfig.isAllowInternalIp() && isInternalIp(host)) { + throw new UnknownHostException("Blocked access to internal IP: " + host); + } + + if (!this.ssrfProtectionConfig.isAllowExternalIp() && !isInternalIp(host)) { + throw new UnknownHostException("Blocked access to external IP: " + host); + } + + // Default behavior: allow if not banned and no allow rules are set + return InetAddress.getAllByName(host); + } + + private boolean isInternalIp(String host) { + // Implement your logic to determine if an IP is internal + // This is a simplified example, you might need more robust checks + return host.startsWith("10.") || host.startsWith("192.168.") || host.startsWith( + "172.16.") || host.startsWith("fd00:"); + } + + @Override + public String resolveCanonicalHostname(String host) throws UnknownHostException { + return host; + } +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/Library.java b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/Library.java deleted file mode 100644 index a3b2c4ba676..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/Library.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.google.springframework.security.web.ssrf; - -public class Library { - public boolean someLibraryMethod() { - return true; - } -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionConfig.java b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionConfig.java new file mode 100644 index 00000000000..43cc49629f5 --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionConfig.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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 com.google.springframework.security.web.ssrf; + + +import java.util.Collections; +import java.util.List; + + +public class SsrfProtectionConfig { + + private List bannedIps = Collections.emptyList(); + private boolean allowInternalIp = false; // New config + private boolean allowExternalIp = false; // New config + + public SsrfProtectionConfig() { + } + + public SsrfProtectionConfig(List bannedIps) { + this.bannedIps = bannedIps; + } + + public List getBannedIps() { + return this.bannedIps; + } + + public boolean isAllowInternalIp() { + return allowInternalIp; + } + + public void setAllowInternalIp(boolean allowInternalIp) { + this.allowInternalIp = allowInternalIp; + } + + public boolean isAllowExternalIp() { + return allowExternalIp; + } + + public void setAllowExternalIp(boolean allowExternalIp) { + this.allowExternalIp = allowExternalIp; + } + + public void setBannedIps(List bannedIps) { + this.bannedIps = bannedIps; + } +} diff --git a/ssrf/src/main/java/org/springframework/http/client/SecureHttpComponentsClientHttpRequestFactory.java b/ssrf/src/main/java/org/springframework/http/client/SecureHttpComponentsClientHttpRequestFactory.java new file mode 100644 index 00000000000..0d81b9e9678 --- /dev/null +++ b/ssrf/src/main/java/org/springframework/http/client/SecureHttpComponentsClientHttpRequestFactory.java @@ -0,0 +1,26 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.http.client; + + +// TODO(vaspori): Document this properly +// Sadly all the needed classes are package private in org.springframework.http.client +// currently this is is the only way to override them. +public class SecureHttpComponentsClientHttpRequestFactory extends HttpComponentsClientHttpRequestFactory{ + + + +} diff --git a/ssrf/src/main/java/org/springframework/http/client/SecureJdkClientHttpConnector.java b/ssrf/src/main/java/org/springframework/http/client/SecureJdkClientHttpConnector.java new file mode 100644 index 00000000000..1014bb4f9da --- /dev/null +++ b/ssrf/src/main/java/org/springframework/http/client/SecureJdkClientHttpConnector.java @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.http.client; + +import org.springframework.http.client.reactive.JdkClientHttpConnector; + +public class SecureJdkClientHttpConnector extends JdkClientHttpConnector { + + public SecureJdkClientHttpConnector() { + + throw new RuntimeException( + "This feature could not be implemented. The client lacks support for overriding address resolution."); + } + +} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/CustomDnsResolverTest.java b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/CustomDnsResolverTest.java new file mode 100644 index 00000000000..cf589198f94 --- /dev/null +++ b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/CustomDnsResolverTest.java @@ -0,0 +1,17 @@ +package com.google.springframework.security.web.ssrf; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.UnknownHostException; +import org.junit.jupiter.api.Test; + +class CustomDnsResolverTest { + + @Test + void resolve() throws UnknownHostException { + SsrfProtectionConfig config = new SsrfProtectionConfig(); + config.setAllowExternalIp(true); + CustomDnsResolver t = new CustomDnsResolver(config); + t.resolve("8.8.8.8"); + } +} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/LibraryTest.java b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/LibraryTest.java deleted file mode 100644 index b36737e715a..00000000000 --- a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/LibraryTest.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * This Java source file was generated by the Gradle 'init' task. - */ -package com.google.springframework.security.web.ssrf; - -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - -class LibraryTest { - @Test void someLibraryMethodReturnsTrue() { - Library classUnderTest = new Library(); - assertTrue(classUnderTest.someLibraryMethod(), "someLibraryMethod should return 'true'"); - } -} diff --git a/ssrf/src/test/java/org/springframework/http/client/SecureJdkClientHttpConnectorTest.java b/ssrf/src/test/java/org/springframework/http/client/SecureJdkClientHttpConnectorTest.java new file mode 100644 index 00000000000..5e704ab6d11 --- /dev/null +++ b/ssrf/src/test/java/org/springframework/http/client/SecureJdkClientHttpConnectorTest.java @@ -0,0 +1,15 @@ +package org.springframework.http.client; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class SecureJdkClientHttpConnectorTest { + + @Test + void checkThatItFails(){ + + + } + +} From f4872ca753648c6bed94f80a32d31211c4676b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Wed, 6 Nov 2024 16:02:22 +0100 Subject: [PATCH 03/34] First implementation of an SSRF hardened RestTemplate --- ssrf/build.gradle | 2 + .../web/ssrf/BasicSSRFProtectionFilter.java | 66 +++++++++++++ .../security/web/ssrf/CustomDnsResolver.java | 36 +++---- .../security/web/ssrf/FilterUtils.java | 52 ++++++++++ .../web/ssrf/HostBlockedException.java} | 15 ++- .../security/web/ssrf/IpOrRange.java | 80 ++++++++++++++++ .../web/ssrf/ListedSsrfProtectionFilter.java | 76 +++++++++++++++ .../web/ssrf/SecureRestTemplateUtil.java | 49 ++++++++++ .../web/ssrf/SsrfProtectionConfig.java | 46 +++++---- .../web/ssrf/SsrfProtectionFilter.java} | 11 +-- .../ssrf/BasicSSRFProtectionFilterTest.java | 86 +++++++++++++++++ .../web/ssrf/CustomDnsResolverTest.java | 76 +++++++++++++-- .../security/web/ssrf/FilterUtilsTest.java | 54 +++++++++++ .../security/web/ssrf/IpOrRangeTest.java | 88 +++++++++++++++++ .../ssrf/ListedSsrfProtectionFilterTest.java | 94 +++++++++++++++++++ .../security/web/ssrf/UsageExample.java | 23 +++++ .../SecureJdkClientHttpConnectorTest.java | 15 --- 17 files changed, 792 insertions(+), 77 deletions(-) create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/ssrf/BasicSSRFProtectionFilter.java create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/ssrf/FilterUtils.java rename ssrf/src/main/java/{org/springframework/http/client/SecureHttpComponentsClientHttpRequestFactory.java => com/google/springframework/security/web/ssrf/HostBlockedException.java} (65%) create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/ssrf/IpOrRange.java create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/ssrf/ListedSsrfProtectionFilter.java create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/ssrf/SecureRestTemplateUtil.java rename ssrf/src/main/java/{org/springframework/http/client/SecureJdkClientHttpConnector.java => com/google/springframework/security/web/ssrf/SsrfProtectionFilter.java} (63%) create mode 100644 ssrf/src/test/java/com/google/springframework/security/web/ssrf/BasicSSRFProtectionFilterTest.java create mode 100644 ssrf/src/test/java/com/google/springframework/security/web/ssrf/FilterUtilsTest.java create mode 100644 ssrf/src/test/java/com/google/springframework/security/web/ssrf/IpOrRangeTest.java create mode 100644 ssrf/src/test/java/com/google/springframework/security/web/ssrf/ListedSsrfProtectionFilterTest.java create mode 100644 ssrf/src/test/java/com/google/springframework/security/web/ssrf/UsageExample.java delete mode 100644 ssrf/src/test/java/org/springframework/http/client/SecureJdkClientHttpConnectorTest.java diff --git a/ssrf/build.gradle b/ssrf/build.gradle index 64caf00733d..ad6ce3a9f68 100644 --- a/ssrf/build.gradle +++ b/ssrf/build.gradle @@ -18,6 +18,8 @@ repositories { dependencies { // Use JUnit Jupiter for testing. testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2' + testImplementation 'org.mockito:mockito-core:5.14.2' + testImplementation "org.mockito:mockito-junit-jupiter:5.14.2" api 'org.springframework:spring-web:' + springVersion diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/BasicSSRFProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/BasicSSRFProtectionFilter.java new file mode 100644 index 00000000000..dfe5ea36af6 --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/BasicSSRFProtectionFilter.java @@ -0,0 +1,66 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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 com.google.springframework.security.web.ssrf; + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +public class BasicSSRFProtectionFilter implements SsrfProtectionFilter { + + public enum FilterMode { + ALLOW_INTERNAL_BLOCK_EXTERNAL, + BLOCK_INTERNAL_ALLOW_EXTERNAL + + } + + private FilterMode mode; + + + public BasicSSRFProtectionFilter(FilterMode mode) { + this.mode = mode; + } + + @Override + public InetAddress[] filter(InetAddress[] addresses) throws HostBlockedException { + + List result = new ArrayList<>(addresses.length); + + for (InetAddress addr : addresses) { + boolean isInternal = FilterUtils.isInternalIp(addr); + boolean shouldAllow = switch (mode) { + case ALLOW_INTERNAL_BLOCK_EXTERNAL -> isInternal; + case BLOCK_INTERNAL_ALLOW_EXTERNAL -> !isInternal; + }; + if (shouldAllow) { + result.add(addr); + } + + } + + if (result.size() == 0) { + String addrFmt = Arrays.stream(addresses).map(a -> a.toString()).collect(Collectors.joining(", ")); + throw new HostBlockedException( + "The following address(es) were blocked due to violating " + mode.name() + " policy: " + addrFmt); + } + + return result.toArray(new InetAddress[]{}); + } + + +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/CustomDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/CustomDnsResolver.java index 3a1d2ca5a89..4ad7f3a805a 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/CustomDnsResolver.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/CustomDnsResolver.java @@ -17,7 +17,8 @@ import java.net.InetAddress; import java.net.UnknownHostException; - +import java.util.ArrayList; +import java.util.List; import org.apache.hc.client5.http.DnsResolver; class CustomDnsResolver implements DnsResolver { @@ -30,31 +31,30 @@ public CustomDnsResolver(SsrfProtectionConfig ssrfProtectionConfig) { @Override public InetAddress[] resolve(final String host) throws UnknownHostException { - if (this.ssrfProtectionConfig.getBannedIps().contains(host)) { - throw new UnknownHostException("Blocked access to IP: " + host); - } - - if (!this.ssrfProtectionConfig.isAllowInternalIp() && isInternalIp(host)) { - throw new UnknownHostException("Blocked access to internal IP: " + host); - } - if (!this.ssrfProtectionConfig.isAllowExternalIp() && !isInternalIp(host)) { - throw new UnknownHostException("Blocked access to external IP: " + host); + // Internally these results are cached for 30 seconds (by default) to prevent naive DNS rebinding + // It's important to fetch it from the cache before running checks and to not run resolution again. + // ( Otherwise this would make us vulnerable to high-frequency switching between valid-invalid addresses ) + InetAddress[] cachedResult = resolveAll(host); + + List result = new ArrayList<>(cachedResult.length); + try { + return ssrfProtectionConfig.getFilter().filter(cachedResult); + } catch (HostBlockedException e) { + // TODO(vaspori): log error as well, exception can't be chained + throw new UnknownHostException( + "Access to " + host + " was blocked because it violates the SSRF protection config"); } - - // Default behavior: allow if not banned and no allow rules are set - return InetAddress.getAllByName(host); } - private boolean isInternalIp(String host) { - // Implement your logic to determine if an IP is internal - // This is a simplified example, you might need more robust checks - return host.startsWith("10.") || host.startsWith("192.168.") || host.startsWith( - "172.16.") || host.startsWith("fd00:"); + // Address resolution moved to a helper function for testing purposes + protected InetAddress[] resolveAll(String host) throws UnknownHostException { + return InetAddress.getAllByName(host); } @Override public String resolveCanonicalHostname(String host) throws UnknownHostException { + //TODO(vaspori): implement properly return host; } } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/FilterUtils.java b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/FilterUtils.java new file mode 100644 index 00000000000..ac1fb6eef83 --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/FilterUtils.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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 com.google.springframework.security.web.ssrf; + +import java.net.InetAddress; + +public class FilterUtils { + + public static boolean isInternalIp(InetAddress addr) { + + if (addr.isLoopbackAddress()) { + return true; + } + + byte[] rawAddress = addr.getAddress(); + // there is sadly no Stream support for byte arrays + int[] iAddr = new int[rawAddress.length]; + for (int i = 0; i < rawAddress.length; i++) { + iAddr[i] = Byte.toUnsignedInt(rawAddress[i]); + } + // Ignoring Multicast addresses + if (addr.getAddress().length == 4) { + // IPv4 filtering + // 10.x.x.x , 192.168.x.x , 172.16.x.x + if (iAddr[0] == 10 || + (iAddr[0] == 192 && iAddr[1] == 168) || + (iAddr[0] == 172 && iAddr[1] == 16)) { + return true; + } + + } else if (addr.getAddress().length == 16) { + // IPv6 + if (iAddr[0] == 0xfd && iAddr[1] == 0x00) { + return true; + } + } + return false; + } +} diff --git a/ssrf/src/main/java/org/springframework/http/client/SecureHttpComponentsClientHttpRequestFactory.java b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/HostBlockedException.java similarity index 65% rename from ssrf/src/main/java/org/springframework/http/client/SecureHttpComponentsClientHttpRequestFactory.java rename to ssrf/src/main/java/com/google/springframework/security/web/ssrf/HostBlockedException.java index 0d81b9e9678..8b4c0a63325 100644 --- a/ssrf/src/main/java/org/springframework/http/client/SecureHttpComponentsClientHttpRequestFactory.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/HostBlockedException.java @@ -13,14 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.http.client; +package com.google.springframework.security.web.ssrf; +import java.io.IOException; -// TODO(vaspori): Document this properly -// Sadly all the needed classes are package private in org.springframework.http.client -// currently this is is the only way to override them. -public class SecureHttpComponentsClientHttpRequestFactory extends HttpComponentsClientHttpRequestFactory{ +public class HostBlockedException extends IOException { + private static final long serialVersionUID = 1; + public HostBlockedException(String message) { + super(message); + } + + public HostBlockedException() { + } } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/IpOrRange.java b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/IpOrRange.java new file mode 100644 index 00000000000..8201182acb8 --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/IpOrRange.java @@ -0,0 +1,80 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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 com.google.springframework.security.web.ssrf; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +/** + * Class to represent and IPv4 or IPv6 range to be used in filtering. Inspired by: + * org.springframework.security.web.util.matcher.IpAddressMatcher.java + */ +public class IpOrRange { + + private final InetAddress address; + private final int nMaskBits; + + public IpOrRange(String addressOrRange) { + if (addressOrRange.indexOf('/') > 0) { + String[] addressAndMask = addressOrRange.split("/"); + address = parseAddress(addressAndMask[0]); + this.nMaskBits = Integer.parseInt(addressAndMask[1]); + } else { + this.nMaskBits = -1; + address = parseAddress(addressOrRange); + } + } + + public boolean matches(InetAddress toCheck) { + + if (this.nMaskBits < 0) { + return toCheck.equals(this.address); + } + byte[] remAddr = toCheck.getAddress(); + byte[] reqAddr = this.address.getAddress(); + int nMaskFullBytes = this.nMaskBits / 8; + byte finalByte = (byte) (0xFF00 >> (this.nMaskBits & 0x07)); + for (int i = 0; i < nMaskFullBytes; i++) { + if (remAddr[i] != reqAddr[i]) { + return false; + } + } + if (finalByte != 0) { + return (remAddr[nMaskFullBytes] & finalByte) == (reqAddr[nMaskFullBytes] & finalByte); + } + return true; + + } + + private InetAddress parseAddress(String address) { + try { + if (address.matches(".*[a-zA-Z\\-].*$") && !address.contains(":")) { + // TODO(vaspori): log warning that the current address for the hostname is going to be used + } + return InetAddress.getByName(address); + } catch (UnknownHostException ex) { + throw new IllegalArgumentException("Failed to parse address '" + address + "'", ex); + } + } + + @Override + public String toString() { + return "IpOrRange{" + + "address=" + address + + ", nMaskBits=" + nMaskBits + + '}'; + } +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/ListedSsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/ListedSsrfProtectionFilter.java new file mode 100644 index 00000000000..6c7c45abf4d --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/ListedSsrfProtectionFilter.java @@ -0,0 +1,76 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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 com.google.springframework.security.web.ssrf; + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +public class ListedSsrfProtectionFilter implements SsrfProtectionFilter { + + /** + * FilterMode enum to make usage more intuitive ( practically this is just a bool ) + */ + public enum FilterMode { + BLOCK_LIST, + ALLOW_LIST, + } + + private List matchingRules; + + private FilterMode mode; + + public ListedSsrfProtectionFilter(List addressList, FilterMode mode) { + this.matchingRules = addressList; + this.mode = mode; + } + + @Override + public InetAddress[] filter(InetAddress[] addresses) throws HostBlockedException { + List result = new ArrayList<>(addresses.length); + + outerLoop: + for (InetAddress addr : addresses) { + if (mode == FilterMode.BLOCK_LIST) { + for (IpOrRange ipOrRange : matchingRules) { + if (ipOrRange.matches(addr)) { + continue outerLoop; + } + } + result.add(addr); + } else if (mode == FilterMode.ALLOW_LIST) { + for (IpOrRange ipOrRange : matchingRules) { + if (ipOrRange.matches(addr)) { + result.add(addr); + continue outerLoop; + } + } + } + } + + if (result.size() == 0) { + String addrFmt = Arrays.stream(addresses).map(a -> a.toString()).collect(Collectors.joining(", ")); + throw new HostBlockedException( + "The following address(es) were blocked due to violating " + mode.name() + " policy: " + addrFmt); + } + + return result.toArray(new InetAddress[]{}); + } + + +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SecureRestTemplateUtil.java b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SecureRestTemplateUtil.java new file mode 100644 index 00000000000..e9d672a7053 --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SecureRestTemplateUtil.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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 com.google.springframework.security.web.ssrf; + +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; +import org.apache.hc.client5.http.socket.ConnectionSocketFactory; +import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.core5.http.config.Registry; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +public class SecureRestTemplateUtil { + + public static RestTemplate makeSecureHC5Template(SsrfProtectionConfig config) { + + CustomDnsResolver dnsResolver = new CustomDnsResolver(config); + Registry registry = RegistryBuilder.create() + .register("http", PlainConnectionSocketFactory.getSocketFactory()) + .register("https", SSLConnectionSocketFactory.getSocketFactory()) + .build(); + + BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager( + registry, null, null, dnsResolver); + + CloseableHttpClient httpClient = HttpClientBuilder.create() + .setConnectionManager(connManager) + .build(); + + HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); + return new RestTemplate(requestFactory); + } +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionConfig.java b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionConfig.java index 43cc49629f5..c95db3f61d4 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionConfig.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionConfig.java @@ -13,48 +13,44 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.springframework.security.web.ssrf; - -import java.util.Collections; +import java.net.InetAddress; import java.util.List; public class SsrfProtectionConfig { - private List bannedIps = Collections.emptyList(); - private boolean allowInternalIp = false; // New config - private boolean allowExternalIp = false; // New config - - public SsrfProtectionConfig() { - } + private SsrfProtectionFilter filter; - public SsrfProtectionConfig(List bannedIps) { - this.bannedIps = bannedIps; + public SsrfProtectionConfig(SsrfProtectionFilter filter) { + this.filter = filter; } - public List getBannedIps() { - return this.bannedIps; + public static SsrfProtectionConfig makeBasicFilter(BasicSSRFProtectionFilter.FilterMode mode) { + return new SsrfProtectionConfig(new BasicSSRFProtectionFilter(mode)); } - public boolean isAllowInternalIp() { - return allowInternalIp; + public static SsrfProtectionConfig makeListedFilter(List addresses, + ListedSsrfProtectionFilter.FilterMode mode) { + return new SsrfProtectionConfig( + new ListedSsrfProtectionFilter(addresses.stream().map(IpOrRange::new).toList(), mode)); } - public void setAllowInternalIp(boolean allowInternalIp) { - this.allowInternalIp = allowInternalIp; - } + public static SsrfProtectionConfig defaultFilter(List addresses, + ListedSsrfProtectionFilter.FilterMode mode) { - public boolean isAllowExternalIp() { - return allowExternalIp; + // TODO(vaspori): use/parse system properties + return new SsrfProtectionConfig(new SsrfProtectionFilter() { + @Override + public InetAddress[] filter(InetAddress[] addresses) throws HostBlockedException { + return addresses; + } + }); } - public void setAllowExternalIp(boolean allowExternalIp) { - this.allowExternalIp = allowExternalIp; - } - public void setBannedIps(List bannedIps) { - this.bannedIps = bannedIps; + public SsrfProtectionFilter getFilter() { + return filter; } } diff --git a/ssrf/src/main/java/org/springframework/http/client/SecureJdkClientHttpConnector.java b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionFilter.java similarity index 63% rename from ssrf/src/main/java/org/springframework/http/client/SecureJdkClientHttpConnector.java rename to ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionFilter.java index 1014bb4f9da..b6223550acd 100644 --- a/ssrf/src/main/java/org/springframework/http/client/SecureJdkClientHttpConnector.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionFilter.java @@ -13,16 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.http.client; +package com.google.springframework.security.web.ssrf; -import org.springframework.http.client.reactive.JdkClientHttpConnector; +import java.net.InetAddress; -public class SecureJdkClientHttpConnector extends JdkClientHttpConnector { - public SecureJdkClientHttpConnector() { +public interface SsrfProtectionFilter { - throw new RuntimeException( - "This feature could not be implemented. The client lacks support for overriding address resolution."); - } + InetAddress[] filter(final InetAddress[] addresses) throws HostBlockedException; } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/BasicSSRFProtectionFilterTest.java b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/BasicSSRFProtectionFilterTest.java new file mode 100644 index 00000000000..48af77f0ef3 --- /dev/null +++ b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/BasicSSRFProtectionFilterTest.java @@ -0,0 +1,86 @@ +package com.google.springframework.security.web.ssrf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +public class BasicSSRFProtectionFilterTest { + + @Test + void testAllowInternalBlockExternal_internalAllowed() throws UnknownHostException, HostBlockedException { + BasicSSRFProtectionFilter filter = new BasicSSRFProtectionFilter( + BasicSSRFProtectionFilter.FilterMode.ALLOW_INTERNAL_BLOCK_EXTERNAL); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), + InetAddress.getByName("10.0.0.1")}; + InetAddress[] filtered = filter.filter(addresses); + assertEquals(2, filtered.length); + assertTrue(Arrays.asList(filtered).containsAll(List.of(addresses))); + } + + @Test + void testAllowInternalBlockExternal_externalBlocked() throws UnknownHostException, HostBlockedException { + BasicSSRFProtectionFilter filter = new BasicSSRFProtectionFilter( + BasicSSRFProtectionFilter.FilterMode.ALLOW_INTERNAL_BLOCK_EXTERNAL); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), + InetAddress.getByName("8.8.8.8")}; + InetAddress[] filtered = filter.filter(addresses); + assertEquals(1, filtered.length); + assertEquals(InetAddress.getByName("192.168.1.1"), filtered[0]); + } + + @Test + void testAllowInternalBlockExternal_allBlocked() throws UnknownHostException { + BasicSSRFProtectionFilter filter = new BasicSSRFProtectionFilter( + BasicSSRFProtectionFilter.FilterMode.ALLOW_INTERNAL_BLOCK_EXTERNAL); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("8.8.8.8"), InetAddress.getByName("1.1.1.1")}; + assertThrows(HostBlockedException.class, () -> filter.filter(addresses)); + } + + @Test + void testBlockInternalAllowExternal_internalBlocked() throws UnknownHostException, HostBlockedException { + BasicSSRFProtectionFilter filter = new BasicSSRFProtectionFilter( + BasicSSRFProtectionFilter.FilterMode.BLOCK_INTERNAL_ALLOW_EXTERNAL); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), + InetAddress.getByName("8.8.8.8")}; + InetAddress[] filtered = filter.filter(addresses); + assertEquals(1, filtered.length); + assertEquals(InetAddress.getByName("8.8.8.8"), filtered[0]); + } + + @Test + void testBlockInternalAllowExternal_externalAllowed() throws UnknownHostException, HostBlockedException { + BasicSSRFProtectionFilter filter = new BasicSSRFProtectionFilter( + BasicSSRFProtectionFilter.FilterMode.BLOCK_INTERNAL_ALLOW_EXTERNAL); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("8.8.8.8"), InetAddress.getByName("1.1.1.1")}; + InetAddress[] filtered = filter.filter(addresses); + assertEquals(2, filtered.length); + assertTrue(Arrays.asList(filtered).containsAll(List.of(addresses))); + } + + @Test + void testBlockInternalAllowExternal_allBlocked() throws UnknownHostException { + BasicSSRFProtectionFilter filter = new BasicSSRFProtectionFilter( + BasicSSRFProtectionFilter.FilterMode.BLOCK_INTERNAL_ALLOW_EXTERNAL); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), + InetAddress.getByName("10.0.0.1")}; + assertThrows(HostBlockedException.class, () -> filter.filter(addresses)); + } + + @Test + void testHostBlockedExceptionMessage() throws UnknownHostException { + BasicSSRFProtectionFilter filter = new BasicSSRFProtectionFilter( + BasicSSRFProtectionFilter.FilterMode.BLOCK_INTERNAL_ALLOW_EXTERNAL); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), + InetAddress.getByName("10.0.0.1")}; + HostBlockedException exception = assertThrows(HostBlockedException.class, () -> filter.filter(addresses)); + assertTrue(exception.getMessage().contains("192.168.1.1")); + assertTrue(exception.getMessage().contains("10.0.0.1")); + assertTrue(exception.getMessage().contains("BLOCK_INTERNAL_ALLOW_EXTERNAL")); + } +} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/CustomDnsResolverTest.java b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/CustomDnsResolverTest.java index cf589198f94..b9e3bc38ac7 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/CustomDnsResolverTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/CustomDnsResolverTest.java @@ -1,17 +1,79 @@ package com.google.springframework.security.web.ssrf; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; +import java.net.InetAddress; import java.net.UnknownHostException; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class CustomDnsResolverTest { + + @Mock + private SsrfProtectionConfig ssrfProtectionConfig; + + @Mock + private SsrfProtectionFilter ssrfProtectionFilter; + + + static class TestableCustomDnsResolver extends CustomDnsResolver { + + InetAddress[] addressesToReturn = null; + + public TestableCustomDnsResolver(SsrfProtectionConfig ssrfProtectionConfig) { + super(ssrfProtectionConfig); + } + + @Override + protected InetAddress[] resolveAll(String host) throws UnknownHostException { + return addressesToReturn; + } + } + + @InjectMocks + private TestableCustomDnsResolver customDnsResolver; + + @Test + void testResolve_validHost() throws UnknownHostException, HostBlockedException { + String host = "www.example.com"; + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("93.184.216.34")}; + when(ssrfProtectionConfig.getFilter()).thenReturn(ssrfProtectionFilter); + when(ssrfProtectionFilter.filter(addresses)).thenReturn(addresses); + customDnsResolver.addressesToReturn = addresses; + + InetAddress[] resolvedAddresses = customDnsResolver.resolve(host); + + assertEquals(1, resolvedAddresses.length); + assertEquals(addresses[0], resolvedAddresses[0]); + } + + @Test + void testResolve_blockedHost() throws UnknownHostException, HostBlockedException { + String host = "192.168.1.1"; + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName(host)}; + when(ssrfProtectionConfig.getFilter()).thenReturn(ssrfProtectionFilter); + when(ssrfProtectionFilter.filter(addresses)).thenThrow(new HostBlockedException("Blocked")); + + customDnsResolver.addressesToReturn = addresses; + + UnknownHostException exception = assertThrows(UnknownHostException.class, + () -> customDnsResolver.resolve(host)); + assertTrue(exception.getMessage().contains("blocked")); + + } -class CustomDnsResolverTest { @Test - void resolve() throws UnknownHostException { - SsrfProtectionConfig config = new SsrfProtectionConfig(); - config.setAllowExternalIp(true); - CustomDnsResolver t = new CustomDnsResolver(config); - t.resolve("8.8.8.8"); + void testResolveCanonicalHostname() throws UnknownHostException { + String host = "www.example.com"; + String resolvedHostname = customDnsResolver.resolveCanonicalHostname(host); + assertEquals(host, resolvedHostname); // Since the method is not fully implemented yet } } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/FilterUtilsTest.java b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/FilterUtilsTest.java new file mode 100644 index 00000000000..687b37376aa --- /dev/null +++ b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/FilterUtilsTest.java @@ -0,0 +1,54 @@ +package com.google.springframework.security.web.ssrf; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import org.junit.jupiter.api.Test; + +class FilterUtilsTest { + + @Test + public void testIsInternalIp_loopback() throws UnknownHostException { + InetAddress addr = InetAddress.getByName("127.0.0.1"); + assertTrue(FilterUtils.isInternalIp(addr)); + } + + @Test + public void testIsInternalIp_ipv4_10x() throws UnknownHostException { + InetAddress addr = InetAddress.getByName("10.1.2.3"); + assertTrue(FilterUtils.isInternalIp(addr)); + } + + @Test + public void testIsInternalIp_ipv4_192x() throws UnknownHostException { + InetAddress addr = InetAddress.getByName("192.168.10.20"); + assertTrue(FilterUtils.isInternalIp(addr)); + } + + @Test + public void testIsInternalIp_ipv4_172x() throws UnknownHostException { + InetAddress addr = InetAddress.getByName("172.16.0.1"); + assertTrue(FilterUtils.isInternalIp(addr)); + } + + @Test + public void testIsInternalIp_ipv6() throws UnknownHostException { + InetAddress addr = InetAddress.getByName("fd00::1"); + assertTrue(FilterUtils.isInternalIp(addr)); + } + + @Test + public void testIsInternalIp_publicIpv4() throws UnknownHostException { + InetAddress addr = InetAddress.getByName("8.8.8.8"); + assertFalse(FilterUtils.isInternalIp(addr)); + } + + @Test + public void testIsInternalIp_publicIpv6() throws UnknownHostException { + InetAddress addr = InetAddress.getByName("2001:4860:4860::8888"); + assertFalse(FilterUtils.isInternalIp(addr)); + } + +} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/IpOrRangeTest.java b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/IpOrRangeTest.java new file mode 100644 index 00000000000..115d72daeb1 --- /dev/null +++ b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/IpOrRangeTest.java @@ -0,0 +1,88 @@ +package com.google.springframework.security.web.ssrf; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import org.junit.jupiter.api.Test; + +public class IpOrRangeTest { + + @Test + public void testSingleIpMatch() throws UnknownHostException { + IpOrRange ipOrRange = new IpOrRange("192.168.1.1"); + InetAddress address = InetAddress.getByName("192.168.1.1"); + assertTrue(ipOrRange.matches(address)); + } + + @Test + public void testSingleIpMismatch() throws UnknownHostException { + IpOrRange ipOrRange = new IpOrRange("192.168.1.1"); + InetAddress address = InetAddress.getByName("192.168.1.2"); + assertFalse(ipOrRange.matches(address)); + } + + @Test + public void testCidrMatch() throws UnknownHostException { + IpOrRange ipOrRange = new IpOrRange("192.168.1.0/24"); + InetAddress address1 = InetAddress.getByName("192.168.1.1"); + InetAddress address2 = InetAddress.getByName("192.168.1.100"); + assertTrue(ipOrRange.matches(address1)); + assertTrue(ipOrRange.matches(address2)); + } + + @Test + public void testCidrMismatch() throws UnknownHostException { + IpOrRange ipOrRange = new IpOrRange("192.168.1.0/24"); + InetAddress address = InetAddress.getByName("192.168.2.1"); + assertFalse(ipOrRange.matches(address)); + } + + @Test + public void testCidrMatch_subnet() throws UnknownHostException { + IpOrRange ipOrRange = new IpOrRange("10.0.0.0/8"); + InetAddress address = InetAddress.getByName("10.10.10.10"); + assertTrue(ipOrRange.matches(address)); + } + + @Test + public void testCidrMismatch_subnet() throws UnknownHostException { + IpOrRange ipOrRange = new IpOrRange("10.0.0.0/16"); + InetAddress address = InetAddress.getByName("10.1.10.10"); + assertFalse(ipOrRange.matches(address)); + } + + @Test + public void testInvalidAddress() { + Exception ex = assertThrows(IllegalArgumentException.class, () -> + new IpOrRange("invalid address"), "Exception not triggered"); + + assertTrue(ex.getMessage().contains("Failed to parse address")); + } + + @Test + public void testHostname() throws UnknownHostException { + // Assuming "localhost" resolves to 127.0.0.1 + IpOrRange ipOrRange = new IpOrRange("localhost"); + InetAddress address = InetAddress.getByName("127.0.0.1"); + assertTrue(ipOrRange.matches(address)); + } + + @Test + public void testIpv6SingleIpMatch() throws UnknownHostException { + IpOrRange ipOrRange = new IpOrRange("2001:db8::1"); + InetAddress address = InetAddress.getByName("2001:db8::1"); + assertTrue(ipOrRange.matches(address)); + } + + @Test + public void testIpv6CidrMatch() throws UnknownHostException { + IpOrRange ipOrRange = new IpOrRange("2001:db8::/32"); + InetAddress address = InetAddress.getByName("2001:db8:1::1"); + assertTrue(ipOrRange.matches(address)); + } + +} + diff --git a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/ListedSsrfProtectionFilterTest.java b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/ListedSsrfProtectionFilterTest.java new file mode 100644 index 00000000000..3e42baf0388 --- /dev/null +++ b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/ListedSsrfProtectionFilterTest.java @@ -0,0 +1,94 @@ +package com.google.springframework.security.web.ssrf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +public class ListedSsrfProtectionFilterTest { + + @Test + void testBlockList_blockedAddress() throws UnknownHostException, HostBlockedException { + List blockList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); + ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, + ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), + InetAddress.getByName("8.8.8.8")}; + InetAddress[] filtered = filter.filter(addresses); + assertEquals(1, filtered.length); + assertEquals(InetAddress.getByName("8.8.8.8"), filtered[0]); + } + + @Test + void testBlockList_allowedAddress() throws UnknownHostException, HostBlockedException { + List blockList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); + ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, + ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.2"), + InetAddress.getByName("8.8.8.8")}; + InetAddress[] filtered = filter.filter(addresses); + assertEquals(2, filtered.length); + assertTrue(Arrays.asList(filtered).containsAll(List.of(addresses))); + } + + @Test + void testBlockList_allBlocked() throws UnknownHostException { + List blockList = List.of(new IpOrRange("192.168.1.0/24"), new IpOrRange("8.8.8.8")); + ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, + ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), + InetAddress.getByName("8.8.8.8")}; + assertThrows(HostBlockedException.class, () -> filter.filter(addresses)); + } + + @Test + void testAllowList_allowedAddress() throws UnknownHostException, HostBlockedException { + List allowList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); + ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(allowList, + ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), + InetAddress.getByName("8.8.8.8")}; + InetAddress[] filtered = filter.filter(addresses); + assertEquals(1, filtered.length); + assertEquals(InetAddress.getByName("192.168.1.1"), filtered[0]); + } + + @Test + void testAllowList_blockedAddress() throws UnknownHostException, HostBlockedException { + List allowList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); + ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(allowList, + ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.200"), + InetAddress.getByName("8.8.8.8")}; + HostBlockedException ex = assertThrows(HostBlockedException.class, + () -> filter.filter(addresses), "This should throw an exception"); + assertTrue(ex.getMessage().contains("blocked due to violating ALLOW_LIST")); + + } + + @Test + void testAllowList_allBlocked() throws UnknownHostException { + List allowList = List.of(new IpOrRange("172.16.0.0/16")); + ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(allowList, + ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), + InetAddress.getByName("8.8.8.8")}; + assertThrows(HostBlockedException.class, () -> filter.filter(addresses)); + } + + @Test + void testHostBlockedExceptionMessage() throws UnknownHostException { + List blockList = List.of(new IpOrRange("192.168.1.0/24")); + ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, + ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1")}; + HostBlockedException exception = assertThrows(HostBlockedException.class, () -> filter.filter(addresses)); + assertTrue(exception.getMessage().contains("192.168.1.1")); + assertTrue(exception.getMessage().contains("BLOCK_LIST")); + } +} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/UsageExample.java b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/UsageExample.java new file mode 100644 index 00000000000..d6b02489bd8 --- /dev/null +++ b/ssrf/src/test/java/com/google/springframework/security/web/ssrf/UsageExample.java @@ -0,0 +1,23 @@ +package com.google.springframework.security.web.ssrf; + +import com.google.springframework.security.web.ssrf.BasicSSRFProtectionFilter.FilterMode; +import org.springframework.web.client.RestTemplate; + +public class UsageExample { + + public static void main(String[] args) { + RestTemplate exampleTemplate = SecureRestTemplateUtil.makeSecureHC5Template( + SsrfProtectionConfig.makeBasicFilter( + FilterMode.ALLOW_INTERNAL_BLOCK_EXTERNAL)); + + try { + exampleTemplate.getForEntity("https://google.com", String.class); + } catch (Exception e) { + System.err.println("Access blocked: " + e.getMessage()); + } + + // This should print: + // Access blocked: I/O error on GET request for "https://google.com": Access to google.com was blocked because it violates the SSRF protection config + } + +} diff --git a/ssrf/src/test/java/org/springframework/http/client/SecureJdkClientHttpConnectorTest.java b/ssrf/src/test/java/org/springframework/http/client/SecureJdkClientHttpConnectorTest.java deleted file mode 100644 index 5e704ab6d11..00000000000 --- a/ssrf/src/test/java/org/springframework/http/client/SecureJdkClientHttpConnectorTest.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.springframework.http.client; - -import static org.junit.jupiter.api.Assertions.*; - -import org.junit.jupiter.api.Test; - -class SecureJdkClientHttpConnectorTest { - - @Test - void checkThatItFails(){ - - - } - -} From 43540c7cf90377e7998ba973c7862ac039064488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Wed, 6 Nov 2024 16:11:36 +0100 Subject: [PATCH 04/34] First version of the README created --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000000..74225245527 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# SSRF Protected `RestTemplate` Library + +This library provides a framework for preventing Server-Side Request Forgery (SSRF) vulnerabilities in Java applications. It allows you to define and enforce rules for restricting which hosts, IP addresses and address ranges can be accessed by your application. + +## Features + +* **Flexible filtering:** Supports different filtering modes, including: + * **Basic filtering:** Allow or block internal/external IP addresses. + * **List-based filtering:** Allow or block specific IP addresses and ranges. +* **Customizable:** Easily integrate with your existing DNS resolution mechanism. +* **Extensible:** Create your own custom filters to implement specific SSRF protection logic. + +## Limitations + +This is the first iteration of the library. Currently the `RestTemplate` is backed by an Apache Commons 5 HttpClient. + +## Usage +```java +RestTemplate exampleTemplate = SecureRestTemplateUtil.makeSecureHC5Template( + SsrfProtectionConfig.makeBasicFilter( + FilterMode.ALLOW_INTERNAL_BLOCK_EXTERNAL)); + +try { + exampleTemplate.getForEntity("https://google.com", String.class); +} catch (Exception e) { + System.err.println("Access blocked: " + e.getMessage()); +} +``` From b890b825ec8d7961854ff5ffb09290e26746df99 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Wed, 20 Nov 2024 13:03:00 +0000 Subject: [PATCH 05/34] Initial, minor updates (#1) * Upgrade to more current version of Gradle * Rename ssrf -> client package If this is indeed for client side use only, then it should be under a client package. Whether there should be a further ssrf remains to be seen depending on what other security feature are added, and how they are configured. * Rename CustomDnsResolver to SsrfDnsResolver --- gradle/wrapper/gradle-wrapper.properties | 2 +- .../{ssrf => client}/BasicSSRFProtectionFilter.java | 2 +- .../security/web/{ssrf => client}/FilterUtils.java | 2 +- .../web/{ssrf => client}/HostBlockedException.java | 2 +- .../security/web/{ssrf => client}/IpOrRange.java | 2 +- .../{ssrf => client}/ListedSsrfProtectionFilter.java | 2 +- .../web/{ssrf => client}/SecureRestTemplateUtil.java | 4 ++-- .../SsrfDnsResolver.java} | 6 +++--- .../web/{ssrf => client}/SsrfProtectionConfig.java | 2 +- .../web/{ssrf => client}/SsrfProtectionFilter.java | 2 +- .../BasicSSRFProtectionFilterTest.java | 2 +- .../security/web/{ssrf => client}/FilterUtilsTest.java | 2 +- .../security/web/{ssrf => client}/IpOrRangeTest.java | 2 +- .../ListedSsrfProtectionFilterTest.java | 2 +- .../SsrfDnsResolverTest.java} | 10 +++++----- .../security/web/{ssrf => client}/UsageExample.java | 4 ++-- 16 files changed, 24 insertions(+), 24 deletions(-) rename ssrf/src/main/java/com/google/springframework/security/web/{ssrf => client}/BasicSSRFProtectionFilter.java (97%) rename ssrf/src/main/java/com/google/springframework/security/web/{ssrf => client}/FilterUtils.java (96%) rename ssrf/src/main/java/com/google/springframework/security/web/{ssrf => client}/HostBlockedException.java (93%) rename ssrf/src/main/java/com/google/springframework/security/web/{ssrf => client}/IpOrRange.java (97%) rename ssrf/src/main/java/com/google/springframework/security/web/{ssrf => client}/ListedSsrfProtectionFilter.java (97%) rename ssrf/src/main/java/com/google/springframework/security/web/{ssrf => client}/SecureRestTemplateUtil.java (94%) rename ssrf/src/main/java/com/google/springframework/security/web/{ssrf/CustomDnsResolver.java => client/SsrfDnsResolver.java} (92%) rename ssrf/src/main/java/com/google/springframework/security/web/{ssrf => client}/SsrfProtectionConfig.java (96%) rename ssrf/src/main/java/com/google/springframework/security/web/{ssrf => client}/SsrfProtectionFilter.java (93%) rename ssrf/src/test/java/com/google/springframework/security/web/{ssrf => client}/BasicSSRFProtectionFilterTest.java (98%) rename ssrf/src/test/java/com/google/springframework/security/web/{ssrf => client}/FilterUtilsTest.java (96%) rename ssrf/src/test/java/com/google/springframework/security/web/{ssrf => client}/IpOrRangeTest.java (98%) rename ssrf/src/test/java/com/google/springframework/security/web/{ssrf => client}/ListedSsrfProtectionFilterTest.java (98%) rename ssrf/src/test/java/com/google/springframework/security/web/{ssrf/CustomDnsResolverTest.java => client/SsrfDnsResolverTest.java} (88%) rename ssrf/src/test/java/com/google/springframework/security/web/{ssrf => client}/UsageExample.java (81%) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae04661ee73..1e2fbf0d458 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/BasicSSRFProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSSRFProtectionFilter.java similarity index 97% rename from ssrf/src/main/java/com/google/springframework/security/web/ssrf/BasicSSRFProtectionFilter.java rename to ssrf/src/main/java/com/google/springframework/security/web/client/BasicSSRFProtectionFilter.java index dfe5ea36af6..2de8512d181 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/BasicSSRFProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSSRFProtectionFilter.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.springframework.security.web.ssrf; +package com.google.springframework.security.web.client; import java.net.InetAddress; import java.util.ArrayList; diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/FilterUtils.java b/ssrf/src/main/java/com/google/springframework/security/web/client/FilterUtils.java similarity index 96% rename from ssrf/src/main/java/com/google/springframework/security/web/ssrf/FilterUtils.java rename to ssrf/src/main/java/com/google/springframework/security/web/client/FilterUtils.java index ac1fb6eef83..0b29741f92d 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/FilterUtils.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/FilterUtils.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.springframework.security.web.ssrf; +package com.google.springframework.security.web.client; import java.net.InetAddress; diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/HostBlockedException.java b/ssrf/src/main/java/com/google/springframework/security/web/client/HostBlockedException.java similarity index 93% rename from ssrf/src/main/java/com/google/springframework/security/web/ssrf/HostBlockedException.java rename to ssrf/src/main/java/com/google/springframework/security/web/client/HostBlockedException.java index 8b4c0a63325..00463436197 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/HostBlockedException.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/HostBlockedException.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.springframework.security.web.ssrf; +package com.google.springframework.security.web.client; import java.io.IOException; diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/IpOrRange.java b/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java similarity index 97% rename from ssrf/src/main/java/com/google/springframework/security/web/ssrf/IpOrRange.java rename to ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java index 8201182acb8..049f52c6282 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/IpOrRange.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.springframework.security.web.ssrf; +package com.google.springframework.security.web.client; import java.net.InetAddress; import java.net.UnknownHostException; diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/ListedSsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java similarity index 97% rename from ssrf/src/main/java/com/google/springframework/security/web/ssrf/ListedSsrfProtectionFilter.java rename to ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java index 6c7c45abf4d..f3b2dddb56d 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/ListedSsrfProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.springframework.security.web.ssrf; +package com.google.springframework.security.web.client; import java.net.InetAddress; import java.util.ArrayList; diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SecureRestTemplateUtil.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplateUtil.java similarity index 94% rename from ssrf/src/main/java/com/google/springframework/security/web/ssrf/SecureRestTemplateUtil.java rename to ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplateUtil.java index e9d672a7053..4710cc83518 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SecureRestTemplateUtil.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplateUtil.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.springframework.security.web.ssrf; +package com.google.springframework.security.web.client; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; @@ -30,7 +30,7 @@ public class SecureRestTemplateUtil { public static RestTemplate makeSecureHC5Template(SsrfProtectionConfig config) { - CustomDnsResolver dnsResolver = new CustomDnsResolver(config); + SsrfDnsResolver dnsResolver = new SsrfDnsResolver(config); Registry registry = RegistryBuilder.create() .register("http", PlainConnectionSocketFactory.getSocketFactory()) .register("https", SSLConnectionSocketFactory.getSocketFactory()) diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/CustomDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java similarity index 92% rename from ssrf/src/main/java/com/google/springframework/security/web/ssrf/CustomDnsResolver.java rename to ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java index 4ad7f3a805a..6f3cddd321e 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/CustomDnsResolver.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.springframework.security.web.ssrf; +package com.google.springframework.security.web.client; import java.net.InetAddress; import java.net.UnknownHostException; @@ -21,11 +21,11 @@ import java.util.List; import org.apache.hc.client5.http.DnsResolver; -class CustomDnsResolver implements DnsResolver { +class SsrfDnsResolver implements DnsResolver { private final SsrfProtectionConfig ssrfProtectionConfig; - public CustomDnsResolver(SsrfProtectionConfig ssrfProtectionConfig) { + public SsrfDnsResolver(SsrfProtectionConfig ssrfProtectionConfig) { this.ssrfProtectionConfig = ssrfProtectionConfig; } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionConfig.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionConfig.java similarity index 96% rename from ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionConfig.java rename to ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionConfig.java index c95db3f61d4..5400a47878d 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionConfig.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionConfig.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.springframework.security.web.ssrf; +package com.google.springframework.security.web.client; import java.net.InetAddress; import java.util.List; diff --git a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java similarity index 93% rename from ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionFilter.java rename to ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java index b6223550acd..de75a1c1f80 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/ssrf/SsrfProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.springframework.security.web.ssrf; +package com.google.springframework.security.web.client; import java.net.InetAddress; diff --git a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/BasicSSRFProtectionFilterTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSSRFProtectionFilterTest.java similarity index 98% rename from ssrf/src/test/java/com/google/springframework/security/web/ssrf/BasicSSRFProtectionFilterTest.java rename to ssrf/src/test/java/com/google/springframework/security/web/client/BasicSSRFProtectionFilterTest.java index 48af77f0ef3..1000c5d9431 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/BasicSSRFProtectionFilterTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSSRFProtectionFilterTest.java @@ -1,4 +1,4 @@ -package com.google.springframework.security.web.ssrf; +package com.google.springframework.security.web.client; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; diff --git a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/FilterUtilsTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/FilterUtilsTest.java similarity index 96% rename from ssrf/src/test/java/com/google/springframework/security/web/ssrf/FilterUtilsTest.java rename to ssrf/src/test/java/com/google/springframework/security/web/client/FilterUtilsTest.java index 687b37376aa..80c9bcfff50 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/FilterUtilsTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/FilterUtilsTest.java @@ -1,4 +1,4 @@ -package com.google.springframework.security.web.ssrf; +package com.google.springframework.security.web.client; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; diff --git a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/IpOrRangeTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/IpOrRangeTest.java similarity index 98% rename from ssrf/src/test/java/com/google/springframework/security/web/ssrf/IpOrRangeTest.java rename to ssrf/src/test/java/com/google/springframework/security/web/client/IpOrRangeTest.java index 115d72daeb1..d0ce0a0346a 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/IpOrRangeTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/IpOrRangeTest.java @@ -1,4 +1,4 @@ -package com.google.springframework.security.web.ssrf; +package com.google.springframework.security.web.client; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; diff --git a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/ListedSsrfProtectionFilterTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java similarity index 98% rename from ssrf/src/test/java/com/google/springframework/security/web/ssrf/ListedSsrfProtectionFilterTest.java rename to ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java index 3e42baf0388..2625a9dd189 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/ListedSsrfProtectionFilterTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java @@ -1,4 +1,4 @@ -package com.google.springframework.security.web.ssrf; +package com.google.springframework.security.web.client; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; diff --git a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/CustomDnsResolverTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java similarity index 88% rename from ssrf/src/test/java/com/google/springframework/security/web/ssrf/CustomDnsResolverTest.java rename to ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java index b9e3bc38ac7..2c956f6808d 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/CustomDnsResolverTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java @@ -1,4 +1,4 @@ -package com.google.springframework.security.web.ssrf; +package com.google.springframework.security.web.client; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -14,7 +14,7 @@ import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) -public class CustomDnsResolverTest { +public class SsrfDnsResolverTest { @Mock private SsrfProtectionConfig ssrfProtectionConfig; @@ -23,11 +23,11 @@ public class CustomDnsResolverTest { private SsrfProtectionFilter ssrfProtectionFilter; - static class TestableCustomDnsResolver extends CustomDnsResolver { + static class TestableSsrfDnsResolver extends SsrfDnsResolver { InetAddress[] addressesToReturn = null; - public TestableCustomDnsResolver(SsrfProtectionConfig ssrfProtectionConfig) { + public TestableSsrfDnsResolver(SsrfProtectionConfig ssrfProtectionConfig) { super(ssrfProtectionConfig); } @@ -38,7 +38,7 @@ protected InetAddress[] resolveAll(String host) throws UnknownHostException { } @InjectMocks - private TestableCustomDnsResolver customDnsResolver; + private TestableSsrfDnsResolver customDnsResolver; @Test void testResolve_validHost() throws UnknownHostException, HostBlockedException { diff --git a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/UsageExample.java b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java similarity index 81% rename from ssrf/src/test/java/com/google/springframework/security/web/ssrf/UsageExample.java rename to ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java index d6b02489bd8..6ace7d23cc7 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/ssrf/UsageExample.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java @@ -1,6 +1,6 @@ -package com.google.springframework.security.web.ssrf; +package com.google.springframework.security.web.client; -import com.google.springframework.security.web.ssrf.BasicSSRFProtectionFilter.FilterMode; +import com.google.springframework.security.web.client.BasicSSRFProtectionFilter.FilterMode; import org.springframework.web.client.RestTemplate; public class UsageExample { From 48c35abe5aff97c3f7c751ebdf9f9bfb0bd553f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Wed, 20 Nov 2024 14:49:10 +0100 Subject: [PATCH 06/34] TODOs resolved: logging added and cannonical hostname resolution implemented --- .../security/web/client/IpOrRange.java | 9 ++++++-- .../security/web/client/SsrfDnsResolver.java | 21 +++++++++++++------ .../web/client/SsrfProtectionConfig.java | 8 +------ 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java b/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java index 049f52c6282..55170006710 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java @@ -17,6 +17,8 @@ import java.net.InetAddress; import java.net.UnknownHostException; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; /** * Class to represent and IPv4 or IPv6 range to be used in filtering. Inspired by: @@ -24,6 +26,7 @@ */ public class IpOrRange { + private static final Log logger = LogFactory.getLog(IpOrRange.class); private final InetAddress address; private final int nMaskBits; @@ -61,10 +64,12 @@ public boolean matches(InetAddress toCheck) { private InetAddress parseAddress(String address) { try { + InetAddress result = InetAddress.getByName(address); if (address.matches(".*[a-zA-Z\\-].*$") && !address.contains(":")) { - // TODO(vaspori): log warning that the current address for the hostname is going to be used + logger.warn("Hostname '" + address + "' resolved to " + result.toString() + + " will be used on IP address matching"); } - return InetAddress.getByName(address); + return result; } catch (UnknownHostException ex) { throw new IllegalArgumentException("Failed to parse address '" + address + "'", ex); } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java index 6f3cddd321e..95efd8fd0d2 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java @@ -17,12 +17,14 @@ import java.net.InetAddress; import java.net.UnknownHostException; -import java.util.ArrayList; -import java.util.List; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.apache.hc.client5.http.DnsResolver; class SsrfDnsResolver implements DnsResolver { + + private static final Log logger = LogFactory.getLog(SsrfDnsResolver.class); private final SsrfProtectionConfig ssrfProtectionConfig; public SsrfDnsResolver(SsrfProtectionConfig ssrfProtectionConfig) { @@ -37,11 +39,11 @@ public InetAddress[] resolve(final String host) throws UnknownHostException { // ( Otherwise this would make us vulnerable to high-frequency switching between valid-invalid addresses ) InetAddress[] cachedResult = resolveAll(host); - List result = new ArrayList<>(cachedResult.length); try { return ssrfProtectionConfig.getFilter().filter(cachedResult); } catch (HostBlockedException e) { - // TODO(vaspori): log error as well, exception can't be chained + // log error as well, exception can't be chained + logger.error("DNS resolution for '" + host + "' resulted in error", e); throw new UnknownHostException( "Access to " + host + " was blocked because it violates the SSRF protection config"); } @@ -54,7 +56,14 @@ protected InetAddress[] resolveAll(String host) throws UnknownHostException { @Override public String resolveCanonicalHostname(String host) throws UnknownHostException { - //TODO(vaspori): implement properly - return host; + if (host == null) { + return null; + } + final InetAddress in = InetAddress.getByName(host); + final String canonicalServer = in.getCanonicalHostName(); + if (in.getHostAddress().contentEquals(canonicalServer)) { + return host; + } + return canonicalServer; } } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionConfig.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionConfig.java index 5400a47878d..ce8a1722f98 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionConfig.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionConfig.java @@ -15,7 +15,6 @@ */ package com.google.springframework.security.web.client; -import java.net.InetAddress; import java.util.List; @@ -41,12 +40,7 @@ public static SsrfProtectionConfig defaultFilter(List addresses, ListedSsrfProtectionFilter.FilterMode mode) { // TODO(vaspori): use/parse system properties - return new SsrfProtectionConfig(new SsrfProtectionFilter() { - @Override - public InetAddress[] filter(InetAddress[] addresses) throws HostBlockedException { - return addresses; - } - }); + throw new UnsupportedOperationException("This feature has not yet been implemented."); } From b5ef00d7d1651be7abd996bfbf8929912ad23c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Mon, 25 Nov 2024 11:33:05 +0100 Subject: [PATCH 07/34] first iteration for build and release actions added --- .github/workflows/build.yaml | 11 ++++++++++ .github/workflows/release.yaml | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 .github/workflows/build.yaml create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 00000000000..2b128022b44 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,11 @@ +name: CI +on: + push: + branches: [ main ] +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check out main branch + uses: actions/checkout@v4 + - run: gradle build diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000000..ea0f00a20f8 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,37 @@ +on: + push: + # Sequence of patterns matched against refs/tags + tags: + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + +name: Upload Release Asset + +jobs: + build: + name: Upload Release Asset + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Build project # This would actually build your project, using zip for an example artifact + run: gradle build + - name: Create Release # TODO: enable write permission for workflows in settings + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false + - name: Upload Release Asset + id: upload-release-asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./ssrf/build/libs/ssrf.jar + asset_name: spring-ssrh-hardening.jar + asset_content_type: application/jar From 5be871ec7086eb1cf1744f79b9e4e9977d28538d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Mon, 25 Nov 2024 17:23:00 +0100 Subject: [PATCH 08/34] adding support for system property based default --- .../web/client/SecureRestTemplateUtil.java | 15 +++++-- .../web/client/SsrfProtectionConfig.java | 44 +++++++++++++++++-- .../security/web/client/UsageExample.java | 16 ++++++- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplateUtil.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplateUtil.java index 4710cc83518..1c8cb91fc48 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplateUtil.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplateUtil.java @@ -28,9 +28,7 @@ public class SecureRestTemplateUtil { - public static RestTemplate makeSecureHC5Template(SsrfProtectionConfig config) { - - SsrfDnsResolver dnsResolver = new SsrfDnsResolver(config); + static RestTemplate buildWithResolver(SsrfDnsResolver dnsResolver) { Registry registry = RegistryBuilder.create() .register("http", PlainConnectionSocketFactory.getSocketFactory()) .register("https", SSLConnectionSocketFactory.getSocketFactory()) @@ -46,4 +44,15 @@ public static RestTemplate makeSecureHC5Template(SsrfProtectionConfig config) { HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); return new RestTemplate(requestFactory); } + + public static RestTemplate makeSecureHC5Template(SsrfProtectionConfig config) { + SsrfDnsResolver dnsResolver = new SsrfDnsResolver(config); + return buildWithResolver(dnsResolver); + } + + public static RestTemplate makeHC5Default() { + SsrfDnsResolver dnsResolver = new SsrfDnsResolver(SsrfProtectionConfig.defaultFilter()); + return buildWithResolver(dnsResolver); + } + } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionConfig.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionConfig.java index ce8a1722f98..2829acd4eb0 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionConfig.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionConfig.java @@ -15,11 +15,24 @@ */ package com.google.springframework.security.web.client; +import com.google.springframework.security.web.client.ListedSsrfProtectionFilter.FilterMode; +import java.util.Arrays; import java.util.List; public class SsrfProtectionConfig { + + /** + * Helper enum to make configuring with system properties easier + */ + enum ProtectionMode { + ALLOW_LIST, + DENY_LIST, + ALLOW_INTERNAL, + ALLOW_EXTERNAL, + } + private SsrfProtectionFilter filter; public SsrfProtectionConfig(SsrfProtectionFilter filter) { @@ -36,11 +49,34 @@ public static SsrfProtectionConfig makeListedFilter(List addresses, new ListedSsrfProtectionFilter(addresses.stream().map(IpOrRange::new).toList(), mode)); } - public static SsrfProtectionConfig defaultFilter(List addresses, - ListedSsrfProtectionFilter.FilterMode mode) { + public static SsrfProtectionConfig defaultFilter() { + String modeProperty = System.getProperty("ssrf.protection.mode"); + + SsrfProtectionFilter filter = null; + + if (modeProperty == null) { + throw new IllegalStateException("ssrf.protection.mode is not set but defaultFilter() requested"); + } + ProtectionMode mode = ProtectionMode.valueOf(modeProperty.toUpperCase()); + + if (mode == ProtectionMode.ALLOW_LIST || mode == ProtectionMode.DENY_LIST) { + String ipList = System.getProperty("ssrf.protection.iplist"); + if (ipList == null) { + throw new IllegalStateException( + "ssrf.protection.iplist is required for ALLOW_LIST or DENY_LIST modes in comma separated CIDR format"); + } + FilterMode filterMode = (mode == ProtectionMode.ALLOW_LIST ? FilterMode.ALLOW_LIST : FilterMode.BLOCK_LIST); + filter = new ListedSsrfProtectionFilter( + Arrays.stream(ipList.strip().split(",")).map(IpOrRange::new).toList(), filterMode); + } + if (mode == ProtectionMode.ALLOW_EXTERNAL || mode == ProtectionMode.ALLOW_INTERNAL) { + BasicSSRFProtectionFilter.FilterMode filterMode = (mode == ProtectionMode.ALLOW_EXTERNAL + ? BasicSSRFProtectionFilter.FilterMode.BLOCK_INTERNAL_ALLOW_EXTERNAL : + BasicSSRFProtectionFilter.FilterMode.ALLOW_INTERNAL_BLOCK_EXTERNAL); + filter = new BasicSSRFProtectionFilter(filterMode); + } - // TODO(vaspori): use/parse system properties - throw new UnsupportedOperationException("This feature has not yet been implemented."); + return new SsrfProtectionConfig(filter); } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java index 6ace7d23cc7..f6cf19c9d18 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java @@ -5,7 +5,8 @@ public class UsageExample { - public static void main(String[] args) { + + public static void example2() { RestTemplate exampleTemplate = SecureRestTemplateUtil.makeSecureHC5Template( SsrfProtectionConfig.makeBasicFilter( FilterMode.ALLOW_INTERNAL_BLOCK_EXTERNAL)); @@ -18,6 +19,19 @@ public static void main(String[] args) { // This should print: // Access blocked: I/O error on GET request for "https://google.com": Access to google.com was blocked because it violates the SSRF protection config + + } + + public static void example1() { + // run with `-Dssrf.protection.mode=deny_list -Dssrf.protection.iplist=127.0.0.1,192.168.0.0/16` + // if the properties are not set accordingly it will fail with IllegalStateException + RestTemplate exampleTemplate = SecureRestTemplateUtil.makeHC5Default(); + exampleTemplate.getForEntity("https://google.com", String.class); + } + + public static void main(String[] args) { + example1(); + example2(); } } From 46fd49c4b39eac89e85d04da742175e7ea6ea9b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Mon, 2 Dec 2024 20:58:31 +0100 Subject: [PATCH 09/34] fix github actions, use Java 17 for building --- .github/workflows/build.yaml | 5 +++++ .github/workflows/release.yaml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2b128022b44..13f25dcb42b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -6,6 +6,11 @@ jobs: build: runs-on: ubuntu-latest steps: + - name: Set up JDK 17 + uses: actions/setup-java@v2 + with: + java-version: 17 + distribution: temurin - name: Check out main branch uses: actions/checkout@v4 - run: gradle build diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ea0f00a20f8..ec92c37274d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -11,6 +11,11 @@ jobs: name: Upload Release Asset runs-on: ubuntu-latest steps: + - name: Set up JDK 17 + uses: actions/setup-java@v2 + with: + java-version: 17 + distribution: temurin - name: Checkout code uses: actions/checkout@v4 - name: Build project # This would actually build your project, using zip for an example artifact From 1dfd233b150c1e0e9eba737265acaed901d99b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Tue, 3 Dec 2024 18:49:41 +0100 Subject: [PATCH 10/34] Complete API refactor, a builder is used to create the RestTemplate, support for filter chaining added --- README.md | 6 +- ssrf/build.gradle | 27 ++- ...er.java => BasicSsrfProtectionFilter.java} | 34 ++-- .../security/web/client/ClientType.java | 20 ++ .../client/ListedSsrfProtectionFilter.java | 21 ++- .../security/web/client/NetworkMode.java | 21 +++ .../web/client/SecureRestTemplate.java | 173 ++++++++++++++++++ .../web/client/SecureRestTemplateUtil.java | 58 ------ .../security/web/client/SsrfDnsResolver.java | 19 +- .../web/client/SsrfProtectionConfig.java | 86 --------- .../web/client/SsrfProtectionFilter.java | 2 +- ...ava => BasicSsrfProtectionFilterTest.java} | 51 +++--- .../ListedSsrfProtectionFilterTest.java | 41 +++-- .../web/client/SsrfDnsResolverTest.java | 26 +-- .../security/web/client/UsageExample.java | 34 +++- 15 files changed, 378 insertions(+), 241 deletions(-) rename ssrf/src/main/java/com/google/springframework/security/web/client/{BasicSSRFProtectionFilter.java => BasicSsrfProtectionFilter.java} (63%) create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/NetworkMode.java create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplateUtil.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionConfig.java rename ssrf/src/test/java/com/google/springframework/security/web/client/{BasicSSRFProtectionFilterTest.java => BasicSsrfProtectionFilterTest.java} (59%) diff --git a/README.md b/README.md index 74225245527..cecebf1ab00 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ This is the first iteration of the library. Currently the `RestTemplate` is back ## Usage ```java -RestTemplate exampleTemplate = SecureRestTemplateUtil.makeSecureHC5Template( - SsrfProtectionConfig.makeBasicFilter( - FilterMode.ALLOW_INTERNAL_BLOCK_EXTERNAL)); +RestTemplate exampleTemplate = new SecureRestTemplate.Builder() + .networkMode(BLOCK_EXTERNAL) + .build(); try { exampleTemplate.getForEntity("https://google.com", String.class); diff --git a/ssrf/build.gradle b/ssrf/build.gradle index ad6ce3a9f68..cb7aa2d5d5f 100644 --- a/ssrf/build.gradle +++ b/ssrf/build.gradle @@ -4,35 +4,32 @@ buildscript { } } - plugins { - // Apply the java-library plugin for API and implementation separation. - id 'java-library' + // Apply the java-library plugin for API and implementation separation. + id 'java-library' } repositories { - // Use Maven Central for resolving dependencies. - mavenCentral() + // Use Maven Central for resolving dependencies. + mavenCentral() } dependencies { - // Use JUnit Jupiter for testing. - testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2' + // Use JUnit Jupiter for testing. + testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2' testImplementation 'org.mockito:mockito-core:5.14.2' testImplementation "org.mockito:mockito-junit-jupiter:5.14.2" - api 'org.springframework:spring-web:' + springVersion - - api 'org.springframework:spring-core:' + springVersion - + api 'org.springframework:spring-web:' + springVersion + api 'org.springframework:spring-core:' + springVersion - api("org.apache.httpcomponents.client5:httpclient5:5.3.1") - api("org.apache.httpcomponents.core5:httpcore5-reactive:5.2.5") + implementation("org.apache.httpcomponents.client5:httpclient5:5.3.1") + implementation("org.apache.httpcomponents.core5:httpcore5-reactive:5.2.5") implementation 'io.projectreactor:reactor-core:3.6.11' } tasks.named('test') { - // Use JUnit Platform for unit tests. - useJUnitPlatform() + // Use JUnit Platform for unit tests. + useJUnitPlatform() } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSSRFProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java similarity index 63% rename from ssrf/src/main/java/com/google/springframework/security/web/client/BasicSSRFProtectionFilter.java rename to ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java index 2de8512d181..6e17f7a3ebb 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSSRFProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java @@ -20,32 +20,30 @@ import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; -public class BasicSSRFProtectionFilter implements SsrfProtectionFilter { +public class BasicSsrfProtectionFilter implements SsrfProtectionFilter { - public enum FilterMode { - ALLOW_INTERNAL_BLOCK_EXTERNAL, - BLOCK_INTERNAL_ALLOW_EXTERNAL + private static final Log logger = LogFactory.getLog(BasicSsrfProtectionFilter.class); + private final boolean reportOnly; + private final NetworkMode mode; - } - - private FilterMode mode; - - - public BasicSSRFProtectionFilter(FilterMode mode) { + public BasicSsrfProtectionFilter(NetworkMode mode, boolean reportOnly) { this.mode = mode; + this.reportOnly = reportOnly; } @Override - public InetAddress[] filter(InetAddress[] addresses) throws HostBlockedException { + public InetAddress[] filteredAddresses(InetAddress[] addresses) throws HostBlockedException { List result = new ArrayList<>(addresses.length); for (InetAddress addr : addresses) { boolean isInternal = FilterUtils.isInternalIp(addr); boolean shouldAllow = switch (mode) { - case ALLOW_INTERNAL_BLOCK_EXTERNAL -> isInternal; - case BLOCK_INTERNAL_ALLOW_EXTERNAL -> !isInternal; + case BLOCK_EXTERNAL -> isInternal; + case BLOCK_INTERNAL -> !isInternal; }; if (shouldAllow) { result.add(addr); @@ -55,8 +53,14 @@ public InetAddress[] filter(InetAddress[] addresses) throws HostBlockedException if (result.size() == 0) { String addrFmt = Arrays.stream(addresses).map(a -> a.toString()).collect(Collectors.joining(", ")); - throw new HostBlockedException( - "The following address(es) were blocked due to violating " + mode.name() + " policy: " + addrFmt); + String errorMessage = + "The following address(es) were blocked due to violating " + mode.name() + " policy: " + addrFmt; + if (reportOnly) { + logger.warn(errorMessage); + return addresses; + } else { + throw new HostBlockedException(errorMessage); + } } return result.toArray(new InetAddress[]{}); diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java new file mode 100644 index 00000000000..c8799c80601 --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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 com.google.springframework.security.web.client; + +public enum ClientType { + HTTP_CLIENT_5 +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java index f3b2dddb56d..ed5cf0a81e0 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java @@ -20,9 +20,13 @@ import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; public class ListedSsrfProtectionFilter implements SsrfProtectionFilter { + private static final Log logger = LogFactory.getLog(ListedSsrfProtectionFilter.class); + /** * FilterMode enum to make usage more intuitive ( practically this is just a bool ) */ @@ -34,14 +38,16 @@ public enum FilterMode { private List matchingRules; private FilterMode mode; + private boolean reportOnly; - public ListedSsrfProtectionFilter(List addressList, FilterMode mode) { + public ListedSsrfProtectionFilter(List addressList, FilterMode mode, boolean reportOnly) { this.matchingRules = addressList; this.mode = mode; + this.reportOnly = reportOnly; } @Override - public InetAddress[] filter(InetAddress[] addresses) throws HostBlockedException { + public InetAddress[] filteredAddresses(InetAddress[] addresses) throws HostBlockedException { List result = new ArrayList<>(addresses.length); outerLoop: @@ -65,8 +71,15 @@ public InetAddress[] filter(InetAddress[] addresses) throws HostBlockedException if (result.size() == 0) { String addrFmt = Arrays.stream(addresses).map(a -> a.toString()).collect(Collectors.joining(", ")); - throw new HostBlockedException( - "The following address(es) were blocked due to violating " + mode.name() + " policy: " + addrFmt); + String errorMessage = + "The following address(es) were blocked due to violating " + mode.name() + " policy: " + addrFmt; + if (reportOnly) { + logger.warn(errorMessage); + return addresses; + } else { + throw new HostBlockedException(errorMessage); + } + } return result.toArray(new InetAddress[]{}); diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/NetworkMode.java b/ssrf/src/main/java/com/google/springframework/security/web/client/NetworkMode.java new file mode 100644 index 00000000000..e5d87eb22c2 --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/NetworkMode.java @@ -0,0 +1,21 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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 com.google.springframework.security.web.client; + +public enum NetworkMode { + BLOCK_EXTERNAL, + BLOCK_INTERNAL +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java new file mode 100644 index 00000000000..cd73ae6dba7 --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java @@ -0,0 +1,173 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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 com.google.springframework.security.web.client; + +import com.google.springframework.security.web.client.ListedSsrfProtectionFilter.FilterMode; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; +import org.apache.hc.client5.http.socket.ConnectionSocketFactory; +import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.core5.http.config.Registry; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +public class SecureRestTemplate { + + /** + * Helper enum to make configuring with system properties easier + */ + enum ProtectionMode { + // TODO(vaspori): make naming consistent with NetworkMode + ALLOW_LIST, DENY_LIST, ALLOW_INTERNAL, ALLOW_EXTERNAL, + } + + public static RestTemplate buildDefault() { + + String modeProperty = System.getProperty("ssrf.protection.mode"); + + SsrfProtectionFilter filter = null; + + if (modeProperty == null) { + throw new IllegalStateException("ssrf.protection.mode is not set but defaultFilter() requested"); + } + ProtectionMode mode = ProtectionMode.valueOf(modeProperty.toUpperCase()); + + boolean reportOnly = System.getProperty("ssrf.protection.report_only") != null; + + if (mode == ProtectionMode.ALLOW_LIST || mode == ProtectionMode.DENY_LIST) { + String ipList = System.getProperty("ssrf.protection.iplist"); + if (ipList == null) { + throw new IllegalStateException( + "ssrf.protection.iplist is required for ALLOW_LIST or DENY_LIST modes in comma separated CIDR format"); + } + FilterMode filterMode = (mode == ProtectionMode.ALLOW_LIST ? FilterMode.ALLOW_LIST : FilterMode.BLOCK_LIST); + filter = new ListedSsrfProtectionFilter( + Arrays.stream(ipList.strip().split(",")).map(IpOrRange::new).toList(), filterMode, reportOnly); + } else if (mode == ProtectionMode.ALLOW_EXTERNAL || mode == ProtectionMode.ALLOW_INTERNAL) { + NetworkMode filterMode = (mode == ProtectionMode.ALLOW_EXTERNAL + ? NetworkMode.BLOCK_INTERNAL + : NetworkMode.BLOCK_EXTERNAL); + + filter = new BasicSsrfProtectionFilter(filterMode, reportOnly); + } + + return new Builder().withCustomFilter(filter).build(); + + + } + + public static class Builder { + + private List customFilters = new ArrayList<>(); + + // Only one of the two can be used at the same time + private List ipAllowList = new ArrayList<>(); + private List ipBlockList = new ArrayList<>(); + private boolean isReportOnly = false; + private NetworkMode networkMode = null; + private ClientType clientType = ClientType.HTTP_CLIENT_5; + + public Builder reportOnly(boolean isReportOnly) { + this.isReportOnly = isReportOnly; + return this; + } + + public Builder networkMode(NetworkMode mode) { + this.networkMode = mode; + return this; + } + + public Builder withAllowlist(String[] ipList) { + this.ipAllowList.addAll(List.of(ipList)); + return this; + } + + public Builder withBlocklist(String[] ipList) { + this.ipBlockList.addAll(List.of(ipList)); + return this; + } + + public Builder withCustomFilter(SsrfProtectionFilter filter) { + this.customFilters.add(filter); + return this; + } + + public Builder withClient(ClientType clientType) { + this.clientType = clientType; + return this; + } + + private RestTemplate buildHttpClient5(SsrfDnsResolver dnsResolver) { + + Registry registry = RegistryBuilder.create() + .register("http", PlainConnectionSocketFactory.getSocketFactory()) + .register("https", SSLConnectionSocketFactory.getSocketFactory()) + .build(); + + BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager( + registry, null, null, dnsResolver); + + CloseableHttpClient httpClient = HttpClientBuilder.create() + .setConnectionManager(connManager) + .build(); + + HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory( + httpClient); + return new RestTemplate(requestFactory); + } + + public RestTemplate build() { + List filters = new ArrayList<>(); + + if (ipAllowList.size() != 0 && ipBlockList.size() != 0) { + throw new IllegalArgumentException( + "Logic inconsistency: ipBlockList and -AllowList can not be used at the same time"); + } + + if (networkMode != null) { + filters.add(new BasicSsrfProtectionFilter(networkMode, isReportOnly)); + } + + if (ipAllowList.size() > 0) { + filters.add(new ListedSsrfProtectionFilter( + ipAllowList.stream().map(IpOrRange::new).collect(Collectors.toList()), + FilterMode.ALLOW_LIST, isReportOnly)); + } + if (ipBlockList.size() > 0) { + filters.add(new ListedSsrfProtectionFilter( + ipAllowList.stream().map(IpOrRange::new).collect(Collectors.toList()), + FilterMode.ALLOW_LIST, isReportOnly)); + } + + filters.addAll(customFilters); + + SsrfDnsResolver dnsResolver = new SsrfDnsResolver(filters); + + if (this.clientType == ClientType.HTTP_CLIENT_5) { + return buildHttpClient5(dnsResolver); + } else { + throw new IllegalArgumentException("Only HTTP_CLIENT_5 backed RestTemplates are supported for now"); + } + } + } +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplateUtil.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplateUtil.java deleted file mode 100644 index 1c8cb91fc48..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplateUtil.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed 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 - * - * https://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 com.google.springframework.security.web.client; - -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; -import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; -import org.apache.hc.client5.http.socket.ConnectionSocketFactory; -import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; -import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; -import org.apache.hc.core5.http.config.Registry; -import org.apache.hc.core5.http.config.RegistryBuilder; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; -import org.springframework.web.client.RestTemplate; - -public class SecureRestTemplateUtil { - - static RestTemplate buildWithResolver(SsrfDnsResolver dnsResolver) { - Registry registry = RegistryBuilder.create() - .register("http", PlainConnectionSocketFactory.getSocketFactory()) - .register("https", SSLConnectionSocketFactory.getSocketFactory()) - .build(); - - BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager( - registry, null, null, dnsResolver); - - CloseableHttpClient httpClient = HttpClientBuilder.create() - .setConnectionManager(connManager) - .build(); - - HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); - return new RestTemplate(requestFactory); - } - - public static RestTemplate makeSecureHC5Template(SsrfProtectionConfig config) { - SsrfDnsResolver dnsResolver = new SsrfDnsResolver(config); - return buildWithResolver(dnsResolver); - } - - public static RestTemplate makeHC5Default() { - SsrfDnsResolver dnsResolver = new SsrfDnsResolver(SsrfProtectionConfig.defaultFilter()); - return buildWithResolver(dnsResolver); - } - -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java index 95efd8fd0d2..5338dc7bfbc 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java @@ -17,6 +17,8 @@ import java.net.InetAddress; import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.apache.hc.client5.http.DnsResolver; @@ -25,10 +27,12 @@ class SsrfDnsResolver implements DnsResolver { private static final Log logger = LogFactory.getLog(SsrfDnsResolver.class); - private final SsrfProtectionConfig ssrfProtectionConfig; - public SsrfDnsResolver(SsrfProtectionConfig ssrfProtectionConfig) { - this.ssrfProtectionConfig = ssrfProtectionConfig; + + protected final List filters; + + public SsrfDnsResolver(List filters) { + this.filters = filters; } @Override @@ -37,10 +41,15 @@ public InetAddress[] resolve(final String host) throws UnknownHostException { // Internally these results are cached for 30 seconds (by default) to prevent naive DNS rebinding // It's important to fetch it from the cache before running checks and to not run resolution again. // ( Otherwise this would make us vulnerable to high-frequency switching between valid-invalid addresses ) - InetAddress[] cachedResult = resolveAll(host); + final InetAddress[] cachedResult = resolveAll(host); + InetAddress[] results = Arrays.copyOf(cachedResult, cachedResult.length); try { - return ssrfProtectionConfig.getFilter().filter(cachedResult); + for (SsrfProtectionFilter f : filters) { + // each filter can restrict the list of addresses resolved to a given host + results = f.filteredAddresses(results); + } + return results; } catch (HostBlockedException e) { // log error as well, exception can't be chained logger.error("DNS resolution for '" + host + "' resulted in error", e); diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionConfig.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionConfig.java deleted file mode 100644 index 2829acd4eb0..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionConfig.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed 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 - * - * https://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 com.google.springframework.security.web.client; - -import com.google.springframework.security.web.client.ListedSsrfProtectionFilter.FilterMode; -import java.util.Arrays; -import java.util.List; - - -public class SsrfProtectionConfig { - - - /** - * Helper enum to make configuring with system properties easier - */ - enum ProtectionMode { - ALLOW_LIST, - DENY_LIST, - ALLOW_INTERNAL, - ALLOW_EXTERNAL, - } - - private SsrfProtectionFilter filter; - - public SsrfProtectionConfig(SsrfProtectionFilter filter) { - this.filter = filter; - } - - public static SsrfProtectionConfig makeBasicFilter(BasicSSRFProtectionFilter.FilterMode mode) { - return new SsrfProtectionConfig(new BasicSSRFProtectionFilter(mode)); - } - - public static SsrfProtectionConfig makeListedFilter(List addresses, - ListedSsrfProtectionFilter.FilterMode mode) { - return new SsrfProtectionConfig( - new ListedSsrfProtectionFilter(addresses.stream().map(IpOrRange::new).toList(), mode)); - } - - public static SsrfProtectionConfig defaultFilter() { - String modeProperty = System.getProperty("ssrf.protection.mode"); - - SsrfProtectionFilter filter = null; - - if (modeProperty == null) { - throw new IllegalStateException("ssrf.protection.mode is not set but defaultFilter() requested"); - } - ProtectionMode mode = ProtectionMode.valueOf(modeProperty.toUpperCase()); - - if (mode == ProtectionMode.ALLOW_LIST || mode == ProtectionMode.DENY_LIST) { - String ipList = System.getProperty("ssrf.protection.iplist"); - if (ipList == null) { - throw new IllegalStateException( - "ssrf.protection.iplist is required for ALLOW_LIST or DENY_LIST modes in comma separated CIDR format"); - } - FilterMode filterMode = (mode == ProtectionMode.ALLOW_LIST ? FilterMode.ALLOW_LIST : FilterMode.BLOCK_LIST); - filter = new ListedSsrfProtectionFilter( - Arrays.stream(ipList.strip().split(",")).map(IpOrRange::new).toList(), filterMode); - } - if (mode == ProtectionMode.ALLOW_EXTERNAL || mode == ProtectionMode.ALLOW_INTERNAL) { - BasicSSRFProtectionFilter.FilterMode filterMode = (mode == ProtectionMode.ALLOW_EXTERNAL - ? BasicSSRFProtectionFilter.FilterMode.BLOCK_INTERNAL_ALLOW_EXTERNAL : - BasicSSRFProtectionFilter.FilterMode.ALLOW_INTERNAL_BLOCK_EXTERNAL); - filter = new BasicSSRFProtectionFilter(filterMode); - } - - return new SsrfProtectionConfig(filter); - } - - - public SsrfProtectionFilter getFilter() { - return filter; - } -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java index de75a1c1f80..e0cd0bb2acb 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java @@ -20,6 +20,6 @@ public interface SsrfProtectionFilter { - InetAddress[] filter(final InetAddress[] addresses) throws HostBlockedException; + InetAddress[] filteredAddresses(final InetAddress[] addresses) throws HostBlockedException; } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSSRFProtectionFilterTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilterTest.java similarity index 59% rename from ssrf/src/test/java/com/google/springframework/security/web/client/BasicSSRFProtectionFilterTest.java rename to ssrf/src/test/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilterTest.java index 1000c5d9431..6b47f8f2e24 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSSRFProtectionFilterTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilterTest.java @@ -10,77 +10,82 @@ import java.util.List; import org.junit.jupiter.api.Test; -public class BasicSSRFProtectionFilterTest { +public class BasicSsrfProtectionFilterTest { @Test void testAllowInternalBlockExternal_internalAllowed() throws UnknownHostException, HostBlockedException { - BasicSSRFProtectionFilter filter = new BasicSSRFProtectionFilter( - BasicSSRFProtectionFilter.FilterMode.ALLOW_INTERNAL_BLOCK_EXTERNAL); + BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_EXTERNAL, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("10.0.0.1")}; - InetAddress[] filtered = filter.filter(addresses); + InetAddress[] filtered = filter.filteredAddresses(addresses); assertEquals(2, filtered.length); assertTrue(Arrays.asList(filtered).containsAll(List.of(addresses))); } @Test void testAllowInternalBlockExternal_externalBlocked() throws UnknownHostException, HostBlockedException { - BasicSSRFProtectionFilter filter = new BasicSSRFProtectionFilter( - BasicSSRFProtectionFilter.FilterMode.ALLOW_INTERNAL_BLOCK_EXTERNAL); + BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_EXTERNAL, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; - InetAddress[] filtered = filter.filter(addresses); + InetAddress[] filtered = filter.filteredAddresses(addresses); assertEquals(1, filtered.length); assertEquals(InetAddress.getByName("192.168.1.1"), filtered[0]); } @Test void testAllowInternalBlockExternal_allBlocked() throws UnknownHostException { - BasicSSRFProtectionFilter filter = new BasicSSRFProtectionFilter( - BasicSSRFProtectionFilter.FilterMode.ALLOW_INTERNAL_BLOCK_EXTERNAL); + BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_EXTERNAL, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("8.8.8.8"), InetAddress.getByName("1.1.1.1")}; - assertThrows(HostBlockedException.class, () -> filter.filter(addresses)); + assertThrows(HostBlockedException.class, () -> filter.filteredAddresses(addresses)); } @Test void testBlockInternalAllowExternal_internalBlocked() throws UnknownHostException, HostBlockedException { - BasicSSRFProtectionFilter filter = new BasicSSRFProtectionFilter( - BasicSSRFProtectionFilter.FilterMode.BLOCK_INTERNAL_ALLOW_EXTERNAL); + BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; - InetAddress[] filtered = filter.filter(addresses); + InetAddress[] filtered = filter.filteredAddresses(addresses); assertEquals(1, filtered.length); assertEquals(InetAddress.getByName("8.8.8.8"), filtered[0]); } @Test void testBlockInternalAllowExternal_externalAllowed() throws UnknownHostException, HostBlockedException { - BasicSSRFProtectionFilter filter = new BasicSSRFProtectionFilter( - BasicSSRFProtectionFilter.FilterMode.BLOCK_INTERNAL_ALLOW_EXTERNAL); + BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("8.8.8.8"), InetAddress.getByName("1.1.1.1")}; - InetAddress[] filtered = filter.filter(addresses); + InetAddress[] filtered = filter.filteredAddresses(addresses); assertEquals(2, filtered.length); assertTrue(Arrays.asList(filtered).containsAll(List.of(addresses))); } @Test void testBlockInternalAllowExternal_allBlocked() throws UnknownHostException { - BasicSSRFProtectionFilter filter = new BasicSSRFProtectionFilter( - BasicSSRFProtectionFilter.FilterMode.BLOCK_INTERNAL_ALLOW_EXTERNAL); + BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("10.0.0.1")}; - assertThrows(HostBlockedException.class, () -> filter.filter(addresses)); + assertThrows(HostBlockedException.class, () -> filter.filteredAddresses(addresses)); } @Test void testHostBlockedExceptionMessage() throws UnknownHostException { - BasicSSRFProtectionFilter filter = new BasicSSRFProtectionFilter( - BasicSSRFProtectionFilter.FilterMode.BLOCK_INTERNAL_ALLOW_EXTERNAL); + BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("10.0.0.1")}; - HostBlockedException exception = assertThrows(HostBlockedException.class, () -> filter.filter(addresses)); + HostBlockedException exception = assertThrows(HostBlockedException.class, + () -> filter.filteredAddresses(addresses)); assertTrue(exception.getMessage().contains("192.168.1.1")); assertTrue(exception.getMessage().contains("10.0.0.1")); - assertTrue(exception.getMessage().contains("BLOCK_INTERNAL_ALLOW_EXTERNAL")); + assertTrue(exception.getMessage().contains("BLOCK_INTERNAL")); + } + + @Test + void testReportOnlyHostBlocking() throws UnknownHostException, HostBlockedException { + BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL, true); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), + InetAddress.getByName("10.0.0.1")}; + + InetAddress[] filtered = filter.filteredAddresses(addresses); + assertEquals(2, filtered.length); + } } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java index 2625a9dd189..0d004374afd 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java @@ -16,10 +16,10 @@ public class ListedSsrfProtectionFilterTest { void testBlockList_blockedAddress() throws UnknownHostException, HostBlockedException { List blockList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, - ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); + ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; - InetAddress[] filtered = filter.filter(addresses); + InetAddress[] filtered = filter.filteredAddresses(addresses); assertEquals(1, filtered.length); assertEquals(InetAddress.getByName("8.8.8.8"), filtered[0]); } @@ -28,10 +28,10 @@ void testBlockList_blockedAddress() throws UnknownHostException, HostBlockedExce void testBlockList_allowedAddress() throws UnknownHostException, HostBlockedException { List blockList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, - ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); + ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.2"), InetAddress.getByName("8.8.8.8")}; - InetAddress[] filtered = filter.filter(addresses); + InetAddress[] filtered = filter.filteredAddresses(addresses); assertEquals(2, filtered.length); assertTrue(Arrays.asList(filtered).containsAll(List.of(addresses))); } @@ -40,20 +40,20 @@ void testBlockList_allowedAddress() throws UnknownHostException, HostBlockedExce void testBlockList_allBlocked() throws UnknownHostException { List blockList = List.of(new IpOrRange("192.168.1.0/24"), new IpOrRange("8.8.8.8")); ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, - ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); + ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; - assertThrows(HostBlockedException.class, () -> filter.filter(addresses)); + assertThrows(HostBlockedException.class, () -> filter.filteredAddresses(addresses)); } @Test void testAllowList_allowedAddress() throws UnknownHostException, HostBlockedException { List allowList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(allowList, - ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST); + ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; - InetAddress[] filtered = filter.filter(addresses); + InetAddress[] filtered = filter.filteredAddresses(addresses); assertEquals(1, filtered.length); assertEquals(InetAddress.getByName("192.168.1.1"), filtered[0]); } @@ -62,11 +62,11 @@ void testAllowList_allowedAddress() throws UnknownHostException, HostBlockedExce void testAllowList_blockedAddress() throws UnknownHostException, HostBlockedException { List allowList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(allowList, - ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST); + ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.200"), InetAddress.getByName("8.8.8.8")}; HostBlockedException ex = assertThrows(HostBlockedException.class, - () -> filter.filter(addresses), "This should throw an exception"); + () -> filter.filteredAddresses(addresses), "This should throw an exception"); assertTrue(ex.getMessage().contains("blocked due to violating ALLOW_LIST")); } @@ -75,20 +75,33 @@ void testAllowList_blockedAddress() throws UnknownHostException, HostBlockedExce void testAllowList_allBlocked() throws UnknownHostException { List allowList = List.of(new IpOrRange("172.16.0.0/16")); ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(allowList, - ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST); + ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; - assertThrows(HostBlockedException.class, () -> filter.filter(addresses)); + assertThrows(HostBlockedException.class, () -> filter.filteredAddresses(addresses)); } @Test void testHostBlockedExceptionMessage() throws UnknownHostException { List blockList = List.of(new IpOrRange("192.168.1.0/24")); ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, - ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); + ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1")}; - HostBlockedException exception = assertThrows(HostBlockedException.class, () -> filter.filter(addresses)); + HostBlockedException exception = assertThrows(HostBlockedException.class, + () -> filter.filteredAddresses(addresses)); assertTrue(exception.getMessage().contains("192.168.1.1")); assertTrue(exception.getMessage().contains("BLOCK_LIST")); } + + @Test + void testReportOnlyWhenBlockedException() throws UnknownHostException, HostBlockedException { + List blockList = List.of(new IpOrRange("192.168.1.0/24")); + ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, + ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST, true); + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1")}; + + InetAddress[] filtered = filter.filteredAddresses(addresses); + assertEquals(1, filtered.length); + assertTrue(Arrays.asList(filtered).containsAll(List.of(addresses))); + } } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java index 2c956f6808d..8ec3686c42f 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java @@ -7,6 +7,8 @@ import java.net.InetAddress; import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -16,37 +18,38 @@ @ExtendWith(MockitoExtension.class) public class SsrfDnsResolverTest { - @Mock - private SsrfProtectionConfig ssrfProtectionConfig; - @Mock private SsrfProtectionFilter ssrfProtectionFilter; - static class TestableSsrfDnsResolver extends SsrfDnsResolver { InetAddress[] addressesToReturn = null; - public TestableSsrfDnsResolver(SsrfProtectionConfig ssrfProtectionConfig) { - super(ssrfProtectionConfig); + public TestableSsrfDnsResolver(List filterList) { + super(filterList); } @Override protected InetAddress[] resolveAll(String host) throws UnknownHostException { return addressesToReturn; } + + public void setFilters(List filterList) { + filters.clear(); + filters.addAll(filterList); + } } @InjectMocks - private TestableSsrfDnsResolver customDnsResolver; + private TestableSsrfDnsResolver customDnsResolver = new TestableSsrfDnsResolver(new ArrayList<>()); @Test void testResolve_validHost() throws UnknownHostException, HostBlockedException { String host = "www.example.com"; InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("93.184.216.34")}; - when(ssrfProtectionConfig.getFilter()).thenReturn(ssrfProtectionFilter); - when(ssrfProtectionFilter.filter(addresses)).thenReturn(addresses); + when(ssrfProtectionFilter.filteredAddresses(addresses)).thenReturn(addresses); customDnsResolver.addressesToReturn = addresses; + customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); InetAddress[] resolvedAddresses = customDnsResolver.resolve(host); @@ -58,10 +61,9 @@ void testResolve_validHost() throws UnknownHostException, HostBlockedException { void testResolve_blockedHost() throws UnknownHostException, HostBlockedException { String host = "192.168.1.1"; InetAddress[] addresses = new InetAddress[]{InetAddress.getByName(host)}; - when(ssrfProtectionConfig.getFilter()).thenReturn(ssrfProtectionFilter); - when(ssrfProtectionFilter.filter(addresses)).thenThrow(new HostBlockedException("Blocked")); - + when(ssrfProtectionFilter.filteredAddresses(addresses)).thenThrow(new HostBlockedException("Blocked")); customDnsResolver.addressesToReturn = addresses; + customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); UnknownHostException exception = assertThrows(UnknownHostException.class, () -> customDnsResolver.resolve(host)); diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java index f6cf19c9d18..a54c07adda8 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java @@ -1,15 +1,32 @@ package com.google.springframework.security.web.client; -import com.google.springframework.security.web.client.BasicSSRFProtectionFilter.FilterMode; +import static com.google.springframework.security.web.client.NetworkMode.BLOCK_EXTERNAL; + +import org.springframework.http.ResponseEntity; import org.springframework.web.client.RestTemplate; public class UsageExample { + public static void example3() { + RestTemplate exampleTemplate = new SecureRestTemplate.Builder() + .reportOnly(true) // Log warning about blocking, but don't block + .networkMode(BLOCK_EXTERNAL) + .withBlocklist(new String[]{"evil.com"}) + .build(); + + try { + ResponseEntity result = exampleTemplate.getForEntity("https://google.com", String.class); + System.out.println(result); + } catch (Exception e) { + // This should not run + System.err.println("Access blocked: " + e.getMessage()); + } + } public static void example2() { - RestTemplate exampleTemplate = SecureRestTemplateUtil.makeSecureHC5Template( - SsrfProtectionConfig.makeBasicFilter( - FilterMode.ALLOW_INTERNAL_BLOCK_EXTERNAL)); + RestTemplate exampleTemplate = new SecureRestTemplate.Builder() + .networkMode(BLOCK_EXTERNAL) + .build(); try { exampleTemplate.getForEntity("https://google.com", String.class); @@ -25,13 +42,20 @@ public static void example2() { public static void example1() { // run with `-Dssrf.protection.mode=deny_list -Dssrf.protection.iplist=127.0.0.1,192.168.0.0/16` // if the properties are not set accordingly it will fail with IllegalStateException - RestTemplate exampleTemplate = SecureRestTemplateUtil.makeHC5Default(); + + // for this example: + System.setProperty("ssrf.protection.mode", "deny_list"); + System.setProperty("ssrf.protection.iplist", "127.0.0.1,192.168.0.0/16"); + + RestTemplate exampleTemplate = SecureRestTemplate.buildDefault(); exampleTemplate.getForEntity("https://google.com", String.class); } public static void main(String[] args) { example1(); example2(); + example3(); } } + From 3d16fb9191db0549f9b68b17910fae63b08df3df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Thu, 5 Dec 2024 12:45:26 +0100 Subject: [PATCH 11/34] Code cleanup; readme updated, ProtectionMode enum namig fixed, builder interface usability improved --- .github/workflows/release.yaml | 2 +- README.md | 9 +++- .../web/client/BasicSsrfProtectionFilter.java | 6 ++- .../client/ListedSsrfProtectionFilter.java | 6 ++- .../web/client/SecureRestTemplate.java | 47 ++++++++++--------- .../security/web/client/SsrfDnsResolver.java | 4 +- .../web/client/SsrfProtectionFilter.java | 2 +- .../client/BasicSsrfProtectionFilterTest.java | 16 +++---- .../ListedSsrfProtectionFilterTest.java | 16 +++---- .../web/client/SsrfDnsResolverTest.java | 6 +-- .../security/web/client/UsageExample.java | 15 +++--- 11 files changed, 73 insertions(+), 56 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ec92c37274d..179a88c8138 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v4 - name: Build project # This would actually build your project, using zip for an example artifact run: gradle build - - name: Create Release # TODO: enable write permission for workflows in settings + - name: Create Release id: create_release uses: actions/create-release@v1 env: diff --git a/README.md b/README.md index cecebf1ab00..efc6eda76b1 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,19 @@ This is the first iteration of the library. Currently the `RestTemplate` is back ## Usage ```java RestTemplate exampleTemplate = new SecureRestTemplate.Builder() + .reportOnly(true) // Log warning about blocking, but don't block .networkMode(BLOCK_EXTERNAL) + .withCustomFilter(addresses -> + Arrays.stream(addresses).filter(a -> !a.isMCNodeLocal()).toArray(InetAddress[]::new) + ) + .withBlocklist("evil.com", "6.6.6.9/16", "123.123.123.123") .build(); try { - exampleTemplate.getForEntity("https://google.com", String.class); + ResponseEntity result = exampleTemplate.getForEntity("https://google.com", String.class); + System.out.println(result); } catch (Exception e) { + // This should not run System.err.println("Access blocked: " + e.getMessage()); } ``` diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java index 6e17f7a3ebb..4e001e814a8 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java @@ -15,6 +15,8 @@ */ package com.google.springframework.security.web.client; +import static java.util.stream.Collectors.joining; + import java.net.InetAddress; import java.util.ArrayList; import java.util.Arrays; @@ -35,7 +37,7 @@ public BasicSsrfProtectionFilter(NetworkMode mode, boolean reportOnly) { } @Override - public InetAddress[] filteredAddresses(InetAddress[] addresses) throws HostBlockedException { + public InetAddress[] filterAddresses(InetAddress[] addresses) throws HostBlockedException { List result = new ArrayList<>(addresses.length); @@ -52,7 +54,7 @@ public InetAddress[] filteredAddresses(InetAddress[] addresses) throws HostBlock } if (result.size() == 0) { - String addrFmt = Arrays.stream(addresses).map(a -> a.toString()).collect(Collectors.joining(", ")); + String addrFmt = Arrays.stream(addresses).map(InetAddress::toString).collect(joining(", ")); String errorMessage = "The following address(es) were blocked due to violating " + mode.name() + " policy: " + addrFmt; if (reportOnly) { diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java index ed5cf0a81e0..6dd8168bad4 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java @@ -15,6 +15,8 @@ */ package com.google.springframework.security.web.client; +import static java.util.stream.Collectors.joining; + import java.net.InetAddress; import java.util.ArrayList; import java.util.Arrays; @@ -47,7 +49,7 @@ public ListedSsrfProtectionFilter(List addressList, FilterMode mode, } @Override - public InetAddress[] filteredAddresses(InetAddress[] addresses) throws HostBlockedException { + public InetAddress[] filterAddresses(InetAddress[] addresses) throws HostBlockedException { List result = new ArrayList<>(addresses.length); outerLoop: @@ -70,7 +72,7 @@ public InetAddress[] filteredAddresses(InetAddress[] addresses) throws HostBlock } if (result.size() == 0) { - String addrFmt = Arrays.stream(addresses).map(a -> a.toString()).collect(Collectors.joining(", ")); + String addrFmt = Arrays.stream(addresses).map(InetAddress::toString).collect(joining(", ")); String errorMessage = "The following address(es) were blocked due to violating " + mode.name() + " policy: " + addrFmt; if (reportOnly) { diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java index cd73ae6dba7..cb1ea7ac0ff 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java @@ -15,11 +15,12 @@ */ package com.google.springframework.security.web.client; +import static java.util.stream.Collectors.toList; + import com.google.springframework.security.web.client.ListedSsrfProtectionFilter.FilterMode; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.stream.Collectors; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; @@ -36,9 +37,8 @@ public class SecureRestTemplate { /** * Helper enum to make configuring with system properties easier */ - enum ProtectionMode { - // TODO(vaspori): make naming consistent with NetworkMode - ALLOW_LIST, DENY_LIST, ALLOW_INTERNAL, ALLOW_EXTERNAL, + private enum ProtectionMode { + ALLOW_LIST, DENY_LIST, BLOCK_EXTERNAL, BLOCK_INTERNAL, } public static RestTemplate buildDefault() { @@ -63,9 +63,8 @@ public static RestTemplate buildDefault() { FilterMode filterMode = (mode == ProtectionMode.ALLOW_LIST ? FilterMode.ALLOW_LIST : FilterMode.BLOCK_LIST); filter = new ListedSsrfProtectionFilter( Arrays.stream(ipList.strip().split(",")).map(IpOrRange::new).toList(), filterMode, reportOnly); - } else if (mode == ProtectionMode.ALLOW_EXTERNAL || mode == ProtectionMode.ALLOW_INTERNAL) { - NetworkMode filterMode = (mode == ProtectionMode.ALLOW_EXTERNAL - ? NetworkMode.BLOCK_INTERNAL + } else if (mode == ProtectionMode.BLOCK_INTERNAL || mode == ProtectionMode.BLOCK_EXTERNAL) { + NetworkMode filterMode = (mode == ProtectionMode.BLOCK_INTERNAL ? NetworkMode.BLOCK_INTERNAL : NetworkMode.BLOCK_EXTERNAL); filter = new BasicSsrfProtectionFilter(filterMode, reportOnly); @@ -97,16 +96,27 @@ public Builder networkMode(NetworkMode mode) { return this; } - public Builder withAllowlist(String[] ipList) { + public Builder withAllowlist(String... ipList) { this.ipAllowList.addAll(List.of(ipList)); return this; } - public Builder withBlocklist(String[] ipList) { + public Builder withAllowlist(Iterable ipList) { + ipList.forEach(this.ipAllowList::add); + return this; + } + + public Builder withBlocklist(String... ipList) { this.ipBlockList.addAll(List.of(ipList)); return this; } + public Builder withBlocklist(Iterable ipList) { + ipList.forEach(this.ipBlockList::add); + return this; + } + + public Builder withCustomFilter(SsrfProtectionFilter filter) { this.customFilters.add(filter); return this; @@ -121,15 +131,12 @@ private RestTemplate buildHttpClient5(SsrfDnsResolver dnsResolver) { Registry registry = RegistryBuilder.create() .register("http", PlainConnectionSocketFactory.getSocketFactory()) - .register("https", SSLConnectionSocketFactory.getSocketFactory()) - .build(); + .register("https", SSLConnectionSocketFactory.getSocketFactory()).build(); - BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager( - registry, null, null, dnsResolver); + BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager(registry, null, null, + dnsResolver); - CloseableHttpClient httpClient = HttpClientBuilder.create() - .setConnectionManager(connManager) - .build(); + CloseableHttpClient httpClient = HttpClientBuilder.create().setConnectionManager(connManager).build(); HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory( httpClient); @@ -149,14 +156,12 @@ public RestTemplate build() { } if (ipAllowList.size() > 0) { - filters.add(new ListedSsrfProtectionFilter( - ipAllowList.stream().map(IpOrRange::new).collect(Collectors.toList()), + filters.add(new ListedSsrfProtectionFilter(ipAllowList.stream().map(IpOrRange::new).collect(toList()), FilterMode.ALLOW_LIST, isReportOnly)); } if (ipBlockList.size() > 0) { - filters.add(new ListedSsrfProtectionFilter( - ipAllowList.stream().map(IpOrRange::new).collect(Collectors.toList()), - FilterMode.ALLOW_LIST, isReportOnly)); + filters.add(new ListedSsrfProtectionFilter(ipAllowList.stream().map(IpOrRange::new).collect(toList()), + FilterMode.BLOCK_LIST, isReportOnly)); } filters.addAll(customFilters); diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java index 5338dc7bfbc..a928d3fe800 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java @@ -41,13 +41,13 @@ public InetAddress[] resolve(final String host) throws UnknownHostException { // Internally these results are cached for 30 seconds (by default) to prevent naive DNS rebinding // It's important to fetch it from the cache before running checks and to not run resolution again. // ( Otherwise this would make us vulnerable to high-frequency switching between valid-invalid addresses ) - final InetAddress[] cachedResult = resolveAll(host); + InetAddress[] cachedResult = resolveAll(host); InetAddress[] results = Arrays.copyOf(cachedResult, cachedResult.length); try { for (SsrfProtectionFilter f : filters) { // each filter can restrict the list of addresses resolved to a given host - results = f.filteredAddresses(results); + results = f.filterAddresses(results); } return results; } catch (HostBlockedException e) { diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java index e0cd0bb2acb..5af375384cb 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java @@ -20,6 +20,6 @@ public interface SsrfProtectionFilter { - InetAddress[] filteredAddresses(final InetAddress[] addresses) throws HostBlockedException; + InetAddress[] filterAddresses(final InetAddress[] addresses) throws HostBlockedException; } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilterTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilterTest.java index 6b47f8f2e24..3b93825cc6e 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilterTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilterTest.java @@ -17,7 +17,7 @@ void testAllowInternalBlockExternal_internalAllowed() throws UnknownHostExceptio BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_EXTERNAL, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("10.0.0.1")}; - InetAddress[] filtered = filter.filteredAddresses(addresses); + InetAddress[] filtered = filter.filterAddresses(addresses); assertEquals(2, filtered.length); assertTrue(Arrays.asList(filtered).containsAll(List.of(addresses))); } @@ -27,7 +27,7 @@ void testAllowInternalBlockExternal_externalBlocked() throws UnknownHostExceptio BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_EXTERNAL, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; - InetAddress[] filtered = filter.filteredAddresses(addresses); + InetAddress[] filtered = filter.filterAddresses(addresses); assertEquals(1, filtered.length); assertEquals(InetAddress.getByName("192.168.1.1"), filtered[0]); } @@ -36,7 +36,7 @@ void testAllowInternalBlockExternal_externalBlocked() throws UnknownHostExceptio void testAllowInternalBlockExternal_allBlocked() throws UnknownHostException { BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_EXTERNAL, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("8.8.8.8"), InetAddress.getByName("1.1.1.1")}; - assertThrows(HostBlockedException.class, () -> filter.filteredAddresses(addresses)); + assertThrows(HostBlockedException.class, () -> filter.filterAddresses(addresses)); } @Test @@ -44,7 +44,7 @@ void testBlockInternalAllowExternal_internalBlocked() throws UnknownHostExceptio BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; - InetAddress[] filtered = filter.filteredAddresses(addresses); + InetAddress[] filtered = filter.filterAddresses(addresses); assertEquals(1, filtered.length); assertEquals(InetAddress.getByName("8.8.8.8"), filtered[0]); } @@ -53,7 +53,7 @@ void testBlockInternalAllowExternal_internalBlocked() throws UnknownHostExceptio void testBlockInternalAllowExternal_externalAllowed() throws UnknownHostException, HostBlockedException { BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("8.8.8.8"), InetAddress.getByName("1.1.1.1")}; - InetAddress[] filtered = filter.filteredAddresses(addresses); + InetAddress[] filtered = filter.filterAddresses(addresses); assertEquals(2, filtered.length); assertTrue(Arrays.asList(filtered).containsAll(List.of(addresses))); } @@ -63,7 +63,7 @@ void testBlockInternalAllowExternal_allBlocked() throws UnknownHostException { BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("10.0.0.1")}; - assertThrows(HostBlockedException.class, () -> filter.filteredAddresses(addresses)); + assertThrows(HostBlockedException.class, () -> filter.filterAddresses(addresses)); } @Test @@ -72,7 +72,7 @@ void testHostBlockedExceptionMessage() throws UnknownHostException { InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("10.0.0.1")}; HostBlockedException exception = assertThrows(HostBlockedException.class, - () -> filter.filteredAddresses(addresses)); + () -> filter.filterAddresses(addresses)); assertTrue(exception.getMessage().contains("192.168.1.1")); assertTrue(exception.getMessage().contains("10.0.0.1")); assertTrue(exception.getMessage().contains("BLOCK_INTERNAL")); @@ -84,7 +84,7 @@ void testReportOnlyHostBlocking() throws UnknownHostException, HostBlockedExcept InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("10.0.0.1")}; - InetAddress[] filtered = filter.filteredAddresses(addresses); + InetAddress[] filtered = filter.filterAddresses(addresses); assertEquals(2, filtered.length); } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java index 0d004374afd..c591d112a19 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java @@ -19,7 +19,7 @@ void testBlockList_blockedAddress() throws UnknownHostException, HostBlockedExce ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; - InetAddress[] filtered = filter.filteredAddresses(addresses); + InetAddress[] filtered = filter.filterAddresses(addresses); assertEquals(1, filtered.length); assertEquals(InetAddress.getByName("8.8.8.8"), filtered[0]); } @@ -31,7 +31,7 @@ void testBlockList_allowedAddress() throws UnknownHostException, HostBlockedExce ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.2"), InetAddress.getByName("8.8.8.8")}; - InetAddress[] filtered = filter.filteredAddresses(addresses); + InetAddress[] filtered = filter.filterAddresses(addresses); assertEquals(2, filtered.length); assertTrue(Arrays.asList(filtered).containsAll(List.of(addresses))); } @@ -43,7 +43,7 @@ void testBlockList_allBlocked() throws UnknownHostException { ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; - assertThrows(HostBlockedException.class, () -> filter.filteredAddresses(addresses)); + assertThrows(HostBlockedException.class, () -> filter.filterAddresses(addresses)); } @Test @@ -53,7 +53,7 @@ void testAllowList_allowedAddress() throws UnknownHostException, HostBlockedExce ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; - InetAddress[] filtered = filter.filteredAddresses(addresses); + InetAddress[] filtered = filter.filterAddresses(addresses); assertEquals(1, filtered.length); assertEquals(InetAddress.getByName("192.168.1.1"), filtered[0]); } @@ -66,7 +66,7 @@ void testAllowList_blockedAddress() throws UnknownHostException, HostBlockedExce InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.200"), InetAddress.getByName("8.8.8.8")}; HostBlockedException ex = assertThrows(HostBlockedException.class, - () -> filter.filteredAddresses(addresses), "This should throw an exception"); + () -> filter.filterAddresses(addresses), "This should throw an exception"); assertTrue(ex.getMessage().contains("blocked due to violating ALLOW_LIST")); } @@ -78,7 +78,7 @@ void testAllowList_allBlocked() throws UnknownHostException { ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; - assertThrows(HostBlockedException.class, () -> filter.filteredAddresses(addresses)); + assertThrows(HostBlockedException.class, () -> filter.filterAddresses(addresses)); } @Test @@ -88,7 +88,7 @@ void testHostBlockedExceptionMessage() throws UnknownHostException { ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST, false); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1")}; HostBlockedException exception = assertThrows(HostBlockedException.class, - () -> filter.filteredAddresses(addresses)); + () -> filter.filterAddresses(addresses)); assertTrue(exception.getMessage().contains("192.168.1.1")); assertTrue(exception.getMessage().contains("BLOCK_LIST")); } @@ -100,7 +100,7 @@ void testReportOnlyWhenBlockedException() throws UnknownHostException, HostBlock ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST, true); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1")}; - InetAddress[] filtered = filter.filteredAddresses(addresses); + InetAddress[] filtered = filter.filterAddresses(addresses); assertEquals(1, filtered.length); assertTrue(Arrays.asList(filtered).containsAll(List.of(addresses))); } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java index 8ec3686c42f..93844b3ba27 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java @@ -47,7 +47,7 @@ public void setFilters(List filterList) { void testResolve_validHost() throws UnknownHostException, HostBlockedException { String host = "www.example.com"; InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("93.184.216.34")}; - when(ssrfProtectionFilter.filteredAddresses(addresses)).thenReturn(addresses); + when(ssrfProtectionFilter.filterAddresses(addresses)).thenReturn(addresses); customDnsResolver.addressesToReturn = addresses; customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); @@ -61,7 +61,7 @@ void testResolve_validHost() throws UnknownHostException, HostBlockedException { void testResolve_blockedHost() throws UnknownHostException, HostBlockedException { String host = "192.168.1.1"; InetAddress[] addresses = new InetAddress[]{InetAddress.getByName(host)}; - when(ssrfProtectionFilter.filteredAddresses(addresses)).thenThrow(new HostBlockedException("Blocked")); + when(ssrfProtectionFilter.filterAddresses(addresses)).thenThrow(new HostBlockedException("Blocked")); customDnsResolver.addressesToReturn = addresses; customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); @@ -76,6 +76,6 @@ void testResolve_blockedHost() throws UnknownHostException, HostBlockedException void testResolveCanonicalHostname() throws UnknownHostException { String host = "www.example.com"; String resolvedHostname = customDnsResolver.resolveCanonicalHostname(host); - assertEquals(host, resolvedHostname); // Since the method is not fully implemented yet + assertEquals(host, resolvedHostname); } } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java index a54c07adda8..0dc937e7e9d 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java @@ -2,16 +2,19 @@ import static com.google.springframework.security.web.client.NetworkMode.BLOCK_EXTERNAL; +import java.net.InetAddress; +import java.util.Arrays; import org.springframework.http.ResponseEntity; import org.springframework.web.client.RestTemplate; public class UsageExample { public static void example3() { - RestTemplate exampleTemplate = new SecureRestTemplate.Builder() - .reportOnly(true) // Log warning about blocking, but don't block - .networkMode(BLOCK_EXTERNAL) - .withBlocklist(new String[]{"evil.com"}) + RestTemplate exampleTemplate = new SecureRestTemplate.Builder().reportOnly( + true) // Log warning about blocking, but don't block + .networkMode(BLOCK_EXTERNAL).withCustomFilter( + addresses -> Arrays.stream(addresses).filter(a -> !a.isMCNodeLocal()) + .toArray(InetAddress[]::new)).withBlocklist("evil.com", "6.6.6.9/16", "123.123.123.123") .build(); try { @@ -24,9 +27,7 @@ public static void example3() { } public static void example2() { - RestTemplate exampleTemplate = new SecureRestTemplate.Builder() - .networkMode(BLOCK_EXTERNAL) - .build(); + RestTemplate exampleTemplate = new SecureRestTemplate.Builder().networkMode(BLOCK_EXTERNAL).build(); try { exampleTemplate.getForEntity("https://google.com", String.class); From c0d33d5de27021e4fc8b993bd76757ea6c4a795d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Thu, 5 Dec 2024 13:11:54 +0100 Subject: [PATCH 12/34] Report only mode handling moved to SsrfDnsResolver to make user provided filters work as well --- README.md | 3 ++ .../web/client/BasicSsrfProtectionFilter.java | 18 ++------ .../client/ListedSsrfProtectionFilter.java | 20 ++------- .../web/client/SecureRestTemplate.java | 14 +++---- .../security/web/client/SsrfDnsResolver.java | 17 +++++--- .../client/BasicSsrfProtectionFilterTest.java | 35 ++++++---------- .../security/web/client/FilterUtilsTest.java | 12 +++--- .../ListedSsrfProtectionFilterTest.java | 41 +++++++------------ .../web/client/SsrfDnsResolverTest.java | 28 +++++++++++-- 9 files changed, 86 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index efc6eda76b1..5b67c211475 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This library provides a framework for preventing Server-Side Request Forgery (SS This is the first iteration of the library. Currently the `RestTemplate` is backed by an Apache Commons 5 HttpClient. ## Usage + ```java RestTemplate exampleTemplate = new SecureRestTemplate.Builder() .reportOnly(true) // Log warning about blocking, but don't block @@ -33,3 +34,5 @@ try { System.err.println("Access blocked: " + e.getMessage()); } ``` + +See `test/java/com/google/springframework/security/web/client/UsageExample.java` for more examples diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java index 4e001e814a8..5c870ffcbfb 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java @@ -21,19 +21,13 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.stream.Collectors; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; public class BasicSsrfProtectionFilter implements SsrfProtectionFilter { - private static final Log logger = LogFactory.getLog(BasicSsrfProtectionFilter.class); - private final boolean reportOnly; private final NetworkMode mode; - public BasicSsrfProtectionFilter(NetworkMode mode, boolean reportOnly) { + public BasicSsrfProtectionFilter(NetworkMode mode) { this.mode = mode; - this.reportOnly = reportOnly; } @Override @@ -55,14 +49,8 @@ public InetAddress[] filterAddresses(InetAddress[] addresses) throws HostBlocked if (result.size() == 0) { String addrFmt = Arrays.stream(addresses).map(InetAddress::toString).collect(joining(", ")); - String errorMessage = - "The following address(es) were blocked due to violating " + mode.name() + " policy: " + addrFmt; - if (reportOnly) { - logger.warn(errorMessage); - return addresses; - } else { - throw new HostBlockedException(errorMessage); - } + throw new HostBlockedException( + "The following address(es) were blocked due to violating " + mode.name() + " policy: " + addrFmt); } return result.toArray(new InetAddress[]{}); diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java index 6dd8168bad4..53b635c84a0 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java @@ -21,14 +21,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.stream.Collectors; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; public class ListedSsrfProtectionFilter implements SsrfProtectionFilter { - private static final Log logger = LogFactory.getLog(ListedSsrfProtectionFilter.class); - /** * FilterMode enum to make usage more intuitive ( practically this is just a bool ) */ @@ -40,12 +35,10 @@ public enum FilterMode { private List matchingRules; private FilterMode mode; - private boolean reportOnly; - public ListedSsrfProtectionFilter(List addressList, FilterMode mode, boolean reportOnly) { + public ListedSsrfProtectionFilter(List addressList, FilterMode mode) { this.matchingRules = addressList; this.mode = mode; - this.reportOnly = reportOnly; } @Override @@ -73,15 +66,8 @@ public InetAddress[] filterAddresses(InetAddress[] addresses) throws HostBlocked if (result.size() == 0) { String addrFmt = Arrays.stream(addresses).map(InetAddress::toString).collect(joining(", ")); - String errorMessage = - "The following address(es) were blocked due to violating " + mode.name() + " policy: " + addrFmt; - if (reportOnly) { - logger.warn(errorMessage); - return addresses; - } else { - throw new HostBlockedException(errorMessage); - } - + throw new HostBlockedException( + "The following address(es) were blocked due to violating " + mode.name() + " policy: " + addrFmt); } return result.toArray(new InetAddress[]{}); diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java index cb1ea7ac0ff..8570ed8e831 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java @@ -62,15 +62,15 @@ public static RestTemplate buildDefault() { } FilterMode filterMode = (mode == ProtectionMode.ALLOW_LIST ? FilterMode.ALLOW_LIST : FilterMode.BLOCK_LIST); filter = new ListedSsrfProtectionFilter( - Arrays.stream(ipList.strip().split(",")).map(IpOrRange::new).toList(), filterMode, reportOnly); + Arrays.stream(ipList.strip().split(",")).map(IpOrRange::new).toList(), filterMode); } else if (mode == ProtectionMode.BLOCK_INTERNAL || mode == ProtectionMode.BLOCK_EXTERNAL) { NetworkMode filterMode = (mode == ProtectionMode.BLOCK_INTERNAL ? NetworkMode.BLOCK_INTERNAL : NetworkMode.BLOCK_EXTERNAL); - filter = new BasicSsrfProtectionFilter(filterMode, reportOnly); + filter = new BasicSsrfProtectionFilter(filterMode); } - return new Builder().withCustomFilter(filter).build(); + return new Builder().reportOnly(reportOnly).withCustomFilter(filter).build(); } @@ -152,21 +152,21 @@ public RestTemplate build() { } if (networkMode != null) { - filters.add(new BasicSsrfProtectionFilter(networkMode, isReportOnly)); + filters.add(new BasicSsrfProtectionFilter(networkMode)); } if (ipAllowList.size() > 0) { filters.add(new ListedSsrfProtectionFilter(ipAllowList.stream().map(IpOrRange::new).collect(toList()), - FilterMode.ALLOW_LIST, isReportOnly)); + FilterMode.ALLOW_LIST)); } if (ipBlockList.size() > 0) { filters.add(new ListedSsrfProtectionFilter(ipAllowList.stream().map(IpOrRange::new).collect(toList()), - FilterMode.BLOCK_LIST, isReportOnly)); + FilterMode.BLOCK_LIST)); } filters.addAll(customFilters); - SsrfDnsResolver dnsResolver = new SsrfDnsResolver(filters); + SsrfDnsResolver dnsResolver = new SsrfDnsResolver(filters, isReportOnly); if (this.clientType == ClientType.HTTP_CLIENT_5) { return buildHttpClient5(dnsResolver); diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java index a928d3fe800..7188fb0fbb0 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java @@ -25,14 +25,14 @@ class SsrfDnsResolver implements DnsResolver { - private static final Log logger = LogFactory.getLog(SsrfDnsResolver.class); - protected final List filters; + protected boolean reportOnly; - public SsrfDnsResolver(List filters) { + public SsrfDnsResolver(List filters, boolean reportOnly) { this.filters = filters; + this.reportOnly = reportOnly; } @Override @@ -49,13 +49,18 @@ public InetAddress[] resolve(final String host) throws UnknownHostException { // each filter can restrict the list of addresses resolved to a given host results = f.filterAddresses(results); } - return results; + return results; } catch (HostBlockedException e) { // log error as well, exception can't be chained logger.error("DNS resolution for '" + host + "' resulted in error", e); - throw new UnknownHostException( - "Access to " + host + " was blocked because it violates the SSRF protection config"); + if ( !reportOnly ) { + throw new UnknownHostException( + "Access to " + host + " was blocked because it violates the SSRF protection config"); + } else { + return cachedResult; + } } + } // Address resolution moved to a helper function for testing purposes diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilterTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilterTest.java index 3b93825cc6e..f3dd99005db 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilterTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilterTest.java @@ -13,8 +13,8 @@ public class BasicSsrfProtectionFilterTest { @Test - void testAllowInternalBlockExternal_internalAllowed() throws UnknownHostException, HostBlockedException { - BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_EXTERNAL, false); + void testAllowInternalBlockExternalWithInternalAllowed() throws UnknownHostException, HostBlockedException { + BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_EXTERNAL); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("10.0.0.1")}; InetAddress[] filtered = filter.filterAddresses(addresses); @@ -23,8 +23,8 @@ void testAllowInternalBlockExternal_internalAllowed() throws UnknownHostExceptio } @Test - void testAllowInternalBlockExternal_externalBlocked() throws UnknownHostException, HostBlockedException { - BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_EXTERNAL, false); + void testAllowInternalBlockExternalWithExternalBlocked() throws UnknownHostException, HostBlockedException { + BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_EXTERNAL); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; InetAddress[] filtered = filter.filterAddresses(addresses); @@ -33,15 +33,15 @@ void testAllowInternalBlockExternal_externalBlocked() throws UnknownHostExceptio } @Test - void testAllowInternalBlockExternal_allBlocked() throws UnknownHostException { - BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_EXTERNAL, false); + void testAllowInternalBlockExternalWithAllBlocked() throws UnknownHostException { + BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_EXTERNAL); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("8.8.8.8"), InetAddress.getByName("1.1.1.1")}; assertThrows(HostBlockedException.class, () -> filter.filterAddresses(addresses)); } @Test - void testBlockInternalAllowExternal_internalBlocked() throws UnknownHostException, HostBlockedException { - BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL, false); + void testBlockInternalAllowExternalWithInternalBlocked() throws UnknownHostException, HostBlockedException { + BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; InetAddress[] filtered = filter.filterAddresses(addresses); @@ -50,8 +50,8 @@ void testBlockInternalAllowExternal_internalBlocked() throws UnknownHostExceptio } @Test - void testBlockInternalAllowExternal_externalAllowed() throws UnknownHostException, HostBlockedException { - BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL, false); + void testBlockInternalAllowExternalWithExternalAllowed() throws UnknownHostException, HostBlockedException { + BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("8.8.8.8"), InetAddress.getByName("1.1.1.1")}; InetAddress[] filtered = filter.filterAddresses(addresses); assertEquals(2, filtered.length); @@ -59,8 +59,8 @@ void testBlockInternalAllowExternal_externalAllowed() throws UnknownHostExceptio } @Test - void testBlockInternalAllowExternal_allBlocked() throws UnknownHostException { - BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL, false); + void testBlockInternalAllowExternalWithAllBlocked() throws UnknownHostException { + BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("10.0.0.1")}; assertThrows(HostBlockedException.class, () -> filter.filterAddresses(addresses)); @@ -68,7 +68,7 @@ void testBlockInternalAllowExternal_allBlocked() throws UnknownHostException { @Test void testHostBlockedExceptionMessage() throws UnknownHostException { - BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL, false); + BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("10.0.0.1")}; HostBlockedException exception = assertThrows(HostBlockedException.class, @@ -78,14 +78,5 @@ void testHostBlockedExceptionMessage() throws UnknownHostException { assertTrue(exception.getMessage().contains("BLOCK_INTERNAL")); } - @Test - void testReportOnlyHostBlocking() throws UnknownHostException, HostBlockedException { - BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL, true); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), - InetAddress.getByName("10.0.0.1")}; - InetAddress[] filtered = filter.filterAddresses(addresses); - assertEquals(2, filtered.length); - - } } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/FilterUtilsTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/FilterUtilsTest.java index 80c9bcfff50..17a734338e5 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/FilterUtilsTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/FilterUtilsTest.java @@ -10,25 +10,25 @@ class FilterUtilsTest { @Test - public void testIsInternalIp_loopback() throws UnknownHostException { + public void testIsInternalIpOnLoopback() throws UnknownHostException { InetAddress addr = InetAddress.getByName("127.0.0.1"); assertTrue(FilterUtils.isInternalIp(addr)); } @Test - public void testIsInternalIp_ipv4_10x() throws UnknownHostException { + public void testIsInternalIpOnIpv4_10x() throws UnknownHostException { InetAddress addr = InetAddress.getByName("10.1.2.3"); assertTrue(FilterUtils.isInternalIp(addr)); } @Test - public void testIsInternalIp_ipv4_192x() throws UnknownHostException { + public void testIsInternalIpOnIpv4_192x() throws UnknownHostException { InetAddress addr = InetAddress.getByName("192.168.10.20"); assertTrue(FilterUtils.isInternalIp(addr)); } @Test - public void testIsInternalIp_ipv4_172x() throws UnknownHostException { + public void testIsInternalIpOnIpv4_172x() throws UnknownHostException { InetAddress addr = InetAddress.getByName("172.16.0.1"); assertTrue(FilterUtils.isInternalIp(addr)); } @@ -40,13 +40,13 @@ public void testIsInternalIp_ipv6() throws UnknownHostException { } @Test - public void testIsInternalIp_publicIpv4() throws UnknownHostException { + public void testIsInternalIpOnPublicIpv4() throws UnknownHostException { InetAddress addr = InetAddress.getByName("8.8.8.8"); assertFalse(FilterUtils.isInternalIp(addr)); } @Test - public void testIsInternalIp_publicIpv6() throws UnknownHostException { + public void testIsInternalIpOnPublicIpv6() throws UnknownHostException { InetAddress addr = InetAddress.getByName("2001:4860:4860::8888"); assertFalse(FilterUtils.isInternalIp(addr)); } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java index c591d112a19..817d6ff9008 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java @@ -13,10 +13,10 @@ public class ListedSsrfProtectionFilterTest { @Test - void testBlockList_blockedAddress() throws UnknownHostException, HostBlockedException { + void testBlockListWithBlockedAddress() throws UnknownHostException, HostBlockedException { List blockList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, - ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST, false); + ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; InetAddress[] filtered = filter.filterAddresses(addresses); @@ -25,10 +25,10 @@ void testBlockList_blockedAddress() throws UnknownHostException, HostBlockedExce } @Test - void testBlockList_allowedAddress() throws UnknownHostException, HostBlockedException { + void testBlockListWithAllowedAddress() throws UnknownHostException, HostBlockedException { List blockList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, - ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST, false); + ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.2"), InetAddress.getByName("8.8.8.8")}; InetAddress[] filtered = filter.filterAddresses(addresses); @@ -37,20 +37,20 @@ void testBlockList_allowedAddress() throws UnknownHostException, HostBlockedExce } @Test - void testBlockList_allBlocked() throws UnknownHostException { + void testBlockListWithAllBlocked() throws UnknownHostException { List blockList = List.of(new IpOrRange("192.168.1.0/24"), new IpOrRange("8.8.8.8")); ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, - ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST, false); + ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; assertThrows(HostBlockedException.class, () -> filter.filterAddresses(addresses)); } @Test - void testAllowList_allowedAddress() throws UnknownHostException, HostBlockedException { + void testAllowListWithAllowedAddress() throws UnknownHostException, HostBlockedException { List allowList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(allowList, - ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST, false); + ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; InetAddress[] filtered = filter.filterAddresses(addresses); @@ -59,23 +59,23 @@ void testAllowList_allowedAddress() throws UnknownHostException, HostBlockedExce } @Test - void testAllowList_blockedAddress() throws UnknownHostException, HostBlockedException { + void testAllowListWithBlockedAddress() throws UnknownHostException, HostBlockedException { List allowList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(allowList, - ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST, false); + ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.200"), InetAddress.getByName("8.8.8.8")}; - HostBlockedException ex = assertThrows(HostBlockedException.class, - () -> filter.filterAddresses(addresses), "This should throw an exception"); + HostBlockedException ex = assertThrows(HostBlockedException.class, () -> filter.filterAddresses(addresses), + "This should throw an exception"); assertTrue(ex.getMessage().contains("blocked due to violating ALLOW_LIST")); } @Test - void testAllowList_allBlocked() throws UnknownHostException { + void testAllowListWithAllBlocked() throws UnknownHostException { List allowList = List.of(new IpOrRange("172.16.0.0/16")); ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(allowList, - ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST, false); + ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), InetAddress.getByName("8.8.8.8")}; assertThrows(HostBlockedException.class, () -> filter.filterAddresses(addresses)); @@ -85,7 +85,7 @@ void testAllowList_allBlocked() throws UnknownHostException { void testHostBlockedExceptionMessage() throws UnknownHostException { List blockList = List.of(new IpOrRange("192.168.1.0/24")); ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, - ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST, false); + ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1")}; HostBlockedException exception = assertThrows(HostBlockedException.class, () -> filter.filterAddresses(addresses)); @@ -93,15 +93,4 @@ void testHostBlockedExceptionMessage() throws UnknownHostException { assertTrue(exception.getMessage().contains("BLOCK_LIST")); } - @Test - void testReportOnlyWhenBlockedException() throws UnknownHostException, HostBlockedException { - List blockList = List.of(new IpOrRange("192.168.1.0/24")); - ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, - ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST, true); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1")}; - - InetAddress[] filtered = filter.filterAddresses(addresses); - assertEquals(1, filtered.length); - assertTrue(Arrays.asList(filtered).containsAll(List.of(addresses))); - } } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java index 93844b3ba27..67828231a92 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java @@ -26,7 +26,7 @@ static class TestableSsrfDnsResolver extends SsrfDnsResolver { InetAddress[] addressesToReturn = null; public TestableSsrfDnsResolver(List filterList) { - super(filterList); + super(filterList, false); } @Override @@ -38,17 +38,22 @@ public void setFilters(List filterList) { filters.clear(); filters.addAll(filterList); } + + public void setReportOnly(boolean b) { + reportOnly = b; + } } @InjectMocks private TestableSsrfDnsResolver customDnsResolver = new TestableSsrfDnsResolver(new ArrayList<>()); @Test - void testResolve_validHost() throws UnknownHostException, HostBlockedException { + void testResolveWithValidHost() throws UnknownHostException, HostBlockedException { String host = "www.example.com"; InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("93.184.216.34")}; when(ssrfProtectionFilter.filterAddresses(addresses)).thenReturn(addresses); customDnsResolver.addressesToReturn = addresses; + customDnsResolver.setReportOnly(false); customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); InetAddress[] resolvedAddresses = customDnsResolver.resolve(host); @@ -58,12 +63,13 @@ void testResolve_validHost() throws UnknownHostException, HostBlockedException { } @Test - void testResolve_blockedHost() throws UnknownHostException, HostBlockedException { + void testResolveBlockedHost() throws UnknownHostException, HostBlockedException { String host = "192.168.1.1"; InetAddress[] addresses = new InetAddress[]{InetAddress.getByName(host)}; when(ssrfProtectionFilter.filterAddresses(addresses)).thenThrow(new HostBlockedException("Blocked")); customDnsResolver.addressesToReturn = addresses; customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); + customDnsResolver.setReportOnly(false); UnknownHostException exception = assertThrows(UnknownHostException.class, () -> customDnsResolver.resolve(host)); @@ -71,6 +77,22 @@ void testResolve_blockedHost() throws UnknownHostException, HostBlockedException } + @Test + void testResolveBlockedHostInRerpotOnlyMode() throws UnknownHostException, HostBlockedException { + String host = "192.168.1.1"; + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName(host)}; + when(ssrfProtectionFilter.filterAddresses(addresses)).thenThrow(new HostBlockedException("Blocked")); + customDnsResolver.addressesToReturn = addresses; + customDnsResolver.setReportOnly(true); + customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); + + InetAddress[] resolvedAddresses = customDnsResolver.resolve(host); + + assertEquals(1, resolvedAddresses.length); + assertEquals(addresses[0], resolvedAddresses[0]); + + } + @Test void testResolveCanonicalHostname() throws UnknownHostException { From 803268c6a1dbf61f3738252860d1a414be69a393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Fri, 17 Jan 2025 17:40:24 +0100 Subject: [PATCH 13/34] Adding Jetty client --- ssrf/build.gradle | 3 + .../security/web/client/ClientType.java | 3 +- ...nsResolver.java => HcSsrfDnsResolver.java} | 6 +- .../web/client/JettySsrfDnsResolver.java | 78 ++++++++ .../web/client/SecureRestTemplate.java | 98 ++++++++-- ...erTest.java => HcSsrfDnsResolverTest.java} | 11 +- .../web/client/JettySsrfDnsResolverTest.java | 115 ++++++++++++ .../security/web/client/UsageExample.java | 175 +++++++++++++++++- 8 files changed, 461 insertions(+), 28 deletions(-) rename ssrf/src/main/java/com/google/springframework/security/web/client/{SsrfDnsResolver.java => HcSsrfDnsResolver.java} (92%) create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/JettySsrfDnsResolver.java rename ssrf/src/test/java/com/google/springframework/security/web/client/{SsrfDnsResolverTest.java => HcSsrfDnsResolverTest.java} (91%) create mode 100644 ssrf/src/test/java/com/google/springframework/security/web/client/JettySsrfDnsResolverTest.java diff --git a/ssrf/build.gradle b/ssrf/build.gradle index cb7aa2d5d5f..8ca955e22f0 100644 --- a/ssrf/build.gradle +++ b/ssrf/build.gradle @@ -19,6 +19,7 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2' testImplementation 'org.mockito:mockito-core:5.14.2' testImplementation "org.mockito:mockito-junit-jupiter:5.14.2" + testImplementation 'org.springframework:spring-context:' + springVersion api 'org.springframework:spring-web:' + springVersion api 'org.springframework:spring-core:' + springVersion @@ -26,6 +27,8 @@ dependencies { implementation("org.apache.httpcomponents.client5:httpclient5:5.3.1") implementation("org.apache.httpcomponents.core5:httpcore5-reactive:5.2.5") + implementation 'org.eclipse.jetty:jetty-client:12.0.15' + implementation 'io.projectreactor:reactor-core:3.6.11' } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java index c8799c80601..9999c7f2a1f 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java @@ -16,5 +16,6 @@ package com.google.springframework.security.web.client; public enum ClientType { - HTTP_CLIENT_5 + HTTP_CLIENT_5, + JETTY_CLIENT } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/client/HcSsrfDnsResolver.java similarity index 92% rename from ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java rename to ssrf/src/main/java/com/google/springframework/security/web/client/HcSsrfDnsResolver.java index 7188fb0fbb0..990418450fe 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfDnsResolver.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/HcSsrfDnsResolver.java @@ -23,14 +23,14 @@ import org.apache.commons.logging.LogFactory; import org.apache.hc.client5.http.DnsResolver; -class SsrfDnsResolver implements DnsResolver { +class HcSsrfDnsResolver implements DnsResolver { - private static final Log logger = LogFactory.getLog(SsrfDnsResolver.class); + private static final Log logger = LogFactory.getLog(HcSsrfDnsResolver.class); protected final List filters; protected boolean reportOnly; - public SsrfDnsResolver(List filters, boolean reportOnly) { + public HcSsrfDnsResolver(List filters, boolean reportOnly) { this.filters = filters; this.reportOnly = reportOnly; } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/JettySsrfDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/client/JettySsrfDnsResolver.java new file mode 100644 index 00000000000..1358839f910 --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/JettySsrfDnsResolver.java @@ -0,0 +1,78 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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 com.google.springframework.security.web.client; + + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.eclipse.jetty.util.Promise; +import org.eclipse.jetty.util.SocketAddressResolver; + +class JettySsrfDnsResolver implements SocketAddressResolver { + + private static final Log logger = LogFactory.getLog(JettySsrfDnsResolver.class); + + protected final List filters; + protected boolean reportOnly; + + public JettySsrfDnsResolver(List filters, boolean reportOnly) { + this.filters = filters; + this.reportOnly = reportOnly; + } + + // Address resolution moved to a helper function for testing purposes + protected InetAddress[] resolveAll(String host) throws UnknownHostException { + return InetAddress.getAllByName(host); + } + + @Override + public void resolve(String host, int port, Promise> promise) { + + InetAddress[] addresses = null; + try { + addresses = resolveAll(host); + + InetAddress[] filteredAddresses = addresses; + for (SsrfProtectionFilter f : filters) { + filteredAddresses = f.filterAddresses(filteredAddresses); + } + + List socketAddresses = Arrays.stream(filteredAddresses) + .map(address -> new InetSocketAddress(address, port)).toList(); + + promise.succeeded(socketAddresses); + } catch (HostBlockedException e) { + + logger.error("DNS resolution for '" + host + "' resulted in error", e); + + if (reportOnly) { + List socketAddresses = Arrays.stream(addresses) + .map(address -> new InetSocketAddress(address, port)).toList(); + promise.succeeded(socketAddresses); + } else { + promise.failed(new UnknownHostException( + "Access to " + host + " was blocked because it violates the SSRF protection config")); + } + } catch (UnknownHostException e) { + promise.failed(e); + } + } +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java index 8570ed8e831..4a8ea1fbff5 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; @@ -29,7 +30,11 @@ import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; import org.apache.hc.core5.http.config.Registry; import org.apache.hc.core5.http.config.RegistryBuilder; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.util.SocketAddressResolver; +import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.client.JettyClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; public class SecureRestTemplate { @@ -84,56 +89,76 @@ public static class Builder { private List ipBlockList = new ArrayList<>(); private boolean isReportOnly = false; private NetworkMode networkMode = null; + + // Currently redundant due to changes around using a Jetty client. + // Keeping it for future use when more client types are added. private ClientType clientType = ClientType.HTTP_CLIENT_5; + private HttpClient jettyClient = null; + + + public Builder fromJettyClient(HttpClient jettyClient) { + this.jettyClient = jettyClient; + this.clientType = ClientType.JETTY_CLIENT; + return this; + } public Builder reportOnly(boolean isReportOnly) { this.isReportOnly = isReportOnly; return this; } - public Builder networkMode(NetworkMode mode) { - this.networkMode = mode; + public Builder networkMode(NetworkMode networkMode) { + this.networkMode = networkMode; return this; } public Builder withAllowlist(String... ipList) { - this.ipAllowList.addAll(List.of(ipList)); + ipAllowList.addAll(List.of(ipList)); return this; } public Builder withAllowlist(Iterable ipList) { - ipList.forEach(this.ipAllowList::add); + ipList.forEach(ipAllowList::add); return this; } public Builder withBlocklist(String... ipList) { - this.ipBlockList.addAll(List.of(ipList)); + ipBlockList.addAll(List.of(ipList)); return this; } public Builder withBlocklist(Iterable ipList) { - ipList.forEach(this.ipBlockList::add); + ipList.forEach(ipBlockList::add); return this; } public Builder withCustomFilter(SsrfProtectionFilter filter) { - this.customFilters.add(filter); + customFilters.add(filter); return this; } - public Builder withClient(ClientType clientType) { + // Reserved for future use, when adding more ClientTypes + // Currently HC5 is the default and Jetty Client needs to be externally provided + // because of cleanup issues + private Builder withClient(ClientType clientType) { + if (clientType == ClientType.JETTY_CLIENT) { + throw new IllegalStateException( + "The Builder was created from a Jetty HttpClient. Cannot change ClientType."); + } + this.clientType = clientType; return this; } - private RestTemplate buildHttpClient5(SsrfDnsResolver dnsResolver) { + private RestTemplate buildHttpClient5(HcSsrfDnsResolver dnsResolver) { Registry registry = RegistryBuilder.create() .register("http", PlainConnectionSocketFactory.getSocketFactory()) .register("https", SSLConnectionSocketFactory.getSocketFactory()).build(); - BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager(registry, null, null, + BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager(registry, null, + null, dnsResolver); CloseableHttpClient httpClient = HttpClientBuilder.create().setConnectionManager(connManager).build(); @@ -143,7 +168,13 @@ private RestTemplate buildHttpClient5(SsrfDnsResolver dnsResolver) { return new RestTemplate(requestFactory); } - public RestTemplate build() { + private RestTemplate buildJettyClient(JettySsrfDnsResolver dnsResolver) { + jettyClient.setSocketAddressResolver(dnsResolver); + JettyClientHttpRequestFactory requestFactory = new JettyClientHttpRequestFactory(jettyClient); + return new RestTemplate(requestFactory); + } + + private List buildFilters() { List filters = new ArrayList<>(); if (ipAllowList.size() != 0 && ipBlockList.size() != 0) { @@ -165,13 +196,50 @@ public RestTemplate build() { } filters.addAll(customFilters); + return filters; + } - SsrfDnsResolver dnsResolver = new SsrfDnsResolver(filters, isReportOnly); + public SocketAddressResolver buildJettyResolver() { + return new JettySsrfDnsResolver(buildFilters(), isReportOnly); + } - if (this.clientType == ClientType.HTTP_CLIENT_5) { - return buildHttpClient5(dnsResolver); + public DnsResolver buildHttpClientDnsResolver() { + return new HcSsrfDnsResolver(buildFilters(), isReportOnly); + } + + public ClientHttpRequestFactory buildHttpRequestFactory() { + if (clientType == ClientType.HTTP_CLIENT_5) { + // TODO(vaspori): deduplicate + Registry registry = RegistryBuilder.create() + .register("http", PlainConnectionSocketFactory.getSocketFactory()) + .register("https", SSLConnectionSocketFactory.getSocketFactory()).build(); + + BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager(registry, null, + null, + new HcSsrfDnsResolver(buildFilters(), isReportOnly)); + + CloseableHttpClient httpClient = HttpClientBuilder.create().setConnectionManager(connManager).build(); + + return new HttpComponentsClientHttpRequestFactory( + httpClient); + } else if (clientType == ClientType.JETTY_CLIENT) { + jettyClient.setSocketAddressResolver(new JettySsrfDnsResolver(buildFilters(), isReportOnly)); + return new JettyClientHttpRequestFactory(jettyClient); } else { - throw new IllegalArgumentException("Only HTTP_CLIENT_5 backed RestTemplates are supported for now"); + throw new IllegalArgumentException( + "Only HTTP_CLIENT_5 and Jetty backed RestTemplates are supported for now"); + } + } + + public RestTemplate build() { + + if (clientType == ClientType.HTTP_CLIENT_5) { + return buildHttpClient5(new HcSsrfDnsResolver(buildFilters(), isReportOnly)); + } else if (clientType == ClientType.JETTY_CLIENT) { + return buildJettyClient(new JettySsrfDnsResolver(buildFilters(), isReportOnly)); + } else { + throw new IllegalArgumentException( + "Only HTTP_CLIENT_5 and Jetty backed RestTemplates are supported for now"); } } } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/HcSsrfDnsResolverTest.java similarity index 91% rename from ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java rename to ssrf/src/test/java/com/google/springframework/security/web/client/HcSsrfDnsResolverTest.java index 67828231a92..9c3a5358842 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/SsrfDnsResolverTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/HcSsrfDnsResolverTest.java @@ -16,16 +16,16 @@ import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) -public class SsrfDnsResolverTest { +public class HcSsrfDnsResolverTest { @Mock private SsrfProtectionFilter ssrfProtectionFilter; - static class TestableSsrfDnsResolver extends SsrfDnsResolver { + static class TestableHcSsrfDnsResolver extends HcSsrfDnsResolver { InetAddress[] addressesToReturn = null; - public TestableSsrfDnsResolver(List filterList) { + public TestableHcSsrfDnsResolver(List filterList) { super(filterList, false); } @@ -34,6 +34,7 @@ protected InetAddress[] resolveAll(String host) throws UnknownHostException { return addressesToReturn; } + public void setFilters(List filterList) { filters.clear(); filters.addAll(filterList); @@ -45,7 +46,7 @@ public void setReportOnly(boolean b) { } @InjectMocks - private TestableSsrfDnsResolver customDnsResolver = new TestableSsrfDnsResolver(new ArrayList<>()); + private TestableHcSsrfDnsResolver customDnsResolver = new TestableHcSsrfDnsResolver(new ArrayList<>()); @Test void testResolveWithValidHost() throws UnknownHostException, HostBlockedException { @@ -96,7 +97,7 @@ void testResolveBlockedHostInRerpotOnlyMode() throws UnknownHostException, HostB @Test void testResolveCanonicalHostname() throws UnknownHostException { - String host = "www.example.com"; + String host = "localhost"; String resolvedHostname = customDnsResolver.resolveCanonicalHostname(host); assertEquals(host, resolvedHostname); } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/JettySsrfDnsResolverTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/JettySsrfDnsResolverTest.java new file mode 100644 index 00000000000..f71c6ea9508 --- /dev/null +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/JettySsrfDnsResolverTest.java @@ -0,0 +1,115 @@ +package com.google.springframework.security.web.client; + + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import org.eclipse.jetty.util.Promise; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class JettySsrfDnsResolverTest { + + @Mock + private SsrfProtectionFilter ssrfProtectionFilter; + + static class TestableJettySsrfDnsResolver extends JettySsrfDnsResolver { + + InetAddress[] addressesToReturn = null; + + public TestableJettySsrfDnsResolver(List filters) { + super(filters, false); + } + + @Override + protected InetAddress[] resolveAll(String host) throws UnknownHostException { + return addressesToReturn; + } + + public void setFilters(List filterList) { + filters.clear(); + filters.addAll(filterList); + } + + public void setReportOnly(boolean b) { + reportOnly = b; + } + } + + @InjectMocks + private TestableJettySsrfDnsResolver customDnsResolver = new TestableJettySsrfDnsResolver(new ArrayList<>()); + + @Test + void testResolveWithValidHost() + throws UnknownHostException, HostBlockedException, ExecutionException, InterruptedException { + String host = "www.example.com"; + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("93.184.216.34")}; + when(ssrfProtectionFilter.filterAddresses(addresses)).thenReturn(addresses); + customDnsResolver.addressesToReturn = addresses; + customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); + + Promise.Completable> promise = new Promise.Completable<>(); + + customDnsResolver.resolve(host, 80, promise); + + assertTrue(promise.isDone()); + List resolvedAddresses = promise.get(); + assertEquals(1, resolvedAddresses.size()); + assertEquals(addresses[0], resolvedAddresses.get(0).getAddress()); + assertEquals(80, resolvedAddresses.get(0).getPort()); + } + + @Test + void testResolveBlockedHostInRerpotOnlyMode() + throws UnknownHostException, HostBlockedException, ExecutionException, InterruptedException { + String host = "192.168.1.1"; + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName(host)}; + when(ssrfProtectionFilter.filterAddresses(addresses)).thenThrow(new HostBlockedException("Blocked")); + customDnsResolver.addressesToReturn = addresses; + customDnsResolver.setReportOnly(true); + customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); + + Promise.Completable> promise = new Promise.Completable<>(); + + customDnsResolver.resolve(host, 443, promise); + + assertTrue(promise.isDone()); + List resolvedAddresses = promise.get(); + + assertEquals(1, resolvedAddresses.size()); + assertEquals(addresses[0], resolvedAddresses.get(0).getAddress()); + assertEquals(443, resolvedAddresses.get(0).getPort()); + } + + @Test + void testResolveBlockedHost() + throws UnknownHostException, HostBlockedException, ExecutionException, InterruptedException { + String host = "192.168.1.1"; + InetAddress[] addresses = new InetAddress[]{InetAddress.getByName(host)}; + when(ssrfProtectionFilter.filterAddresses(addresses)).thenThrow(new HostBlockedException("Blocked")); + customDnsResolver.addressesToReturn = addresses; + customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); + customDnsResolver.setReportOnly(false); + + Promise.Completable> promise = new Promise.Completable<>(); + + customDnsResolver.resolve(host, 443, promise); + promise.whenComplete((res, exc) -> { + assertTrue(exc.getMessage().contains("was blocked")); + }); + assertTrue(promise.isCompletedExceptionally()); + } +} + + diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java index 0dc937e7e9d..504788abcf5 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java @@ -4,15 +4,174 @@ import java.net.InetAddress; import java.util.Arrays; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.TlsConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; +import org.apache.hc.client5.http.socket.ConnectionSocketFactory; +import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.core5.http.config.Registry; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.apache.hc.core5.util.Timeout; +import org.eclipse.jetty.client.HttpClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.client.JettyClientHttpRequestFactory; +import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; + public class UsageExample { + public static void example6() { + System.out.println("Example 6"); + + ClientHttpRequestFactory clientHttpRequestFactory = new SecureRestTemplate.Builder() + .reportOnly(true) // Log warning about blocking, but don't block + .networkMode(BLOCK_EXTERNAL) + .withCustomFilter( + addresses -> Arrays.stream(addresses).filter(a -> !a.isMCNodeLocal()) + .toArray(InetAddress[]::new)).withBlocklist("evil.com", "6.6.6.9/16", "123.123.123.123") + .buildHttpRequestFactory(); + + if (clientHttpRequestFactory instanceof HttpComponentsClientHttpRequestFactory) { + HttpComponentsClientHttpRequestFactory factory = (HttpComponentsClientHttpRequestFactory) clientHttpRequestFactory; + factory.setConnectTimeout(1000); + } + + // a secure RestTemplate can be still built + RestTemplate secureRestTemplate = new RestTemplate(clientHttpRequestFactory); + + try { + ResponseEntity result = secureRestTemplate.getForEntity("https://google.com", String.class); + System.out.println(result); + } catch (Exception e) { + // This should not run + System.err.println("Access blocked: " + e.getMessage()); + } + } + + public static void example5() { + System.out.println("Example 5"); + + // For SSRF prevention the "magic is built into the DNS resolver" + DnsResolver dnsResolver = new SecureRestTemplate.Builder() + .reportOnly(true) // Log warning about blocking, but don't block + .networkMode(BLOCK_EXTERNAL) + .withCustomFilter( + addresses -> Arrays.stream(addresses).filter(a -> !a.isMCNodeLocal()) + .toArray(InetAddress[]::new)).withBlocklist("evil.com", "6.6.6.9/16", "123.123.123.123") + .buildHttpClientDnsResolver(); + + // When a very custom client is needed + Registry registry = RegistryBuilder.create() + .register("http", PlainConnectionSocketFactory.getSocketFactory()) + .register("https", SSLConnectionSocketFactory.getSocketFactory()).build(); + + BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager(registry, null, + null, + dnsResolver); + + // with custom timeout + connManager.setConnectionConfig(ConnectionConfig.custom().setConnectTimeout(Timeout.ofMinutes(2)).build()); + // with custom TLS config + connManager.setTlsConfig(TlsConfig.custom().setSupportedCipherSuites("TLSv1-3").build()); + + CloseableHttpClient httpClient = HttpClientBuilder.create().setConnectionManager(connManager).build(); + + HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory( + httpClient); + + // a secure RestTemplate can be still built + RestTemplate secureRestTemplate = new RestTemplate(requestFactory); + + try { + ResponseEntity result = secureRestTemplate.getForEntity("https://google.com", String.class); + System.out.println(result); + } catch (Exception e) { + // This should not run + System.err.println("Access blocked: " + e.getMessage()); + } + } + + /** + * Showcasing the configuration to be used for a Jetty HttpClient based RestTemplate + */ + @Configuration + public static class ExampleConfig4 { + + @Bean + HttpClient jettyClient() { + return new HttpClient(); + } + + @Bean + JettyClientHttpRequestFactory jettyClientHttpRequestFactory(HttpClient jettyClient) { + // This bean manages the lifecycle of (starts/stops) the HttpClient + return new JettyClientHttpRequestFactory(jettyClient); + } + + @Bean("secureRestTemplate") + RestTemplate secureRestTemplate(HttpClient jettyClient) { + return new SecureRestTemplate.Builder() + .fromJettyClient(jettyClient) + .reportOnly(true) // Log warning about blocking, but don't block + .networkMode(BLOCK_EXTERNAL) + .withCustomFilter(addresses -> Arrays.stream(addresses).filter(a -> !a.isMCNodeLocal()) + .toArray(InetAddress[]::new)).withBlocklist("evil.com", "6.6.6.9/16", "123.123.123.123") + .build(); + } + } + + @Component + public static class Example4App { + + @Autowired + RestTemplate secureRestTemplate; + + public void run() { + + try { + ResponseEntity result = secureRestTemplate.getForEntity("https://google.com", String.class); + System.out.println(result); + } catch (Exception e) { + // This should not run + System.err.println("Access blocked: " + e.getMessage()); + } + + } + } + + public static void example4() { + System.out.println("Example 4"); + // Barebone spring application to demonstrate Jetty client usage above + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); + ctx.register(ExampleConfig4.class); + ctx.register(Example4App.class); + ctx.refresh(); + ctx.start(); + + Example4App app = ctx.getBean(Example4App.class); + app.run(); + + ctx.close(); + } + + public static void example3() { - RestTemplate exampleTemplate = new SecureRestTemplate.Builder().reportOnly( - true) // Log warning about blocking, but don't block - .networkMode(BLOCK_EXTERNAL).withCustomFilter( + System.out.println("Example 3"); + RestTemplate exampleTemplate = new SecureRestTemplate.Builder() + .reportOnly(true) // Log warning about blocking, but don't block + .networkMode(BLOCK_EXTERNAL) + .withCustomFilter( addresses -> Arrays.stream(addresses).filter(a -> !a.isMCNodeLocal()) .toArray(InetAddress[]::new)).withBlocklist("evil.com", "6.6.6.9/16", "123.123.123.123") .build(); @@ -26,8 +185,12 @@ public static void example3() { } } + public static void example2() { - RestTemplate exampleTemplate = new SecureRestTemplate.Builder().networkMode(BLOCK_EXTERNAL).build(); + System.out.println("Example 2"); + RestTemplate exampleTemplate = new SecureRestTemplate.Builder(). + networkMode(BLOCK_EXTERNAL) + .build(); try { exampleTemplate.getForEntity("https://google.com", String.class); @@ -41,6 +204,7 @@ public static void example2() { } public static void example1() { + System.out.println("Example 1"); // run with `-Dssrf.protection.mode=deny_list -Dssrf.protection.iplist=127.0.0.1,192.168.0.0/16` // if the properties are not set accordingly it will fail with IllegalStateException @@ -56,6 +220,9 @@ public static void main(String[] args) { example1(); example2(); example3(); + example4(); + example5(); + example6(); } } From 6e25d598e8a81ef93cf8983377fca2d2cee6a32d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Wed, 22 Jan 2025 16:15:04 +0100 Subject: [PATCH 14/34] UsageExample updated, RestClient example added --- .../security/web/client/UsageExample.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java index 504788abcf5..959669bb6f2 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java @@ -1,6 +1,7 @@ package com.google.springframework.security.web.client; import static com.google.springframework.security.web.client.NetworkMode.BLOCK_EXTERNAL; +import static org.springframework.http.MediaType.TEXT_HTML; import java.net.InetAddress; import java.util.Arrays; @@ -26,11 +27,28 @@ import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.client.JettyClientHttpRequestFactory; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; import org.springframework.web.client.RestTemplate; public class UsageExample { + public static void example7() { + System.out.println("Example 7"); + RestClient exampleClient = RestClient.create(new SecureRestTemplate.Builder(). + networkMode(BLOCK_EXTERNAL).build()); + + try { + exampleClient.get() + .uri("https://google.com") + .accept(TEXT_HTML) + .retrieve() + .body(String.class); + } catch (Exception e) { + System.err.println("Access blocked: " + e.getMessage()); + } + } + public static void example6() { System.out.println("Example 6"); @@ -223,7 +241,9 @@ public static void main(String[] args) { example4(); example5(); example6(); + example7(); } + } From 466e2c52c12c021a09856737d3e26c4751291ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Wed, 5 Feb 2025 16:36:06 +0100 Subject: [PATCH 15/34] Code cleanup and documentation --- .../web/client/SecureRestTemplate.java | 49 ++++++++----------- .../security/web/client/UsageExample.java | 4 +- 2 files changed, 22 insertions(+), 31 deletions(-) diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java index 4a8ea1fbff5..ad526398732 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java @@ -37,6 +37,11 @@ import org.springframework.http.client.JettyClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; +/** + * SecureRestTemplate provides a way to create a RestTemplate which protects against unintentional network access + * and provides mitigations against Server Side Resource Forgery via DNS rebinding. + * + */ public class SecureRestTemplate { /** @@ -151,30 +156,29 @@ private Builder withClient(ClientType clientType) { return this; } - private RestTemplate buildHttpClient5(HcSsrfDnsResolver dnsResolver) { + private ClientHttpRequestFactory makeHttpClient5(HcSsrfDnsResolver dnsResolver) { Registry registry = RegistryBuilder.create() .register("http", PlainConnectionSocketFactory.getSocketFactory()) .register("https", SSLConnectionSocketFactory.getSocketFactory()).build(); - BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager(registry, null, - null, + BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager(registry, null, null, dnsResolver); CloseableHttpClient httpClient = HttpClientBuilder.create().setConnectionManager(connManager).build(); HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory( httpClient); - return new RestTemplate(requestFactory); + return requestFactory; } - private RestTemplate buildJettyClient(JettySsrfDnsResolver dnsResolver) { + private ClientHttpRequestFactory makeJettyClient(JettySsrfDnsResolver dnsResolver) { jettyClient.setSocketAddressResolver(dnsResolver); JettyClientHttpRequestFactory requestFactory = new JettyClientHttpRequestFactory(jettyClient); - return new RestTemplate(requestFactory); + return requestFactory; } - private List buildFilters() { + private List makeFilters() { List filters = new ArrayList<>(); if (ipAllowList.size() != 0 && ipBlockList.size() != 0) { @@ -199,31 +203,19 @@ private List buildFilters() { return filters; } - public SocketAddressResolver buildJettyResolver() { - return new JettySsrfDnsResolver(buildFilters(), isReportOnly); + public SocketAddressResolver buildToJettyResolver() { + return new JettySsrfDnsResolver(makeFilters(), isReportOnly); } - public DnsResolver buildHttpClientDnsResolver() { - return new HcSsrfDnsResolver(buildFilters(), isReportOnly); + public DnsResolver buildToHttpClientDnsResolver() { + return new HcSsrfDnsResolver(makeFilters(), isReportOnly); } - public ClientHttpRequestFactory buildHttpRequestFactory() { + public ClientHttpRequestFactory buildToHttpRequestFactory() { if (clientType == ClientType.HTTP_CLIENT_5) { - // TODO(vaspori): deduplicate - Registry registry = RegistryBuilder.create() - .register("http", PlainConnectionSocketFactory.getSocketFactory()) - .register("https", SSLConnectionSocketFactory.getSocketFactory()).build(); - - BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager(registry, null, - null, - new HcSsrfDnsResolver(buildFilters(), isReportOnly)); - - CloseableHttpClient httpClient = HttpClientBuilder.create().setConnectionManager(connManager).build(); - - return new HttpComponentsClientHttpRequestFactory( - httpClient); + return makeHttpClient5(new HcSsrfDnsResolver(makeFilters(), isReportOnly)); } else if (clientType == ClientType.JETTY_CLIENT) { - jettyClient.setSocketAddressResolver(new JettySsrfDnsResolver(buildFilters(), isReportOnly)); + jettyClient.setSocketAddressResolver(new JettySsrfDnsResolver(makeFilters(), isReportOnly)); return new JettyClientHttpRequestFactory(jettyClient); } else { throw new IllegalArgumentException( @@ -232,11 +224,10 @@ public ClientHttpRequestFactory buildHttpRequestFactory() { } public RestTemplate build() { - if (clientType == ClientType.HTTP_CLIENT_5) { - return buildHttpClient5(new HcSsrfDnsResolver(buildFilters(), isReportOnly)); + return new RestTemplate(makeHttpClient5(new HcSsrfDnsResolver(makeFilters(), isReportOnly))); } else if (clientType == ClientType.JETTY_CLIENT) { - return buildJettyClient(new JettySsrfDnsResolver(buildFilters(), isReportOnly)); + return new RestTemplate(makeJettyClient(new JettySsrfDnsResolver(makeFilters(), isReportOnly))); } else { throw new IllegalArgumentException( "Only HTTP_CLIENT_5 and Jetty backed RestTemplates are supported for now"); diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java index 959669bb6f2..4544fa74920 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java @@ -58,7 +58,7 @@ public static void example6() { .withCustomFilter( addresses -> Arrays.stream(addresses).filter(a -> !a.isMCNodeLocal()) .toArray(InetAddress[]::new)).withBlocklist("evil.com", "6.6.6.9/16", "123.123.123.123") - .buildHttpRequestFactory(); + .buildToHttpRequestFactory(); if (clientHttpRequestFactory instanceof HttpComponentsClientHttpRequestFactory) { HttpComponentsClientHttpRequestFactory factory = (HttpComponentsClientHttpRequestFactory) clientHttpRequestFactory; @@ -87,7 +87,7 @@ public static void example5() { .withCustomFilter( addresses -> Arrays.stream(addresses).filter(a -> !a.isMCNodeLocal()) .toArray(InetAddress[]::new)).withBlocklist("evil.com", "6.6.6.9/16", "123.123.123.123") - .buildHttpClientDnsResolver(); + .buildToHttpClientDnsResolver(); // When a very custom client is needed Registry registry = RegistryBuilder.create() From a340c5cbc4657e4d9b6d15938ec2608a228409fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Tue, 11 Feb 2025 15:45:26 +0100 Subject: [PATCH 16/34] Documentation added and minor cleanups regarding class visibility --- .../web/client/BasicSsrfProtectionFilter.java | 2 +- .../security/web/client/ClientType.java | 4 + .../security/web/client/FilterUtils.java | 2 +- .../web/client/HostBlockedException.java | 4 + .../client/ListedSsrfProtectionFilter.java | 2 +- .../security/web/client/NetworkMode.java | 4 + .../web/client/SecureRestTemplate.java | 106 +++++++++++++++++- .../web/client/SsrfProtectionFilter.java | 14 ++- 8 files changed, 128 insertions(+), 10 deletions(-) diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java index 5c870ffcbfb..8bddcee3cfa 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java @@ -22,7 +22,7 @@ import java.util.Arrays; import java.util.List; -public class BasicSsrfProtectionFilter implements SsrfProtectionFilter { +class BasicSsrfProtectionFilter implements SsrfProtectionFilter { private final NetworkMode mode; diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java index 9999c7f2a1f..408f8587180 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java @@ -15,6 +15,10 @@ */ package com.google.springframework.security.web.client; +/** + * Enum to be used in {@link com.google.springframework.security.web.client.SecureRestTemplate} to select + * the underlying HTTP client type. + */ public enum ClientType { HTTP_CLIENT_5, JETTY_CLIENT diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/FilterUtils.java b/ssrf/src/main/java/com/google/springframework/security/web/client/FilterUtils.java index 0b29741f92d..52841b91561 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/FilterUtils.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/FilterUtils.java @@ -17,7 +17,7 @@ import java.net.InetAddress; -public class FilterUtils { +class FilterUtils { public static boolean isInternalIp(InetAddress addr) { diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/HostBlockedException.java b/ssrf/src/main/java/com/google/springframework/security/web/client/HostBlockedException.java index 00463436197..ee256d7db39 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/HostBlockedException.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/HostBlockedException.java @@ -17,6 +17,10 @@ import java.io.IOException; +/** + * Exception thrown when a request violates the security criteria specified in a + * {@link com.google.springframework.security.web.client.SsrfProtectionFilter} + */ public class HostBlockedException extends IOException { private static final long serialVersionUID = 1; diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java index 53b635c84a0..3b5dd5e9e87 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java @@ -22,7 +22,7 @@ import java.util.Arrays; import java.util.List; -public class ListedSsrfProtectionFilter implements SsrfProtectionFilter { +class ListedSsrfProtectionFilter implements SsrfProtectionFilter { /** * FilterMode enum to make usage more intuitive ( practically this is just a bool ) diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/NetworkMode.java b/ssrf/src/main/java/com/google/springframework/security/web/client/NetworkMode.java index e5d87eb22c2..0e47cf9863d 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/NetworkMode.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/NetworkMode.java @@ -15,6 +15,10 @@ */ package com.google.springframework.security.web.client; +/** + * This specifies if the {@link com.google.springframework.security.web.client.SecureRestTemplate} should + * allow request to the local network only or only towards the internet only ( e.g. to prevent access to cloud VM metadata ). + */ public enum NetworkMode { BLOCK_EXTERNAL, BLOCK_INTERNAL diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java index ad526398732..5ac9995984b 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java @@ -38,19 +38,57 @@ import org.springframework.web.client.RestTemplate; /** - * SecureRestTemplate provides a way to create a RestTemplate which protects against unintentional network access - * and provides mitigations against Server Side Resource Forgery via DNS rebinding. - * + * SecureRestTemplate provides a way to create a RestTemplate which protects against unintentional network access and + * provides mitigations against Server Side Resource Forgery via DNS rebinding. Use the associated + * {@link com.google.springframework.security.web.client.SecureRestTemplate.Builder} to create new instances, it also + * provides a method to create the underlying {@link org.springframework.http.client.ClientHttpRequestFactory}. + * Currently two flavours are supported, backed by Apache HttpClient 5 or Jetty. */ public class SecureRestTemplate { /** - * Helper enum to make configuring with system properties easier + * Helper enum to make configuring with system properties easier, when using {@link #buildDefault()} + * + * @see #ALLOW_LIST + * @see #DENY_LIST + * @see #BLOCK_EXTERNAL + * @see #BLOCK_INTERNAL */ private enum ProtectionMode { - ALLOW_LIST, DENY_LIST, BLOCK_EXTERNAL, BLOCK_INTERNAL, + /** + * Use the ssrf.protection.iplist property in {@link #buildDefault()} as an allow-list. + */ + ALLOW_LIST, + /** + * Use the ssrf.protection.iplist property in {@link #buildDefault()} as a deny-list. + */ + DENY_LIST, + + /** + * Block requests directed towards the non-local, non-loopback addresses. + */ + BLOCK_EXTERNAL, + + /** + * Block requests directed towards the local or loopback addresses. + */ + BLOCK_INTERNAL, } + /** + * Build {@link com.google.springframework.security.web.client.SecureRestTemplate} based on JVM global system + * properties. The following properties can be used to configured: + *

    + *
  • ssrf.protection.mode
  • Mandatory property to specify + * {@link com.google.springframework.security.web.client.SecureRestTemplate.ProtectionMode} if you would like to use this method. + *
  • ssrf.protection.iplist
  • + * The list of ip addresses or hostnames to use for allow-listing/block-listing based on the property above when it's set to + * ALLOW_LIST or DENY_LIST. + *
  • ssrf.protection.report_only
  • If set, request are not blocked just logged. Only the existence of the property is checked, the value is ignored. + * + *
+ */ + public static RestTemplate buildDefault() { String modeProperty = System.getProperty("ssrf.protection.mode"); @@ -85,6 +123,11 @@ public static RestTemplate buildDefault() { } + /** + * Builder class to create a {@link com.google.springframework.security.web.client.SecureRestTemplate} or an + * underlying {@link org.springframework.http.client.ClientHttpRequestFactory}. It also exposes ways to create the + * underlying DNS-resolvers which contain the heart of the protection logic. + */ public static class Builder { private List customFilters = new ArrayList<>(); @@ -100,6 +143,14 @@ public static class Builder { private ClientType clientType = ClientType.HTTP_CLIENT_5; private HttpClient jettyClient = null; + /** + * Create a {@link com.google.springframework.security.web.client.SecureRestTemplate} with a + * {@link org.eclipse.jetty.client.HttpClient} to use to make the requests. The lifetime of a + * {@link org.eclipse.jetty.client.HttpClient} should be handled by Spring (or manually) + * {@see UsageExample.java} + * + * @param jettyClient the HttpClient to use + */ public Builder fromJettyClient(HttpClient jettyClient) { this.jettyClient = jettyClient; @@ -107,37 +158,60 @@ public Builder fromJettyClient(HttpClient jettyClient) { return this; } + /** + * When set to true rule violating requests are not blocked only logged. + */ public Builder reportOnly(boolean isReportOnly) { this.isReportOnly = isReportOnly; return this; } + /** + * Set mode do block requests towards the internet or block requests towards the internet. + */ public Builder networkMode(NetworkMode networkMode) { this.networkMode = networkMode; return this; } + /** + * List of ip-addresses or hostnames to use in an allow-list. + */ public Builder withAllowlist(String... ipList) { ipAllowList.addAll(List.of(ipList)); return this; } + /** + * List of ip-addresses or hostnames to use in an allow-list. + */ public Builder withAllowlist(Iterable ipList) { ipList.forEach(ipAllowList::add); return this; } + + /** + * List of ip-addresses or hostnames to use in an block-list. + */ public Builder withBlocklist(String... ipList) { ipBlockList.addAll(List.of(ipList)); return this; } + /** + * List of ip-addresses or hostnames to use in an block-list. + */ public Builder withBlocklist(Iterable ipList) { ipList.forEach(ipBlockList::add); return this; } - + /** + * When very specific criteria are needed to block or allow a request a custom + * {@link com.google.springframework.security.web.client.SsrfProtectionFilter} implementation can be plugged + * in. + */ public Builder withCustomFilter(SsrfProtectionFilter filter) { customFilters.add(filter); return this; @@ -203,14 +277,30 @@ private List makeFilters() { return filters; } + /** + * Helper method to create the Jetty specific DNS resolver used in an underlying + * {@link org.eclipse.jetty.client.HttpClient} for request filtering. + * + * @return the custom resolver which implements the filtering and DNS rebinding protection logic. + */ public SocketAddressResolver buildToJettyResolver() { return new JettySsrfDnsResolver(makeFilters(), isReportOnly); } + /** + * Helper method to create the DNS resolver used in an underlying + * {@link org.apache.hc.client5.http.impl.classic.CloseableHttpClient} for request filtering. + * + * @return the custom resolver which implements the filtering and DNS rebinding protection logic. + */ public DnsResolver buildToHttpClientDnsResolver() { return new HcSsrfDnsResolver(makeFilters(), isReportOnly); } + /** + * Helper method to create a {@link org.springframework.http.client.ClientHttpRequestFactory} for more + * customization before creating a RestTemplate or RestClient. + */ public ClientHttpRequestFactory buildToHttpRequestFactory() { if (clientType == ClientType.HTTP_CLIENT_5) { return makeHttpClient5(new HcSsrfDnsResolver(makeFilters(), isReportOnly)); @@ -223,6 +313,10 @@ public ClientHttpRequestFactory buildToHttpRequestFactory() { } } + /** + * Create the {@link com.google.springframework.security.web.client.SecureRestTemplate} configured by this + * builder. + */ public RestTemplate build() { if (clientType == ClientType.HTTP_CLIENT_5) { return new RestTemplate(makeHttpClient5(new HcSsrfDnsResolver(makeFilters(), isReportOnly))); diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java index 5af375384cb..080f1ac0154 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java @@ -17,9 +17,21 @@ import java.net.InetAddress; - +/** + * The interface which is used to implement all filtering logic in the DNS resolvers used by {@link SecureRestTemplate}. + */ public interface SsrfProtectionFilter { + /** + * Because a hostname can be resolved to multiple addresses ( e.g. round-robin DNS ) all implementations + * must check that all the address conform to their internal filtering logic. + * + * @param addresses list addresses to checked against a filtering criteria. + * @return the list of InetAddress that pass through the filter + * + * @throws HostBlockedException when there are no addresses that pass the filtering criteria this exception should be thrown. + */ + InetAddress[] filterAddresses(final InetAddress[] addresses) throws HostBlockedException; } From ac5b9274c3a85ef225e97867b3ae90e32db63808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Fri, 14 Feb 2025 17:56:50 +0100 Subject: [PATCH 17/34] Minor fixes based on review feedback, string formatting, IPV6 filtering --- .../web/client/BasicSsrfProtectionFilter.java | 5 +++-- .../security/web/client/FilterUtils.java | 17 +++++++++++++++-- .../security/web/client/IpOrRange.java | 4 ++-- .../web/client/JettySsrfDnsResolver.java | 3 ++- .../web/client/ListedSsrfProtectionFilter.java | 5 +++-- .../security/web/client/SecureRestTemplate.java | 2 +- .../security/web/client/FilterUtilsTest.java | 12 ++++++++++++ 7 files changed, 38 insertions(+), 10 deletions(-) diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java index 8bddcee3cfa..57fb8814c5c 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java @@ -47,10 +47,11 @@ public InetAddress[] filterAddresses(InetAddress[] addresses) throws HostBlocked } - if (result.size() == 0) { + if (result.isEmpty()) { String addrFmt = Arrays.stream(addresses).map(InetAddress::toString).collect(joining(", ")); throw new HostBlockedException( - "The following address(es) were blocked due to violating " + mode.name() + " policy: " + addrFmt); + String.format("The following address(es) were blocked due to violating %s, policy: %s", mode.name(), + addrFmt)); } return result.toArray(new InetAddress[]{}); diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/FilterUtils.java b/ssrf/src/main/java/com/google/springframework/security/web/client/FilterUtils.java index 52841b91561..4a16b26f8ea 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/FilterUtils.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/FilterUtils.java @@ -26,11 +26,13 @@ public static boolean isInternalIp(InetAddress addr) { } byte[] rawAddress = addr.getAddress(); + // there is sadly no Stream support for byte arrays int[] iAddr = new int[rawAddress.length]; for (int i = 0; i < rawAddress.length; i++) { iAddr[i] = Byte.toUnsignedInt(rawAddress[i]); } + // Ignoring Multicast addresses if (addr.getAddress().length == 4) { // IPv4 filtering @@ -42,10 +44,21 @@ public static boolean isInternalIp(InetAddress addr) { } } else if (addr.getAddress().length == 16) { - // IPv6 - if (iAddr[0] == 0xfd && iAddr[1] == 0x00) { + // IPv6, check for Unique Local Addresses + if (iAddr[0] == 0xfc || iAddr[0] == 0xfd) { return true; } + + // IPv4/IPv6 translation, 64:ff9b + if (iAddr[0] == 0x00 && iAddr[1] == 0x64 && iAddr[2] == 0xff && iAddr[3] == 0x9b) { + int[] ipv4Part = new int[]{iAddr[12], iAddr[13], iAddr[14], iAddr[15]}; + // same check as above plus a check for loopback + if (ipv4Part[0] == 10 || ipv4Part[0] == 127 || + (ipv4Part[0] == 192 && ipv4Part[1] == 168) || + (ipv4Part[0] == 172 && ipv4Part[1] == 16)) { + return true; + } + } } return false; } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java b/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java index 55170006710..197e62f28a7 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java @@ -24,7 +24,7 @@ * Class to represent and IPv4 or IPv6 range to be used in filtering. Inspired by: * org.springframework.security.web.util.matcher.IpAddressMatcher.java */ -public class IpOrRange { +public final class IpOrRange { private static final Log logger = LogFactory.getLog(IpOrRange.class); private final InetAddress address; @@ -71,7 +71,7 @@ private InetAddress parseAddress(String address) { } return result; } catch (UnknownHostException ex) { - throw new IllegalArgumentException("Failed to parse address '" + address + "'", ex); + throw new IllegalArgumentException(String.format("Failed to parse address '%s'", address), ex); } } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/JettySsrfDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/client/JettySsrfDnsResolver.java index 1358839f910..3c1d8489588 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/JettySsrfDnsResolver.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/JettySsrfDnsResolver.java @@ -69,7 +69,8 @@ public void resolve(String host, int port, Promise> prom promise.succeeded(socketAddresses); } else { promise.failed(new UnknownHostException( - "Access to " + host + " was blocked because it violates the SSRF protection config")); + String.format("Access to '%s' was blocked because it violates the SSRF protection config", + host))); } } catch (UnknownHostException e) { promise.failed(e); diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java index 3b5dd5e9e87..0708942432a 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java @@ -64,10 +64,11 @@ public InetAddress[] filterAddresses(InetAddress[] addresses) throws HostBlocked } } - if (result.size() == 0) { + if (result.isEmpty()) { String addrFmt = Arrays.stream(addresses).map(InetAddress::toString).collect(joining(", ")); throw new HostBlockedException( - "The following address(es) were blocked due to violating " + mode.name() + " policy: " + addrFmt); + String.format("The following address(es) were blocked due to violating %s , policy: %s", + mode.name(), addrFmt)); } return result.toArray(new InetAddress[]{}); diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java index 5ac9995984b..a00a6b3c221 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java @@ -269,7 +269,7 @@ private List makeFilters() { FilterMode.ALLOW_LIST)); } if (ipBlockList.size() > 0) { - filters.add(new ListedSsrfProtectionFilter(ipAllowList.stream().map(IpOrRange::new).collect(toList()), + filters.add(new ListedSsrfProtectionFilter(ipBlockList.stream().map(IpOrRange::new).collect(toList()), FilterMode.BLOCK_LIST)); } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/FilterUtilsTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/FilterUtilsTest.java index 17a734338e5..dd6ba633ec8 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/FilterUtilsTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/FilterUtilsTest.java @@ -51,4 +51,16 @@ public void testIsInternalIpOnPublicIpv6() throws UnknownHostException { assertFalse(FilterUtils.isInternalIp(addr)); } + @Test + public void testIsInternalUniqueLocalAddresses() throws UnknownHostException { + InetAddress addr = InetAddress.getByName("fcaa::4860:4860:8888"); + assertTrue(FilterUtils.isInternalIp(addr)); + } + + @Test + public void testIsTranslatedLocalAddress() throws UnknownHostException { + InetAddress addr = InetAddress.getByName("0064:ff9b::127.0.0.1"); + assertTrue(FilterUtils.isInternalIp(addr)); + } + } From 5bf2f93719aed8b14b6ab4c58db294ff34a4404c Mon Sep 17 00:00:00 2001 From: Kian Jamali Date: Tue, 18 Feb 2025 23:51:02 +0000 Subject: [PATCH 18/34] Adding Netty client --- ssrf/build.gradle | 16 ++ .../security/web/client/ClientType.java | 3 +- .../web/client/NettySsrfDnsResolver.java | 186 ++++++++++++++++++ .../web/client/SecureRestTemplate.java | 18 ++ 4 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java diff --git a/ssrf/build.gradle b/ssrf/build.gradle index 8ca955e22f0..9eb195d379a 100644 --- a/ssrf/build.gradle +++ b/ssrf/build.gradle @@ -30,6 +30,22 @@ dependencies { implementation 'org.eclipse.jetty:jetty-client:12.0.15' implementation 'io.projectreactor:reactor-core:3.6.11' + + // Core Netty functionality (EventLoop, Channel, etc.) + implementation 'io.netty:netty-common:4.1.107.Final' // Use latest version + implementation 'io.netty:netty-buffer:4.1.107.Final' + implementation 'io.netty:netty-transport:4.1.107.Final' + + // Netty's DNS resolver (REQUIRED NOW) + implementation 'io.netty:netty-resolver-dns:4.1.107.Final' + + // Spring WebFlux (REQUIRED for WebClient, make available to tests) + api "org.springframework:spring-webflux:$springVersion" + testImplementation "org.springframework:spring-webflux:$springVersion" // Add this line + implementation "org.springframework:spring-context:$springVersion" + + // Reactor Netty HTTP Client (REQUIRED for reactor.netty.http.client.HttpClient) + implementation 'io.projectreactor.netty:reactor-netty-http:1.1.15' // Use latest version } tasks.named('test') { diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java index 408f8587180..87f7d081edd 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java @@ -21,5 +21,6 @@ */ public enum ClientType { HTTP_CLIENT_5, - JETTY_CLIENT + JETTY_CLIENT, + NETTY_CLIENT } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java new file mode 100644 index 00000000000..e4e5b0c02db --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java @@ -0,0 +1,186 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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 com.google.springframework.security.web.client; + +import io.netty.resolver.AddressResolver; +import io.netty.resolver.AddressResolverGroup; +import io.netty.resolver.DefaultAddressResolverGroup; +import io.netty.util.concurrent.*; +import java.net.InetSocketAddress; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + + +public class NettySsrfDnsResolver extends AddressResolverGroup { + + private static final Log logger = LogFactory.getLog(NettySsrfDnsResolver.class); + + private final List filters; + private final boolean reportOnly; + private final AddressResolverGroup defaultResolverGroup; + + public NettySsrfDnsResolver(List filters, boolean reportOnly) { + this(filters, reportOnly, DefaultAddressResolverGroup.INSTANCE); + } + + // For testing + protected NettySsrfDnsResolver(List filters, boolean reportOnly, + AddressResolverGroup defaultResolverGroup) { + this.filters = filters; + this.reportOnly = reportOnly; + this.defaultResolverGroup = defaultResolverGroup; + } + + @Override + protected AddressResolver newResolver(EventExecutor executor) { + return new AddressResolver() { + private final AddressResolver resolver = defaultResolverGroup.getResolver(executor); + + @Override + public boolean isSupported(SocketAddress address) { + if (address instanceof InetSocketAddress) { + return resolver.isSupported((InetSocketAddress) address); + } + return false; + } + + @Override + public boolean isResolved(SocketAddress address) { + return resolver.isResolved(address); + } + + @Override + public Future resolve(SocketAddress address) { + return resolver.resolve(address); + } + + @Override + public Future resolve(SocketAddress address, + Promise promise) { + return resolver.resolve(address, promise); + } + + @Override + public Future> resolveAll(SocketAddress address) { + if (address instanceof InetSocketAddress inetSocketAddress && inetSocketAddress.isUnresolved()) { + // This is where we apply our SSRF filtering. + return resolveAllUnresolved(inetSocketAddress.getHostName(), inetSocketAddress.getPort(), executor.newPromise()); + } + // If it's already resolved or not an InetSocketAddress, use the default resolver. + if(address instanceof InetSocketAddress) { + return resolver.resolveAll((InetSocketAddress) address); + } + return executor.newFailedFuture(new IllegalArgumentException("Unsupported address type: " + address.getClass())); + } + + @Override + public Future> resolveAll(SocketAddress address, Promise> promise) { + if (address instanceof InetSocketAddress inetSocketAddress && inetSocketAddress.isUnresolved()) { + return resolveAllUnresolved(inetSocketAddress.getHostName(), inetSocketAddress.getPort(), promise); + } + if(address instanceof InetSocketAddress){ + return resolver.resolveAll((InetSocketAddress) address, promise); + } + return promise.setFailure(new IllegalArgumentException("Unsupported address type: " + address.getClass())); + + } + + + // Helper method to handle the actual filtering logic (for unresolved addresses) + private Future> resolveAllUnresolved(String host, int port, Promise> promise) { + Future> future; + try{ + future = resolveWithDefaultResolver(host, port); + } catch(UnknownHostException e){ + return promise.setFailure(e); + } + + future.addListener((FutureListener>) f -> { + if (f.isSuccess()) { + // 1. Get the resolved addresses from the default resolver + List resolvedAddresses = f.getNow(); + + // 2. Convert to InetAddress array (for your filter interface) + InetAddress[] inetAddresses = resolvedAddresses.stream() + .map(InetSocketAddress::getAddress) + .toArray(InetAddress[]::new); + + // 3. Apply SSRF filters + try { + InetAddress[] filteredAddresses = inetAddresses; + for (SsrfProtectionFilter filter : filters) { + filteredAddresses = filter.filterAddresses(filteredAddresses); + } + + // 4. Convert back to InetSocketAddress list + List filteredSocketAddresses = Arrays.stream(filteredAddresses) + .map(addr -> new InetSocketAddress(addr, port)) // Use original port + .toList(); + + // 5. Fulfill the promise with the FILTERED results + promise.setSuccess(filteredSocketAddresses); + + } catch (HostBlockedException e) { + logger.error("DNS resolution for '" + host + "' blocked by SSRF filter", e); + if (reportOnly) { + // In report-only mode, we still succeed with the *original* addresses + promise.setSuccess(resolvedAddresses); + } else { + // Block the resolution + promise.setFailure(new UnknownHostException( + String.format("Access to '%s' was blocked: %s", host, e.getMessage()))); + } + } + } else { + // If the default resolver failed, propagate the failure + promise.setFailure(f.cause()); + } + }); + + return promise; // Return the promise to the caller + } + + + @Override + public void close() { + resolver.close(); // Close the underlying default resolver + } + }; + } + + //for testing + Future> resolveWithDefaultResolver(String host, int port) throws UnknownHostException { + // Use DefaultAddressResolverGroup to perform the actual DNS lookup + InetSocketAddress unresolvedAddress = InetSocketAddress.createUnresolved(host, port); + + //This is just to check to see if the default resolver will throw an exception + resolveAll(host); + + return defaultResolverGroup.getResolver(ImmediateEventExecutor.INSTANCE).resolveAll(unresolvedAddress); + } + + //for testing + protected InetAddress[] resolveAll(String host) throws UnknownHostException { + return InetAddress.getAllByName(host); + } +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java index a00a6b3c221..5414d73fe4f 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java @@ -18,6 +18,7 @@ import static java.util.stream.Collectors.toList; import com.google.springframework.security.web.client.ListedSsrfProtectionFilter.FilterMode; +import io.netty.resolver.AddressResolverGroup; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -35,6 +36,8 @@ import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.client.JettyClientHttpRequestFactory; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.client.RestTemplate; /** @@ -142,6 +145,7 @@ public static class Builder { // Keeping it for future use when more client types are added. private ClientType clientType = ClientType.HTTP_CLIENT_5; private HttpClient jettyClient = null; + private reactor.netty.http.client.HttpClient nettyClient = null; /** * Create a {@link com.google.springframework.security.web.client.SecureRestTemplate} with a @@ -158,6 +162,12 @@ public Builder fromJettyClient(HttpClient jettyClient) { return this; } + public Builder fromNettyClient(reactor.netty.http.client.HttpClient nettyClient) { + this.nettyClient = nettyClient; + this.clientType = ClientType.NETTY_CLIENT; + return this; + } + /** * When set to true rule violating requests are not blocked only logged. */ @@ -297,6 +307,14 @@ public DnsResolver buildToHttpClientDnsResolver() { return new HcSsrfDnsResolver(makeFilters(), isReportOnly); } + public ClientHttpConnector buildToClientHttpConnector() { + if (clientType != ClientType.NETTY_CLIENT) { + throw new IllegalStateException("buildToClientHttpConnector() can only be used with NETTY_CLIENT."); + } + NettySsrfDnsResolver nettyResolver = new NettySsrfDnsResolver(makeFilters(), isReportOnly); + return new ReactorClientHttpConnector(nettyClient.resolver(nettyResolver)); // Directly use resolver() + } + /** * Helper method to create a {@link org.springframework.http.client.ClientHttpRequestFactory} for more * customization before creating a RestTemplate or RestClient. From c9566b31ff845ae0399c0d30e12bae73981b7702 Mon Sep 17 00:00:00 2001 From: Kian Jamali Date: Tue, 18 Feb 2025 23:52:33 +0000 Subject: [PATCH 19/34] Revert "Adding Netty client" This reverts commit 5bf2f93719aed8b14b6ab4c58db294ff34a4404c. --- ssrf/build.gradle | 16 -- .../security/web/client/ClientType.java | 3 +- .../web/client/NettySsrfDnsResolver.java | 186 ------------------ .../web/client/SecureRestTemplate.java | 18 -- 4 files changed, 1 insertion(+), 222 deletions(-) delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java diff --git a/ssrf/build.gradle b/ssrf/build.gradle index 9eb195d379a..8ca955e22f0 100644 --- a/ssrf/build.gradle +++ b/ssrf/build.gradle @@ -30,22 +30,6 @@ dependencies { implementation 'org.eclipse.jetty:jetty-client:12.0.15' implementation 'io.projectreactor:reactor-core:3.6.11' - - // Core Netty functionality (EventLoop, Channel, etc.) - implementation 'io.netty:netty-common:4.1.107.Final' // Use latest version - implementation 'io.netty:netty-buffer:4.1.107.Final' - implementation 'io.netty:netty-transport:4.1.107.Final' - - // Netty's DNS resolver (REQUIRED NOW) - implementation 'io.netty:netty-resolver-dns:4.1.107.Final' - - // Spring WebFlux (REQUIRED for WebClient, make available to tests) - api "org.springframework:spring-webflux:$springVersion" - testImplementation "org.springframework:spring-webflux:$springVersion" // Add this line - implementation "org.springframework:spring-context:$springVersion" - - // Reactor Netty HTTP Client (REQUIRED for reactor.netty.http.client.HttpClient) - implementation 'io.projectreactor.netty:reactor-netty-http:1.1.15' // Use latest version } tasks.named('test') { diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java index 87f7d081edd..408f8587180 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java @@ -21,6 +21,5 @@ */ public enum ClientType { HTTP_CLIENT_5, - JETTY_CLIENT, - NETTY_CLIENT + JETTY_CLIENT } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java deleted file mode 100644 index e4e5b0c02db..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed 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 - * - * https://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 com.google.springframework.security.web.client; - -import io.netty.resolver.AddressResolver; -import io.netty.resolver.AddressResolverGroup; -import io.netty.resolver.DefaultAddressResolverGroup; -import io.netty.util.concurrent.*; -import java.net.InetSocketAddress; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.SocketAddress; -import java.net.UnknownHostException; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - - -public class NettySsrfDnsResolver extends AddressResolverGroup { - - private static final Log logger = LogFactory.getLog(NettySsrfDnsResolver.class); - - private final List filters; - private final boolean reportOnly; - private final AddressResolverGroup defaultResolverGroup; - - public NettySsrfDnsResolver(List filters, boolean reportOnly) { - this(filters, reportOnly, DefaultAddressResolverGroup.INSTANCE); - } - - // For testing - protected NettySsrfDnsResolver(List filters, boolean reportOnly, - AddressResolverGroup defaultResolverGroup) { - this.filters = filters; - this.reportOnly = reportOnly; - this.defaultResolverGroup = defaultResolverGroup; - } - - @Override - protected AddressResolver newResolver(EventExecutor executor) { - return new AddressResolver() { - private final AddressResolver resolver = defaultResolverGroup.getResolver(executor); - - @Override - public boolean isSupported(SocketAddress address) { - if (address instanceof InetSocketAddress) { - return resolver.isSupported((InetSocketAddress) address); - } - return false; - } - - @Override - public boolean isResolved(SocketAddress address) { - return resolver.isResolved(address); - } - - @Override - public Future resolve(SocketAddress address) { - return resolver.resolve(address); - } - - @Override - public Future resolve(SocketAddress address, - Promise promise) { - return resolver.resolve(address, promise); - } - - @Override - public Future> resolveAll(SocketAddress address) { - if (address instanceof InetSocketAddress inetSocketAddress && inetSocketAddress.isUnresolved()) { - // This is where we apply our SSRF filtering. - return resolveAllUnresolved(inetSocketAddress.getHostName(), inetSocketAddress.getPort(), executor.newPromise()); - } - // If it's already resolved or not an InetSocketAddress, use the default resolver. - if(address instanceof InetSocketAddress) { - return resolver.resolveAll((InetSocketAddress) address); - } - return executor.newFailedFuture(new IllegalArgumentException("Unsupported address type: " + address.getClass())); - } - - @Override - public Future> resolveAll(SocketAddress address, Promise> promise) { - if (address instanceof InetSocketAddress inetSocketAddress && inetSocketAddress.isUnresolved()) { - return resolveAllUnresolved(inetSocketAddress.getHostName(), inetSocketAddress.getPort(), promise); - } - if(address instanceof InetSocketAddress){ - return resolver.resolveAll((InetSocketAddress) address, promise); - } - return promise.setFailure(new IllegalArgumentException("Unsupported address type: " + address.getClass())); - - } - - - // Helper method to handle the actual filtering logic (for unresolved addresses) - private Future> resolveAllUnresolved(String host, int port, Promise> promise) { - Future> future; - try{ - future = resolveWithDefaultResolver(host, port); - } catch(UnknownHostException e){ - return promise.setFailure(e); - } - - future.addListener((FutureListener>) f -> { - if (f.isSuccess()) { - // 1. Get the resolved addresses from the default resolver - List resolvedAddresses = f.getNow(); - - // 2. Convert to InetAddress array (for your filter interface) - InetAddress[] inetAddresses = resolvedAddresses.stream() - .map(InetSocketAddress::getAddress) - .toArray(InetAddress[]::new); - - // 3. Apply SSRF filters - try { - InetAddress[] filteredAddresses = inetAddresses; - for (SsrfProtectionFilter filter : filters) { - filteredAddresses = filter.filterAddresses(filteredAddresses); - } - - // 4. Convert back to InetSocketAddress list - List filteredSocketAddresses = Arrays.stream(filteredAddresses) - .map(addr -> new InetSocketAddress(addr, port)) // Use original port - .toList(); - - // 5. Fulfill the promise with the FILTERED results - promise.setSuccess(filteredSocketAddresses); - - } catch (HostBlockedException e) { - logger.error("DNS resolution for '" + host + "' blocked by SSRF filter", e); - if (reportOnly) { - // In report-only mode, we still succeed with the *original* addresses - promise.setSuccess(resolvedAddresses); - } else { - // Block the resolution - promise.setFailure(new UnknownHostException( - String.format("Access to '%s' was blocked: %s", host, e.getMessage()))); - } - } - } else { - // If the default resolver failed, propagate the failure - promise.setFailure(f.cause()); - } - }); - - return promise; // Return the promise to the caller - } - - - @Override - public void close() { - resolver.close(); // Close the underlying default resolver - } - }; - } - - //for testing - Future> resolveWithDefaultResolver(String host, int port) throws UnknownHostException { - // Use DefaultAddressResolverGroup to perform the actual DNS lookup - InetSocketAddress unresolvedAddress = InetSocketAddress.createUnresolved(host, port); - - //This is just to check to see if the default resolver will throw an exception - resolveAll(host); - - return defaultResolverGroup.getResolver(ImmediateEventExecutor.INSTANCE).resolveAll(unresolvedAddress); - } - - //for testing - protected InetAddress[] resolveAll(String host) throws UnknownHostException { - return InetAddress.getAllByName(host); - } -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java index 5414d73fe4f..a00a6b3c221 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java @@ -18,7 +18,6 @@ import static java.util.stream.Collectors.toList; import com.google.springframework.security.web.client.ListedSsrfProtectionFilter.FilterMode; -import io.netty.resolver.AddressResolverGroup; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -36,8 +35,6 @@ import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.client.JettyClientHttpRequestFactory; -import org.springframework.http.client.reactive.ClientHttpConnector; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.client.RestTemplate; /** @@ -145,7 +142,6 @@ public static class Builder { // Keeping it for future use when more client types are added. private ClientType clientType = ClientType.HTTP_CLIENT_5; private HttpClient jettyClient = null; - private reactor.netty.http.client.HttpClient nettyClient = null; /** * Create a {@link com.google.springframework.security.web.client.SecureRestTemplate} with a @@ -162,12 +158,6 @@ public Builder fromJettyClient(HttpClient jettyClient) { return this; } - public Builder fromNettyClient(reactor.netty.http.client.HttpClient nettyClient) { - this.nettyClient = nettyClient; - this.clientType = ClientType.NETTY_CLIENT; - return this; - } - /** * When set to true rule violating requests are not blocked only logged. */ @@ -307,14 +297,6 @@ public DnsResolver buildToHttpClientDnsResolver() { return new HcSsrfDnsResolver(makeFilters(), isReportOnly); } - public ClientHttpConnector buildToClientHttpConnector() { - if (clientType != ClientType.NETTY_CLIENT) { - throw new IllegalStateException("buildToClientHttpConnector() can only be used with NETTY_CLIENT."); - } - NettySsrfDnsResolver nettyResolver = new NettySsrfDnsResolver(makeFilters(), isReportOnly); - return new ReactorClientHttpConnector(nettyClient.resolver(nettyResolver)); // Directly use resolver() - } - /** * Helper method to create a {@link org.springframework.http.client.ClientHttpRequestFactory} for more * customization before creating a RestTemplate or RestClient. From 407164ab1ed946b539c82dbae17f873f2902d297 Mon Sep 17 00:00:00 2001 From: Kian Jamali Date: Tue, 18 Feb 2025 23:56:21 +0000 Subject: [PATCH 20/34] Adding Netty client --- ssrf/build.gradle | 16 ++ .../security/web/client/ClientType.java | 3 +- .../web/client/NettySsrfDnsResolver.java | 186 ++++++++++++++++++ .../web/client/SecureRestTemplate.java | 18 ++ .../security/web/client/UsageExample.java | 65 +++++- 5 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java diff --git a/ssrf/build.gradle b/ssrf/build.gradle index 8ca955e22f0..9eb195d379a 100644 --- a/ssrf/build.gradle +++ b/ssrf/build.gradle @@ -30,6 +30,22 @@ dependencies { implementation 'org.eclipse.jetty:jetty-client:12.0.15' implementation 'io.projectreactor:reactor-core:3.6.11' + + // Core Netty functionality (EventLoop, Channel, etc.) + implementation 'io.netty:netty-common:4.1.107.Final' // Use latest version + implementation 'io.netty:netty-buffer:4.1.107.Final' + implementation 'io.netty:netty-transport:4.1.107.Final' + + // Netty's DNS resolver (REQUIRED NOW) + implementation 'io.netty:netty-resolver-dns:4.1.107.Final' + + // Spring WebFlux (REQUIRED for WebClient, make available to tests) + api "org.springframework:spring-webflux:$springVersion" + testImplementation "org.springframework:spring-webflux:$springVersion" // Add this line + implementation "org.springframework:spring-context:$springVersion" + + // Reactor Netty HTTP Client (REQUIRED for reactor.netty.http.client.HttpClient) + implementation 'io.projectreactor.netty:reactor-netty-http:1.1.15' // Use latest version } tasks.named('test') { diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java index 408f8587180..87f7d081edd 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java @@ -21,5 +21,6 @@ */ public enum ClientType { HTTP_CLIENT_5, - JETTY_CLIENT + JETTY_CLIENT, + NETTY_CLIENT } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java new file mode 100644 index 00000000000..e4e5b0c02db --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java @@ -0,0 +1,186 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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 com.google.springframework.security.web.client; + +import io.netty.resolver.AddressResolver; +import io.netty.resolver.AddressResolverGroup; +import io.netty.resolver.DefaultAddressResolverGroup; +import io.netty.util.concurrent.*; +import java.net.InetSocketAddress; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + + +public class NettySsrfDnsResolver extends AddressResolverGroup { + + private static final Log logger = LogFactory.getLog(NettySsrfDnsResolver.class); + + private final List filters; + private final boolean reportOnly; + private final AddressResolverGroup defaultResolverGroup; + + public NettySsrfDnsResolver(List filters, boolean reportOnly) { + this(filters, reportOnly, DefaultAddressResolverGroup.INSTANCE); + } + + // For testing + protected NettySsrfDnsResolver(List filters, boolean reportOnly, + AddressResolverGroup defaultResolverGroup) { + this.filters = filters; + this.reportOnly = reportOnly; + this.defaultResolverGroup = defaultResolverGroup; + } + + @Override + protected AddressResolver newResolver(EventExecutor executor) { + return new AddressResolver() { + private final AddressResolver resolver = defaultResolverGroup.getResolver(executor); + + @Override + public boolean isSupported(SocketAddress address) { + if (address instanceof InetSocketAddress) { + return resolver.isSupported((InetSocketAddress) address); + } + return false; + } + + @Override + public boolean isResolved(SocketAddress address) { + return resolver.isResolved(address); + } + + @Override + public Future resolve(SocketAddress address) { + return resolver.resolve(address); + } + + @Override + public Future resolve(SocketAddress address, + Promise promise) { + return resolver.resolve(address, promise); + } + + @Override + public Future> resolveAll(SocketAddress address) { + if (address instanceof InetSocketAddress inetSocketAddress && inetSocketAddress.isUnresolved()) { + // This is where we apply our SSRF filtering. + return resolveAllUnresolved(inetSocketAddress.getHostName(), inetSocketAddress.getPort(), executor.newPromise()); + } + // If it's already resolved or not an InetSocketAddress, use the default resolver. + if(address instanceof InetSocketAddress) { + return resolver.resolveAll((InetSocketAddress) address); + } + return executor.newFailedFuture(new IllegalArgumentException("Unsupported address type: " + address.getClass())); + } + + @Override + public Future> resolveAll(SocketAddress address, Promise> promise) { + if (address instanceof InetSocketAddress inetSocketAddress && inetSocketAddress.isUnresolved()) { + return resolveAllUnresolved(inetSocketAddress.getHostName(), inetSocketAddress.getPort(), promise); + } + if(address instanceof InetSocketAddress){ + return resolver.resolveAll((InetSocketAddress) address, promise); + } + return promise.setFailure(new IllegalArgumentException("Unsupported address type: " + address.getClass())); + + } + + + // Helper method to handle the actual filtering logic (for unresolved addresses) + private Future> resolveAllUnresolved(String host, int port, Promise> promise) { + Future> future; + try{ + future = resolveWithDefaultResolver(host, port); + } catch(UnknownHostException e){ + return promise.setFailure(e); + } + + future.addListener((FutureListener>) f -> { + if (f.isSuccess()) { + // 1. Get the resolved addresses from the default resolver + List resolvedAddresses = f.getNow(); + + // 2. Convert to InetAddress array (for your filter interface) + InetAddress[] inetAddresses = resolvedAddresses.stream() + .map(InetSocketAddress::getAddress) + .toArray(InetAddress[]::new); + + // 3. Apply SSRF filters + try { + InetAddress[] filteredAddresses = inetAddresses; + for (SsrfProtectionFilter filter : filters) { + filteredAddresses = filter.filterAddresses(filteredAddresses); + } + + // 4. Convert back to InetSocketAddress list + List filteredSocketAddresses = Arrays.stream(filteredAddresses) + .map(addr -> new InetSocketAddress(addr, port)) // Use original port + .toList(); + + // 5. Fulfill the promise with the FILTERED results + promise.setSuccess(filteredSocketAddresses); + + } catch (HostBlockedException e) { + logger.error("DNS resolution for '" + host + "' blocked by SSRF filter", e); + if (reportOnly) { + // In report-only mode, we still succeed with the *original* addresses + promise.setSuccess(resolvedAddresses); + } else { + // Block the resolution + promise.setFailure(new UnknownHostException( + String.format("Access to '%s' was blocked: %s", host, e.getMessage()))); + } + } + } else { + // If the default resolver failed, propagate the failure + promise.setFailure(f.cause()); + } + }); + + return promise; // Return the promise to the caller + } + + + @Override + public void close() { + resolver.close(); // Close the underlying default resolver + } + }; + } + + //for testing + Future> resolveWithDefaultResolver(String host, int port) throws UnknownHostException { + // Use DefaultAddressResolverGroup to perform the actual DNS lookup + InetSocketAddress unresolvedAddress = InetSocketAddress.createUnresolved(host, port); + + //This is just to check to see if the default resolver will throw an exception + resolveAll(host); + + return defaultResolverGroup.getResolver(ImmediateEventExecutor.INSTANCE).resolveAll(unresolvedAddress); + } + + //for testing + protected InetAddress[] resolveAll(String host) throws UnknownHostException { + return InetAddress.getAllByName(host); + } +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java index a00a6b3c221..5414d73fe4f 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java @@ -18,6 +18,7 @@ import static java.util.stream.Collectors.toList; import com.google.springframework.security.web.client.ListedSsrfProtectionFilter.FilterMode; +import io.netty.resolver.AddressResolverGroup; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -35,6 +36,8 @@ import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.client.JettyClientHttpRequestFactory; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.client.RestTemplate; /** @@ -142,6 +145,7 @@ public static class Builder { // Keeping it for future use when more client types are added. private ClientType clientType = ClientType.HTTP_CLIENT_5; private HttpClient jettyClient = null; + private reactor.netty.http.client.HttpClient nettyClient = null; /** * Create a {@link com.google.springframework.security.web.client.SecureRestTemplate} with a @@ -158,6 +162,12 @@ public Builder fromJettyClient(HttpClient jettyClient) { return this; } + public Builder fromNettyClient(reactor.netty.http.client.HttpClient nettyClient) { + this.nettyClient = nettyClient; + this.clientType = ClientType.NETTY_CLIENT; + return this; + } + /** * When set to true rule violating requests are not blocked only logged. */ @@ -297,6 +307,14 @@ public DnsResolver buildToHttpClientDnsResolver() { return new HcSsrfDnsResolver(makeFilters(), isReportOnly); } + public ClientHttpConnector buildToClientHttpConnector() { + if (clientType != ClientType.NETTY_CLIENT) { + throw new IllegalStateException("buildToClientHttpConnector() can only be used with NETTY_CLIENT."); + } + NettySsrfDnsResolver nettyResolver = new NettySsrfDnsResolver(makeFilters(), isReportOnly); + return new ReactorClientHttpConnector(nettyClient.resolver(nettyResolver)); // Directly use resolver() + } + /** * Helper method to create a {@link org.springframework.http.client.ClientHttpRequestFactory} for more * customization before creating a RestTemplate or RestClient. diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java index 4544fa74920..bd3304bcfea 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java @@ -1,10 +1,12 @@ package com.google.springframework.security.web.client; import static com.google.springframework.security.web.client.NetworkMode.BLOCK_EXTERNAL; +import static com.google.springframework.security.web.client.NetworkMode.BLOCK_INTERNAL; import static org.springframework.http.MediaType.TEXT_HTML; import java.net.InetAddress; import java.util.Arrays; +import java.util.List; import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.TlsConfig; @@ -26,13 +28,61 @@ import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.client.JettyClientHttpRequestFactory; +import org.springframework.http.client.reactive.ClientHttpConnector; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; public class UsageExample { + public static void example8() { + System.out.println("Example 8 (WebClient - Netty - should be blocked)"); + + // 2. Create a Reactor Netty HttpClient (default settings are fine for this example) + reactor.netty.http.client.HttpClient nettyClient = reactor.netty.http.client.HttpClient.create(); + + // 3. Create the SecureRestTemplate.Builder and configure it for Netty + SecureRestTemplate.Builder builder = new SecureRestTemplate.Builder() + .fromNettyClient(nettyClient) // Use the Netty client + .reportOnly(false) // 'false' to block, 'true' to report only + .withCustomFilter(new BasicSsrfProtectionFilter(BLOCK_EXTERNAL)); + + // 4. Get the ClientHttpConnector (this integrates the SSRF protection) + ClientHttpConnector connector = builder.buildToClientHttpConnector(); + + // 5. Create a WebClient using the connector + WebClient webClient = WebClient.builder() + .clientConnector(connector) + .build(); + + // 6. Make a request to a BLOCKED URL (e.g., google.com) + Mono blockedResponseMono = webClient.get() + .uri("https://www.google.com") // This *should* be blocked + .retrieve() + .bodyToMono(String.class); + + blockedResponseMono.subscribe( + response -> { + // Should NOT be reached if blocking is enabled + System.out.println("BLOCKED Request - Unexpected Success: " + response); + }, + error -> { + // *Should* be reached if blocking is enabled + System.err.println("BLOCKED Request - Expected Failure: " + error.getMessage()); + } + ); + + // Keep the application running (for demonstration purposes only) + try { + Thread.sleep(5000); // Wait for the asynchronous request to complete + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + public static void example7() { System.out.println("Example 7"); RestClient exampleClient = RestClient.create(new SecureRestTemplate.Builder(). @@ -235,13 +285,14 @@ public static void example1() { } public static void main(String[] args) { - example1(); - example2(); - example3(); - example4(); - example5(); - example6(); - example7(); + // example1(); + // example2(); + // example3(); + // example4(); + // example5(); + // example6(); + // example7(); + example8(); } From 7a25cb7f86ef89d1dfbba2816efa6da8de6cfacc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Wed, 19 Feb 2025 13:04:55 +0100 Subject: [PATCH 21/34] Netty client; uncomment invocations in UsageExample.java --- .../security/web/client/UsageExample.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java index bd3304bcfea..ca10d16b0e5 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java @@ -285,13 +285,13 @@ public static void example1() { } public static void main(String[] args) { - // example1(); - // example2(); - // example3(); - // example4(); - // example5(); - // example6(); - // example7(); + example1(); + example2(); + example3(); + example4(); + example5(); + example6(); + example7(); example8(); } From 969dc4a62c42d648304d3bab377cc6a54878c659 Mon Sep 17 00:00:00 2001 From: vasporig Date: Wed, 19 Feb 2025 17:23:22 +0100 Subject: [PATCH 22/34] Adapter refactor (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introducing adapters to decouple underlying clients and prevent dependency bloat * Minor cleanups, comments added, naming for Apache HTTP client specific classes changed --------- Co-authored-by: Gábor Vaspöri --- ssrf/build.gradle | 22 +-- .../security/web/client/ClientAdapter.java | 19 ++ .../security/web/client/Hc5ClientAdapter.java | 64 +++++++ ...sResolver.java => Hc5SsrfDnsResolver.java} | 6 +- .../web/client/JettyClientAdapter.java | 41 +++++ .../web/client/NettyClientAdapter.java | 38 ++++ .../web/client/SecureRestTemplate.java | 171 +++++++----------- ...rTest.java => Hc5SsrfDnsResolverTest.java} | 4 +- .../security/web/client/UsageExample.java | 55 +++--- 9 files changed, 276 insertions(+), 144 deletions(-) create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/ClientAdapter.java create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java rename ssrf/src/main/java/com/google/springframework/security/web/client/{HcSsrfDnsResolver.java => Hc5SsrfDnsResolver.java} (92%) create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/JettyClientAdapter.java create mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/NettyClientAdapter.java rename ssrf/src/test/java/com/google/springframework/security/web/client/{HcSsrfDnsResolverTest.java => Hc5SsrfDnsResolverTest.java} (96%) diff --git a/ssrf/build.gradle b/ssrf/build.gradle index 9eb195d379a..c1b7ef72c46 100644 --- a/ssrf/build.gradle +++ b/ssrf/build.gradle @@ -24,28 +24,28 @@ dependencies { api 'org.springframework:spring-web:' + springVersion api 'org.springframework:spring-core:' + springVersion - implementation("org.apache.httpcomponents.client5:httpclient5:5.3.1") - implementation("org.apache.httpcomponents.core5:httpcore5-reactive:5.2.5") + api "org.apache.httpcomponents.client5:httpclient5:5.3.1" + api "org.apache.httpcomponents.core5:httpcore5-reactive:5.2.5" - implementation 'org.eclipse.jetty:jetty-client:12.0.15' + api 'org.eclipse.jetty:jetty-client:12.0.15' - implementation 'io.projectreactor:reactor-core:3.6.11' + api 'io.projectreactor:reactor-core:3.6.11' // Core Netty functionality (EventLoop, Channel, etc.) - implementation 'io.netty:netty-common:4.1.107.Final' // Use latest version - implementation 'io.netty:netty-buffer:4.1.107.Final' - implementation 'io.netty:netty-transport:4.1.107.Final' + api 'io.netty:netty-common:4.1.107.Final' // Use latest version + api 'io.netty:netty-buffer:4.1.107.Final' + api 'io.netty:netty-transport:4.1.107.Final' - // Netty's DNS resolver (REQUIRED NOW) - implementation 'io.netty:netty-resolver-dns:4.1.107.Final' + // Netty's DNS resolver + api 'io.netty:netty-resolver-dns:4.1.107.Final' // Spring WebFlux (REQUIRED for WebClient, make available to tests) - api "org.springframework:spring-webflux:$springVersion" + implementation "org.springframework:spring-webflux:$springVersion" testImplementation "org.springframework:spring-webflux:$springVersion" // Add this line implementation "org.springframework:spring-context:$springVersion" // Reactor Netty HTTP Client (REQUIRED for reactor.netty.http.client.HttpClient) - implementation 'io.projectreactor.netty:reactor-netty-http:1.1.15' // Use latest version + api 'io.projectreactor.netty:reactor-netty-http:1.1.15' // Use latest version } tasks.named('test') { diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientAdapter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientAdapter.java new file mode 100644 index 00000000000..f02bf3c57bb --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientAdapter.java @@ -0,0 +1,19 @@ +package com.google.springframework.security.web.client; + +import java.util.List; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.client.RestTemplate; + + +/** + * This interface is used to abstract away the underlying HTTP Client utilized for fetching the data. + */ +public interface ClientAdapter { + + ClientHttpRequestFactory buildToHttpRequestFactory(List filters, boolean reportOnly); + + RestTemplate buildRestTemplate(List filters, boolean reportOnly); + + ReactorClientHttpConnector buildToClientHttpConnector(List filters, boolean reportOnly); +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java new file mode 100644 index 00000000000..40493c525dc --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java @@ -0,0 +1,64 @@ +package com.google.springframework.security.web.client; + +import java.util.List; +import java.util.function.Function; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; +import org.apache.hc.client5.http.socket.ConnectionSocketFactory; +import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.core5.http.config.Registry; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.client.RestTemplate; + +public class Hc5ClientAdapter implements ClientAdapter { + + private Function customBuilder = null; + + + public Hc5ClientAdapter() { + } + + public static Hc5ClientAdapter withCustomBuilder(Function customBuilder) { + Hc5ClientAdapter hc5ClientAdapter = new Hc5ClientAdapter(); + hc5ClientAdapter.customBuilder = customBuilder; + return hc5ClientAdapter; + } + + @Override + public ClientHttpRequestFactory buildToHttpRequestFactory(List filters, + boolean reportOnly) { + Registry registry = RegistryBuilder.create() + .register("http", PlainConnectionSocketFactory.getSocketFactory()) + .register("https", SSLConnectionSocketFactory.getSocketFactory()).build(); + + Hc5SsrfDnsResolver hc5SsrfDnsResolver = new Hc5SsrfDnsResolver(filters, reportOnly); + if (customBuilder != null) { + return customBuilder.apply(hc5SsrfDnsResolver); + } + BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager(registry, null, null, + hc5SsrfDnsResolver); + + CloseableHttpClient httpClient = HttpClientBuilder.create().setConnectionManager(connManager).build(); + + HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory( + httpClient); + return requestFactory; + } + + @Override + public RestTemplate buildRestTemplate(List filters, boolean reportOnly) { + return new RestTemplate(buildToHttpRequestFactory(filters, reportOnly)); + } + + @Override + public ReactorClientHttpConnector buildToClientHttpConnector(List filters, + boolean reportOnly) { + throw new UnsupportedOperationException(); + } +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/HcSsrfDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5SsrfDnsResolver.java similarity index 92% rename from ssrf/src/main/java/com/google/springframework/security/web/client/HcSsrfDnsResolver.java rename to ssrf/src/main/java/com/google/springframework/security/web/client/Hc5SsrfDnsResolver.java index 990418450fe..45bcd2ffe5d 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/HcSsrfDnsResolver.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5SsrfDnsResolver.java @@ -23,14 +23,14 @@ import org.apache.commons.logging.LogFactory; import org.apache.hc.client5.http.DnsResolver; -class HcSsrfDnsResolver implements DnsResolver { +class Hc5SsrfDnsResolver implements DnsResolver { - private static final Log logger = LogFactory.getLog(HcSsrfDnsResolver.class); + private static final Log logger = LogFactory.getLog(Hc5SsrfDnsResolver.class); protected final List filters; protected boolean reportOnly; - public HcSsrfDnsResolver(List filters, boolean reportOnly) { + public Hc5SsrfDnsResolver(List filters, boolean reportOnly) { this.filters = filters; this.reportOnly = reportOnly; } diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/JettyClientAdapter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/JettyClientAdapter.java new file mode 100644 index 00000000000..dad38a7a2a8 --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/JettyClientAdapter.java @@ -0,0 +1,41 @@ +package com.google.springframework.security.web.client; + +import java.util.List; +import org.eclipse.jetty.client.HttpClient; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.JettyClientHttpRequestFactory; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.client.RestTemplate; + +public class JettyClientAdapter implements ClientAdapter { + + private HttpClient jettyClient = null; + + private ClientHttpRequestFactory makeJettyClient(JettySsrfDnsResolver dnsResolver) { + jettyClient.setSocketAddressResolver(dnsResolver); + JettyClientHttpRequestFactory requestFactory = new JettyClientHttpRequestFactory(jettyClient); + return requestFactory; + } + + public JettyClientAdapter(HttpClient jettyClient) { + this.jettyClient = jettyClient; + } + + public RestTemplate buildRestTemplate(List filters, boolean reportOnly) { + return new RestTemplate(makeJettyClient(new JettySsrfDnsResolver(filters, reportOnly))); + } + + @Override + public ReactorClientHttpConnector buildToClientHttpConnector(List filters, + boolean reportOnly) { + throw new UnsupportedOperationException(); + } + + public ClientHttpRequestFactory buildToHttpRequestFactory(List filters, boolean reportOnly) { + jettyClient.setSocketAddressResolver(new JettySsrfDnsResolver(filters, reportOnly)); + return new JettyClientHttpRequestFactory(jettyClient); + + } + + +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/NettyClientAdapter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/NettyClientAdapter.java new file mode 100644 index 00000000000..c5f20e0895d --- /dev/null +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/NettyClientAdapter.java @@ -0,0 +1,38 @@ +package com.google.springframework.security.web.client; + +import java.util.List; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ReactorNettyClientRequestFactory; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.client.RestTemplate; +import reactor.netty.http.client.HttpClient; + +public class NettyClientAdapter implements ClientAdapter { + + private HttpClient nettyClient; + + public NettyClientAdapter(HttpClient nettyClient) { + this.nettyClient = nettyClient; + } + + @Override + public ClientHttpRequestFactory buildToHttpRequestFactory(List filters, + boolean reportOnly) { + NettySsrfDnsResolver nettyResolver = new NettySsrfDnsResolver(filters, reportOnly); + return new ReactorNettyClientRequestFactory(nettyClient.resolver(nettyResolver)); + } + + @Override + public RestTemplate buildRestTemplate(List filters, boolean reportOnly) { + + return new RestTemplate(buildToHttpRequestFactory(filters, reportOnly)); + } + + public ReactorClientHttpConnector buildToClientHttpConnector(List filters, + boolean reportOnly) { + NettySsrfDnsResolver nettyResolver = new NettySsrfDnsResolver(filters, reportOnly); + return new ReactorClientHttpConnector(nettyClient.resolver(nettyResolver)); + + } + +} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java index 5414d73fe4f..ffb6e9993e8 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java @@ -18,26 +18,12 @@ import static java.util.stream.Collectors.toList; import com.google.springframework.security.web.client.ListedSsrfProtectionFilter.FilterMode; -import io.netty.resolver.AddressResolverGroup; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import org.apache.hc.client5.http.DnsResolver; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; -import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; -import org.apache.hc.client5.http.socket.ConnectionSocketFactory; -import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; -import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; -import org.apache.hc.core5.http.config.Registry; -import org.apache.hc.core5.http.config.RegistryBuilder; -import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.util.SocketAddressResolver; import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; -import org.springframework.http.client.JettyClientHttpRequestFactory; import org.springframework.http.client.reactive.ClientHttpConnector; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.util.ClassUtils; import org.springframework.web.client.RestTemplate; /** @@ -78,6 +64,14 @@ private enum ProtectionMode { BLOCK_INTERNAL, } + private static final boolean hc5Present; + + static { + ClassLoader classLoader = RestTemplate.class.getClassLoader(); + hc5Present = ClassUtils.isPresent("org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager", + classLoader); + } + /** * Build {@link com.google.springframework.security.web.client.SecureRestTemplate} based on JVM global system * properties. The following properties can be used to configured: @@ -122,8 +116,6 @@ public static RestTemplate buildDefault() { } return new Builder().reportOnly(reportOnly).withCustomFilter(filter).build(); - - } /** @@ -141,33 +133,60 @@ public static class Builder { private boolean isReportOnly = false; private NetworkMode networkMode = null; - // Currently redundant due to changes around using a Jetty client. - // Keeping it for future use when more client types are added. private ClientType clientType = ClientType.HTTP_CLIENT_5; - private HttpClient jettyClient = null; - private reactor.netty.http.client.HttpClient nettyClient = null; + + private ClientAdapter clientAdapter = null; /** - * Create a {@link com.google.springframework.security.web.client.SecureRestTemplate} with a - * {@link org.eclipse.jetty.client.HttpClient} to use to make the requests. The lifetime of a - * {@link org.eclipse.jetty.client.HttpClient} should be handled by Spring (or manually) + * Create a {@link com.google.springframework.security.web.client.SecureRestTemplate} by using a Jetty client. + * * {@see UsageExample.java} * - * @param jettyClient the HttpClient to use + * @param jettyClient a {@link JettyClientAdapter} should be used here */ - - public Builder fromJettyClient(HttpClient jettyClient) { - this.jettyClient = jettyClient; + public Builder fromJettyClient(ClientAdapter jettyClient) { + // TODO(vaspori): make sure clientType and adapter are consistent or remove clientType this.clientType = ClientType.JETTY_CLIENT; + this.clientAdapter = jettyClient; return this; } - public Builder fromNettyClient(reactor.netty.http.client.HttpClient nettyClient) { - this.nettyClient = nettyClient; + + /** + * Create a {@link com.google.springframework.security.web.client.SecureRestTemplate} by using a Netty client. + * + * {@see UsageExample.java} + * + * @param nettyClient a {@link NettyClientAdapter} should be used here + */ + public Builder fromNettyClient(ClientAdapter nettyClient) { this.clientType = ClientType.NETTY_CLIENT; + this.clientAdapter = nettyClient; + return this; + } + + /** + * Create a {@link com.google.springframework.security.web.client.SecureRestTemplate} by using a Apache + * HttpClient . {@link Hc5ClientAdapter} makes it also possible to customize the parameters of the underlying + * client: {@see UsageExample.java} + * + * @param hc5Client a {@link Hc5ClientAdapter} should be used here + */ + public Builder fromApacheClient(ClientAdapter hc5Client) { + this.clientType = ClientType.HTTP_CLIENT_5; + this.clientAdapter = hc5Client; return this; } + /** + * Create a default Builder, an Apache HttpClient will be used as a default. If the library is not in the + * classpath, the {@see build()} method will throw a RuntimeException(). + */ + public Builder() { + this.clientType = ClientType.HTTP_CLIENT_5; + + } + /** * When set to true rule violating requests are not blocked only logged. */ @@ -227,41 +246,6 @@ public Builder withCustomFilter(SsrfProtectionFilter filter) { return this; } - // Reserved for future use, when adding more ClientTypes - // Currently HC5 is the default and Jetty Client needs to be externally provided - // because of cleanup issues - private Builder withClient(ClientType clientType) { - if (clientType == ClientType.JETTY_CLIENT) { - throw new IllegalStateException( - "The Builder was created from a Jetty HttpClient. Cannot change ClientType."); - } - - this.clientType = clientType; - return this; - } - - private ClientHttpRequestFactory makeHttpClient5(HcSsrfDnsResolver dnsResolver) { - - Registry registry = RegistryBuilder.create() - .register("http", PlainConnectionSocketFactory.getSocketFactory()) - .register("https", SSLConnectionSocketFactory.getSocketFactory()).build(); - - BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager(registry, null, null, - dnsResolver); - - CloseableHttpClient httpClient = HttpClientBuilder.create().setConnectionManager(connManager).build(); - - HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory( - httpClient); - return requestFactory; - } - - private ClientHttpRequestFactory makeJettyClient(JettySsrfDnsResolver dnsResolver) { - jettyClient.setSocketAddressResolver(dnsResolver); - JettyClientHttpRequestFactory requestFactory = new JettyClientHttpRequestFactory(jettyClient); - return requestFactory; - } - private List makeFilters() { List filters = new ArrayList<>(); @@ -287,48 +271,39 @@ private List makeFilters() { return filters; } - /** - * Helper method to create the Jetty specific DNS resolver used in an underlying - * {@link org.eclipse.jetty.client.HttpClient} for request filtering. - * - * @return the custom resolver which implements the filtering and DNS rebinding protection logic. - */ - public SocketAddressResolver buildToJettyResolver() { - return new JettySsrfDnsResolver(makeFilters(), isReportOnly); - } + private void checkDependencies() { + if (clientType == ClientType.HTTP_CLIENT_5 && clientAdapter == null) { + if (SecureRestTemplate.hc5Present) { + try { + Class aClass = Class.forName( + "com.google.springframework.security.web.client.Hc5ClientAdapter"); + this.clientAdapter = (ClientAdapter) aClass.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } else { + throw new RuntimeException( + "Dependency org.apache.httpcomponents.client5:httpclient5 required for this RestTemplate"); + } + } - /** - * Helper method to create the DNS resolver used in an underlying - * {@link org.apache.hc.client5.http.impl.classic.CloseableHttpClient} for request filtering. - * - * @return the custom resolver which implements the filtering and DNS rebinding protection logic. - */ - public DnsResolver buildToHttpClientDnsResolver() { - return new HcSsrfDnsResolver(makeFilters(), isReportOnly); } public ClientHttpConnector buildToClientHttpConnector() { if (clientType != ClientType.NETTY_CLIENT) { throw new IllegalStateException("buildToClientHttpConnector() can only be used with NETTY_CLIENT."); } - NettySsrfDnsResolver nettyResolver = new NettySsrfDnsResolver(makeFilters(), isReportOnly); - return new ReactorClientHttpConnector(nettyClient.resolver(nettyResolver)); // Directly use resolver() + return clientAdapter.buildToClientHttpConnector(makeFilters(), isReportOnly); } + /** * Helper method to create a {@link org.springframework.http.client.ClientHttpRequestFactory} for more * customization before creating a RestTemplate or RestClient. */ public ClientHttpRequestFactory buildToHttpRequestFactory() { - if (clientType == ClientType.HTTP_CLIENT_5) { - return makeHttpClient5(new HcSsrfDnsResolver(makeFilters(), isReportOnly)); - } else if (clientType == ClientType.JETTY_CLIENT) { - jettyClient.setSocketAddressResolver(new JettySsrfDnsResolver(makeFilters(), isReportOnly)); - return new JettyClientHttpRequestFactory(jettyClient); - } else { - throw new IllegalArgumentException( - "Only HTTP_CLIENT_5 and Jetty backed RestTemplates are supported for now"); - } + checkDependencies(); + return this.clientAdapter.buildToHttpRequestFactory(makeFilters(), isReportOnly); } /** @@ -336,14 +311,8 @@ public ClientHttpRequestFactory buildToHttpRequestFactory() { * builder. */ public RestTemplate build() { - if (clientType == ClientType.HTTP_CLIENT_5) { - return new RestTemplate(makeHttpClient5(new HcSsrfDnsResolver(makeFilters(), isReportOnly))); - } else if (clientType == ClientType.JETTY_CLIENT) { - return new RestTemplate(makeJettyClient(new JettySsrfDnsResolver(makeFilters(), isReportOnly))); - } else { - throw new IllegalArgumentException( - "Only HTTP_CLIENT_5 and Jetty backed RestTemplates are supported for now"); - } + checkDependencies(); + return this.clientAdapter.buildRestTemplate(makeFilters(), isReportOnly); } } } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/HcSsrfDnsResolverTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/Hc5SsrfDnsResolverTest.java similarity index 96% rename from ssrf/src/test/java/com/google/springframework/security/web/client/HcSsrfDnsResolverTest.java rename to ssrf/src/test/java/com/google/springframework/security/web/client/Hc5SsrfDnsResolverTest.java index 9c3a5358842..be48dbebbc7 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/HcSsrfDnsResolverTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/Hc5SsrfDnsResolverTest.java @@ -16,12 +16,12 @@ import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) -public class HcSsrfDnsResolverTest { +public class Hc5SsrfDnsResolverTest { @Mock private SsrfProtectionFilter ssrfProtectionFilter; - static class TestableHcSsrfDnsResolver extends HcSsrfDnsResolver { + static class TestableHcSsrfDnsResolver extends Hc5SsrfDnsResolver { InetAddress[] addressesToReturn = null; diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java index ca10d16b0e5..afe39d083f9 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java @@ -1,12 +1,10 @@ package com.google.springframework.security.web.client; import static com.google.springframework.security.web.client.NetworkMode.BLOCK_EXTERNAL; -import static com.google.springframework.security.web.client.NetworkMode.BLOCK_INTERNAL; import static org.springframework.http.MediaType.TEXT_HTML; import java.net.InetAddress; import java.util.Arrays; -import java.util.List; import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.TlsConfig; @@ -46,7 +44,7 @@ public static void example8() { // 3. Create the SecureRestTemplate.Builder and configure it for Netty SecureRestTemplate.Builder builder = new SecureRestTemplate.Builder() - .fromNettyClient(nettyClient) // Use the Netty client + .fromNettyClient(new NettyClientAdapter(nettyClient)) // Use the Netty client .reportOnly(false) // 'false' to block, 'true' to report only .withCustomFilter(new BasicSsrfProtectionFilter(BLOCK_EXTERNAL)); @@ -131,35 +129,38 @@ public static void example5() { System.out.println("Example 5"); // For SSRF prevention the "magic is built into the DNS resolver" - DnsResolver dnsResolver = new SecureRestTemplate.Builder() - .reportOnly(true) // Log warning about blocking, but don't block - .networkMode(BLOCK_EXTERNAL) - .withCustomFilter( - addresses -> Arrays.stream(addresses).filter(a -> !a.isMCNodeLocal()) - .toArray(InetAddress[]::new)).withBlocklist("evil.com", "6.6.6.9/16", "123.123.123.123") - .buildToHttpClientDnsResolver(); + RestTemplate secureRestTemplate = new SecureRestTemplate.Builder() + .fromApacheClient(Hc5ClientAdapter.withCustomBuilder((DnsResolver dnsResolver) -> { - // When a very custom client is needed - Registry registry = RegistryBuilder.create() - .register("http", PlainConnectionSocketFactory.getSocketFactory()) - .register("https", SSLConnectionSocketFactory.getSocketFactory()).build(); + // When a very custom client is needed + Registry registry = RegistryBuilder.create() + .register("http", PlainConnectionSocketFactory.getSocketFactory()) + .register("https", SSLConnectionSocketFactory.getSocketFactory()).build(); - BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager(registry, null, - null, - dnsResolver); + BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager(registry, null, + null, + dnsResolver); - // with custom timeout - connManager.setConnectionConfig(ConnectionConfig.custom().setConnectTimeout(Timeout.ofMinutes(2)).build()); - // with custom TLS config - connManager.setTlsConfig(TlsConfig.custom().setSupportedCipherSuites("TLSv1-3").build()); + // with custom timeout + connManager.setConnectionConfig( + ConnectionConfig.custom().setConnectTimeout(Timeout.ofMinutes(2)).build()); + // with custom TLS config + connManager.setTlsConfig(TlsConfig.custom().setSupportedCipherSuites("TLSv1-3").build()); - CloseableHttpClient httpClient = HttpClientBuilder.create().setConnectionManager(connManager).build(); + CloseableHttpClient httpClient = HttpClientBuilder.create().setConnectionManager(connManager) + .build(); - HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory( - httpClient); + HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory( + httpClient); - // a secure RestTemplate can be still built - RestTemplate secureRestTemplate = new RestTemplate(requestFactory); + return requestFactory; + })) + .reportOnly(true) // Log warning about blocking, but don't block + .networkMode(BLOCK_EXTERNAL) + .withCustomFilter( + addresses -> Arrays.stream(addresses).filter(a -> !a.isMCNodeLocal()) + .toArray(InetAddress[]::new)).withBlocklist("evil.com", "6.6.6.9/16", "123.123.123.123") + .build(); // a secure RestTemplate can be still built try { ResponseEntity result = secureRestTemplate.getForEntity("https://google.com", String.class); @@ -190,7 +191,7 @@ JettyClientHttpRequestFactory jettyClientHttpRequestFactory(HttpClient jettyClie @Bean("secureRestTemplate") RestTemplate secureRestTemplate(HttpClient jettyClient) { return new SecureRestTemplate.Builder() - .fromJettyClient(jettyClient) + .fromJettyClient(new JettyClientAdapter(jettyClient)) .reportOnly(true) // Log warning about blocking, but don't block .networkMode(BLOCK_EXTERNAL) .withCustomFilter(addresses -> Arrays.stream(addresses).filter(a -> !a.isMCNodeLocal()) From 4b4cb84d2b1b75e329e9f03255d465c387e36f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Vasp=C3=B6ri?= Date: Mon, 24 Mar 2025 15:51:22 +0100 Subject: [PATCH 23/34] Adding a simpler customization to Hc5ClientAdapter to configure ClientConnectionManager --- .../security/web/client/Hc5ClientAdapter.java | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java index 40493c525dc..cec83455be3 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java @@ -5,7 +5,7 @@ import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; -import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; import org.apache.hc.client5.http.socket.ConnectionSocketFactory; import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; @@ -20,6 +20,8 @@ public class Hc5ClientAdapter implements ClientAdapter { private Function customBuilder = null; + private Function, HttpClientConnectionManager> customConnectionMgr = null; + public Hc5ClientAdapter() { } @@ -30,6 +32,12 @@ public static Hc5ClientAdapter withCustomBuilder(Function, HttpClientConnectionManager> customConnectionMgr) { + this.customConnectionMgr = customConnectionMgr; + return this; + } + @Override public ClientHttpRequestFactory buildToHttpRequestFactory(List filters, boolean reportOnly) { @@ -41,10 +49,17 @@ public ClientHttpRequestFactory buildToHttpRequestFactory(List Date: Thu, 24 Apr 2025 10:38:35 -0400 Subject: [PATCH 24/34] Fix a bug in the apache client adapter which disabled the usage of our custom DNS (#6) Co-authored-by: Kian Jamali --- .../springframework/security/web/client/Hc5ClientAdapter.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java index cec83455be3..de55c785cc9 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java @@ -5,6 +5,8 @@ import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; import org.apache.hc.client5.http.io.HttpClientConnectionManager; import org.apache.hc.client5.http.socket.ConnectionSocketFactory; import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; @@ -55,6 +57,8 @@ public ClientHttpRequestFactory buildToHttpRequestFactory(List Date: Thu, 24 Apr 2025 23:58:57 +0000 Subject: [PATCH 25/34] Fix bug: Make sure that a customConnectionManager can be used --- .../springframework/security/web/client/Hc5ClientAdapter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java index de55c785cc9..74fa07218db 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java @@ -55,10 +55,10 @@ public ClientHttpRequestFactory buildToHttpRequestFactory(List Date: Wed, 4 Jun 2025 21:49:45 +0100 Subject: [PATCH 26/34] Draft of restructuring (#5) --- ssrf/build.gradle | 12 +- .../client/HttpComponentsDnsResolver.java | 51 ++++ .../client/JettyHttpClientDnsResolver.java | 56 +++++ .../client/dns/DefaultInetAddressFilter.java | 229 ++++++++++++++++++ .../java/client/dns/InetAddressFilter.java | 31 +++ .../java/client/dns/SecurityDnsHandler.java | 145 +++++++++++ .../main/java/client/dns/package-info.java | 4 + ssrf/src/main/java/client/package-info.java | 4 + 8 files changed, 529 insertions(+), 3 deletions(-) create mode 100644 ssrf/src/main/java/client/HttpComponentsDnsResolver.java create mode 100644 ssrf/src/main/java/client/JettyHttpClientDnsResolver.java create mode 100644 ssrf/src/main/java/client/dns/DefaultInetAddressFilter.java create mode 100644 ssrf/src/main/java/client/dns/InetAddressFilter.java create mode 100644 ssrf/src/main/java/client/dns/SecurityDnsHandler.java create mode 100644 ssrf/src/main/java/client/dns/package-info.java create mode 100644 ssrf/src/main/java/client/package-info.java diff --git a/ssrf/build.gradle b/ssrf/build.gradle index c1b7ef72c46..a7d79403aac 100644 --- a/ssrf/build.gradle +++ b/ssrf/build.gradle @@ -16,18 +16,22 @@ repositories { dependencies { // Use JUnit Jupiter for testing. - testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2' + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2' testImplementation 'org.mockito:mockito-core:5.14.2' testImplementation "org.mockito:mockito-junit-jupiter:5.14.2" testImplementation 'org.springframework:spring-context:' + springVersion + testImplementation "org.apache.httpcomponents.client5:httpclient5:5.4.3" + testImplementation "org.apache.httpcomponents.core5:httpcore5-reactive:5.2.5" + testImplementation 'org.eclipse.jetty:jetty-client:12.1.0.alpha2' api 'org.springframework:spring-web:' + springVersion api 'org.springframework:spring-core:' + springVersion - api "org.apache.httpcomponents.client5:httpclient5:5.3.1" + api "org.apache.httpcomponents.client5:httpclient5:5.4.3" api "org.apache.httpcomponents.core5:httpcore5-reactive:5.2.5" - api 'org.eclipse.jetty:jetty-client:12.0.15' + api 'org.eclipse.jetty:jetty-client:12.1.0.alpha2' api 'io.projectreactor:reactor-core:3.6.11' @@ -39,6 +43,8 @@ dependencies { // Netty's DNS resolver api 'io.netty:netty-resolver-dns:4.1.107.Final' + api "org.jspecify:jspecify:1.0.0" + // Spring WebFlux (REQUIRED for WebClient, make available to tests) implementation "org.springframework:spring-webflux:$springVersion" testImplementation "org.springframework:spring-webflux:$springVersion" // Add this line diff --git a/ssrf/src/main/java/client/HttpComponentsDnsResolver.java b/ssrf/src/main/java/client/HttpComponentsDnsResolver.java new file mode 100644 index 00000000000..8563dd991e2 --- /dev/null +++ b/ssrf/src/main/java/client/HttpComponentsDnsResolver.java @@ -0,0 +1,51 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 + * + * https://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 client; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; + +import client.dns.SecurityDnsHandler; +import org.apache.hc.client5.http.DnsResolver; + +public class HttpComponentsDnsResolver implements DnsResolver { + + private final DnsResolver delegate; + + private final SecurityDnsHandler securityDnsHandler; + + + public HttpComponentsDnsResolver(DnsResolver delegate, SecurityDnsHandler securityDnsHandler) { + this.delegate = delegate; + this.securityDnsHandler = securityDnsHandler; + } + + + @Override + public InetAddress[] resolve(String host) throws UnknownHostException { + InetAddress[] addresses = this.delegate.resolve(host); + List inetAddresses = this.securityDnsHandler.handleAddresses(Arrays.asList(addresses)); + return inetAddresses.toArray(new InetAddress[0]); + } + + @Override + public String resolveCanonicalHostname(String host) throws UnknownHostException { + return this.delegate.resolveCanonicalHostname(host); + } +} diff --git a/ssrf/src/main/java/client/JettyHttpClientDnsResolver.java b/ssrf/src/main/java/client/JettyHttpClientDnsResolver.java new file mode 100644 index 00000000000..02d8f6d6160 --- /dev/null +++ b/ssrf/src/main/java/client/JettyHttpClientDnsResolver.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 + * + * https://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 client; + + +import java.net.InetSocketAddress; +import java.util.List; + +import client.dns.SecurityDnsHandler; +import org.eclipse.jetty.util.Promise; +import org.eclipse.jetty.util.SocketAddressResolver; + +public class JettyHttpClientDnsResolver implements SocketAddressResolver { + + private final SocketAddressResolver delegate; + + private final SecurityDnsHandler securityDnsHandler; + + + public JettyHttpClientDnsResolver(SocketAddressResolver delegate, SecurityDnsHandler securityDnsHandler) { + this.delegate = delegate; + this.securityDnsHandler = securityDnsHandler; + } + + + @Override + public void resolve(String host, int port, Promise> promise) { + this.delegate.resolve(host, port, new Promise<>() { + + @Override + public void succeeded(List candidates) { + Promise.super.succeeded(securityDnsHandler.handleInetSocketAddresses(candidates, port)); + } + + @Override + public void failed(Throwable ex) { + Promise.super.failed(ex); + } + }); + } + +} diff --git a/ssrf/src/main/java/client/dns/DefaultInetAddressFilter.java b/ssrf/src/main/java/client/dns/DefaultInetAddressFilter.java new file mode 100644 index 00000000000..acb4f65e885 --- /dev/null +++ b/ssrf/src/main/java/client/dns/DefaultInetAddressFilter.java @@ -0,0 +1,229 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 + * + * https://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 client.dns; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + + +final class DefaultInetAddressFilter implements InetAddressFilter { + + private final List allowList; + + private final List denyList; + + private final @Nullable BlockMode blockMode; + + private final List customFilters; + + + DefaultInetAddressFilter( + List allowList, List denyList, boolean blockExternal, boolean blockInternal, + List customFilters) { + + this.allowList = initIpList(allowList); + this.denyList = initIpList(denyList); + this.blockMode = BlockMode.from(blockExternal, blockInternal); + this.customFilters = new ArrayList<>(customFilters); + } + + private static List initIpList(List ipList) { + return ipList.stream().map(IpAddressMatcher::new).collect(Collectors.toList()); + } + + + @Override + public boolean filterAddress(InetAddress address) { + if (!passBlockMode(address) || !passDenyList(address) && !passAllowList(address)) { + return false; + } + for (InetAddressFilter filter : this.customFilters) { + if (!filter.filterAddress(address)) { + return false; + } + } + return true; + } + + private boolean passBlockMode(InetAddress address) { + if (this.blockMode != null) { + if (this.blockMode == BlockMode.EXTERNAL) { + return !isInternalIp(address); + } + if (this.blockMode == BlockMode.INTERNAL) { + return isInternalIp(address); + } + } + return true; + } + + private boolean passAllowList(InetAddress address) { + if (this.allowList.isEmpty()) { + return true; + } + for (IpAddressMatcher ipAddressMatcher : this.allowList) { + if (ipAddressMatcher.matches(address)) { + return true; + } + } + return false; + } + + private boolean passDenyList(InetAddress address) { + for (IpAddressMatcher ipAddressMatcher : this.denyList) { + if (ipAddressMatcher.matches(address)) { + return false; + } + } + return true; + } + + private static boolean isInternalIp(InetAddress addr) { + + if (addr.isLoopbackAddress()) { + return true; + } + + byte[] rawAddress = addr.getAddress(); + + // there is sadly no Stream support for byte arrays + int[] iAddr = new int[rawAddress.length]; + for (int i = 0; i < rawAddress.length; i++) { + iAddr[i] = Byte.toUnsignedInt(rawAddress[i]); + } + + // Ignoring Multicast addresses + if (addr.getAddress().length == 4) { + // IPv4 filtering + // 10.x.x.x , 192.168.x.x , 172.16.x.x + if (iAddr[0] == 10 || + (iAddr[0] == 192 && iAddr[1] == 168) || + (iAddr[0] == 172 && iAddr[1] == 16)) { + return true; + } + + } else if (addr.getAddress().length == 16) { + // IPv6, check for Unique Local Addresses + if (iAddr[0] == 0xfc || iAddr[0] == 0xfd) { + return true; + } + + // IPv4/IPv6 translation, 64:ff9b + if (iAddr[0] == 0x00 && iAddr[1] == 0x64 && iAddr[2] == 0xff && iAddr[3] == 0x9b) { + int[] ipv4Part = new int[]{iAddr[12], iAddr[13], iAddr[14], iAddr[15]}; + // same check as above plus a check for loopback + if (ipv4Part[0] == 10 || ipv4Part[0] == 127 || + (ipv4Part[0] == 192 && ipv4Part[1] == 168) || + (ipv4Part[0] == 172 && ipv4Part[1] == 16)) { + return true; + } + } + } + return false; + } + + + private enum BlockMode { + + /** + * Allow request to the local network only. + */ + EXTERNAL, + + /** + * Allow requests towards the internet only, e.g. prevent access to cloud VM metadata. + */ + INTERNAL; + + + public static @Nullable BlockMode from(boolean blockExternal, boolean blockInternal) { + return (blockExternal ? EXTERNAL : (blockInternal ? INTERNAL : null)); + } + } + + + /** + * Class to represent and IPv4 or IPv6 range to be used in filtering. Inspired by: + * org.springframework.security.web.util.matcher.IpAddressMatcher.java + */ + private static final class IpAddressMatcher { + + private static final Log logger = LogFactory.getLog(IpAddressMatcher.class); + private final InetAddress address; + private final int nMaskBits; + + public IpAddressMatcher(String addressOrRange) { + if (addressOrRange.indexOf('/') > 0) { + String[] addressAndMask = addressOrRange.split("/"); + address = parseAddress(addressAndMask[0]); + this.nMaskBits = Integer.parseInt(addressAndMask[1]); + } else { + this.nMaskBits = -1; + address = parseAddress(addressOrRange); + } + } + + public boolean matches(InetAddress toCheck) { + + if (this.nMaskBits < 0) { + return toCheck.equals(this.address); + } + byte[] remAddr = toCheck.getAddress(); + byte[] reqAddr = this.address.getAddress(); + int nMaskFullBytes = this.nMaskBits / 8; + byte finalByte = (byte) (0xFF00 >> (this.nMaskBits & 0x07)); + for (int i = 0; i < nMaskFullBytes; i++) { + if (remAddr[i] != reqAddr[i]) { + return false; + } + } + if (finalByte != 0) { + return (remAddr[nMaskFullBytes] & finalByte) == (reqAddr[nMaskFullBytes] & finalByte); + } + return true; + + } + + private InetAddress parseAddress(String address) { + try { + InetAddress result = InetAddress.getByName(address); + if (address.matches(".*[a-zA-Z\\-].*$") && !address.contains(":")) { + logger.warn("Hostname '" + address + "' resolved to " + result.toString() + + " will be used on IP address matching"); + } + return result; + } catch (UnknownHostException ex) { + throw new IllegalArgumentException(String.format("Failed to parse address '%s'", address), ex); + } + } + + @Override + public String toString() { + return "IpAddressMatcher{" + + "address=" + address + + ", nMaskBits=" + nMaskBits + + '}'; + } + } +} diff --git a/ssrf/src/main/java/client/dns/InetAddressFilter.java b/ssrf/src/main/java/client/dns/InetAddressFilter.java new file mode 100644 index 00000000000..139059c8094 --- /dev/null +++ b/ssrf/src/main/java/client/dns/InetAddressFilter.java @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 + * + * https://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 client.dns; + +import java.net.InetAddress; + +/** + * Filter to decide if an {@link InetAddress} can be used. + */ +public interface InetAddressFilter { + + /** + * Return {@code true} if the address should be used, and {@code false} if + * it should be filtered out. + */ + boolean filterAddress(InetAddress address); + +} diff --git a/ssrf/src/main/java/client/dns/SecurityDnsHandler.java b/ssrf/src/main/java/client/dns/SecurityDnsHandler.java new file mode 100644 index 00000000000..75c082b119e --- /dev/null +++ b/ssrf/src/main/java/client/dns/SecurityDnsHandler.java @@ -0,0 +1,145 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 + * + * https://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 client.dns; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * Component to filter addresses according configured security rules. + * For use from an HTTP client's DNS resolver. + * + *

Use {@link #builder()} to create an instance. + */ +public final class SecurityDnsHandler { + + private static final Log logger = LogFactory.getLog(SecurityDnsHandler.class); + + + private final InetAddressFilter inetAddressFilter; + + private final boolean reportOnly; + + + private SecurityDnsHandler(InetAddressFilter filter, boolean reportOnly) { + this.inetAddressFilter = filter; + this.reportOnly = reportOnly; + } + + + public List handleAddresses(List candidateAddresses) { + List blocked = null; + for (InetAddress address : candidateAddresses) { + if (this.inetAddressFilter.filterAddress(address)) { + blocked = (blocked != null) ? blocked : new ArrayList<>(); + blocked.add(address); + } + } + + if (blocked == null) { + return candidateAddresses; + } + + if (logger.isErrorEnabled()) { + logger.error("Blocked IP addresses: " + candidateAddresses); + } + + if (candidateAddresses.size() == blocked.size()) { + if (this.reportOnly) { + return candidateAddresses; + } + } + + ArrayList result = new ArrayList<>(candidateAddresses); + result.removeAll(blocked); + return result; + } + + public List handleInetSocketAddresses(List candidates, int port) { + List input = candidates.stream().map(InetSocketAddress::getAddress).toList(); + List output = handleAddresses(input); + return output.stream().map(address -> new InetSocketAddress(address, port)).toList(); + } + + + public static Builder builder() { + return new Builder(); + } + + + public static class Builder { + + private final List allowList = new ArrayList<>(); + + private final List denyList = new ArrayList<>(); + + private boolean blockAllExternal; + + private boolean blockAllInternal; + + private final List customFilters = new ArrayList<>(); + + private boolean reportOnly; + + + private Builder blockAllExternal(boolean block) { + this.blockAllExternal = block; + return this; + } + + public Builder blockAllInternal(boolean block) { + this.blockAllInternal = block; + return this; + } + + public Builder allowList(String... ipAddresses) { + this.allowList.addAll(Arrays.asList(ipAddresses)); + return this; + } + + public Builder denyList(String... ipAddresses) { + this.denyList.addAll(Arrays.asList(ipAddresses)); + return this; + } + + public Builder customFilter(InetAddressFilter filter) { + this.customFilters.add(filter); + return this; + } + + public Builder reportOnly(boolean report) { + this.reportOnly = report; + return this; + } + + public SecurityDnsHandler build() { + + DefaultInetAddressFilter filter = new DefaultInetAddressFilter( + this.allowList, this.denyList, this.blockAllExternal, this.blockAllInternal, this.customFilters); + + return new SecurityDnsHandler(filter, this.reportOnly); + } + + } + +} diff --git a/ssrf/src/main/java/client/dns/package-info.java b/ssrf/src/main/java/client/dns/package-info.java new file mode 100644 index 00000000000..684dc08b573 --- /dev/null +++ b/ssrf/src/main/java/client/dns/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package client.dns; + +import org.jspecify.annotations.NullMarked; diff --git a/ssrf/src/main/java/client/package-info.java b/ssrf/src/main/java/client/package-info.java new file mode 100644 index 00000000000..630f91689a3 --- /dev/null +++ b/ssrf/src/main/java/client/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package client; + +import org.jspecify.annotations.NullMarked; From 1433ee3329ee668ad5fe76506030086743f1a466 Mon Sep 17 00:00:00 2001 From: Kian Jamali Date: Fri, 13 Jun 2025 18:16:25 -0400 Subject: [PATCH 27/34] Feature/hostname allowlist (#9) * Enable hostname matching in IpOrRange allowlist Previously, IpOrRange would resolve all input addresses (including hostnames) to IP addresses, discarding the original hostname. This prevented direct hostname-to-hostname matching if a hostname was used in an allowlist. This change introduces the following modifications: 1. `IpOrRange.java`: * Now stores the original input string as `hostname` if it appears to be a hostname (contains letters, no colons). Otherwise, `hostname` is null. * The `matches` method signature is changed to `matches(String toCheckAddressString, InetAddress toCheckInetAddress)`. * New matching logic: * If `IpOrRange` has a `hostname` stored: * If `toCheckAddressString` is also a hostname, they are compared (case-insensitive). A match here returns true. * If `toCheckAddressString` is an IP, `IpOrRange` falls back to comparing its resolved IP with `toCheckInetAddress`. * If `IpOrRange` has no `hostname` (was initialized with an IP), it performs an IP-based match as before. 2. `ListedSsrfProtectionFilter.java`: * Updated to call the new `IpOrRange.matches` method. It now passes `addr.getHostName()` as `toCheckAddressString` and `addr` as `toCheckInetAddress`. This allows `IpOrRange` to use the hostname of the address being checked if available via rDNS. 3. `IpOrRangeTest.java`: * Existing tests updated to conform to the new `matches` signature. * Added new unit tests using Mockito to cover various scenarios of hostname and IP matching, including cases where: * Allowlist entry is a hostname, checked address is a matching/non-matching hostname. * Allowlist entry is a hostname, checked address is an IP (matching/non-matching the resolved IP of the allowlist hostname). * Allowlist entry is an IP, checked address is a hostname (resolving to matching/non-matching IP). * The `mockito-inline` dependency was added to `ssrf/build.gradle` to support static mocking of `InetAddress`. These changes allow for more flexible allowlist configurations where specific hostnames can be targeted, rather than just their underlying IP addresses, which can change. * Fixing the tests * Fix a bug related to www in domain names * Uncomment all example usages --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- ssrf/build.gradle | 5 +- .../security/web/client/IpOrRange.java | 67 ++++- .../client/ListedSsrfProtectionFilter.java | 4 +- .../security/web/client/IpOrRangeTest.java | 250 +++++++++++++++++- .../security/web/client/UsageExample.java | 5 +- 5 files changed, 306 insertions(+), 25 deletions(-) diff --git a/ssrf/build.gradle b/ssrf/build.gradle index a7d79403aac..5dce2ee739f 100644 --- a/ssrf/build.gradle +++ b/ssrf/build.gradle @@ -16,9 +16,10 @@ repositories { dependencies { // Use JUnit Jupiter for testing. - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2' + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' testImplementation 'org.mockito:mockito-core:5.14.2' + // testImplementation 'org.mockito:mockito-inline:5.14.2' testImplementation "org.mockito:mockito-junit-jupiter:5.14.2" testImplementation 'org.springframework:spring-context:' + springVersion testImplementation "org.apache.httpcomponents.client5:httpclient5:5.4.3" diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java b/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java index 197e62f28a7..333a0313f59 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java @@ -29,37 +29,88 @@ public final class IpOrRange { private static final Log logger = LogFactory.getLog(IpOrRange.class); private final InetAddress address; private final int nMaskBits; + private final String hostname; public IpOrRange(String addressOrRange) { + String addressToParse; + String originalInputAddress; + if (addressOrRange.indexOf('/') > 0) { String[] addressAndMask = addressOrRange.split("/"); - address = parseAddress(addressAndMask[0]); + originalInputAddress = addressAndMask[0]; + addressToParse = addressAndMask[0]; this.nMaskBits = Integer.parseInt(addressAndMask[1]); } else { + originalInputAddress = addressOrRange; + addressToParse = addressOrRange; this.nMaskBits = -1; - address = parseAddress(addressOrRange); } + + if (originalInputAddress.matches(".*[a-zA-Z].*") && !originalInputAddress.contains(":")) { + this.hostname = originalInputAddress; + } else { + this.hostname = null; + } + + this.address = parseAddress(addressToParse); } - public boolean matches(InetAddress toCheck) { + private String stripWww(String host) { + // host is expected to be non-null by callers in the matches method. + if (host.toLowerCase().startsWith("www.")) { + return host.substring(4); + } + return host; + } + public boolean matches(String toCheckAddressString, InetAddress toCheckInetAddress) { + if (this.hostname != null) { + // Check if toCheckAddressString is a hostname + if (toCheckAddressString.matches(".*[a-zA-Z].*") && !toCheckAddressString.contains(":")) { + String normalizedStoredHostname = stripWww(this.hostname); + String normalizedToCheckHostname = stripWww(toCheckAddressString); + return normalizedStoredHostname.equalsIgnoreCase(normalizedToCheckHostname); + } + // If this.hostname is not null, but toCheckAddressString is an IP, fall through to IP matching + } + + // IP matching logic (either this.hostname is null, or it's a hostname but toCheckAddressString is an IP) if (this.nMaskBits < 0) { - return toCheck.equals(this.address); + // This means this IpOrRange is a single IP address (not a range) + if (this.address == null) { // Should not happen if constructor logic is correct + return false; + } + return this.address.equals(toCheckInetAddress); + } + + // This is a range comparison + if (this.address == null || toCheckInetAddress == null) { // Should not happen + return false; } - byte[] remAddr = toCheck.getAddress(); + + byte[] remAddr = toCheckInetAddress.getAddress(); byte[] reqAddr = this.address.getAddress(); + + // Ensure address families are the same + if (remAddr.length != reqAddr.length) { + return false; + } + int nMaskFullBytes = this.nMaskBits / 8; - byte finalByte = (byte) (0xFF00 >> (this.nMaskBits & 0x07)); + byte finalByte = (byte) (0xFF00 >> (this.nMaskBits & 0x07)); // MASK for last byte + for (int i = 0; i < nMaskFullBytes; i++) { if (remAddr[i] != reqAddr[i]) { return false; } } - if (finalByte != 0) { + + if (finalByte != 0) { // Check if the mask covers a partial byte return (remAddr[nMaskFullBytes] & finalByte) == (reqAddr[nMaskFullBytes] & finalByte); } - return true; + // If mask is a multiple of 8, then all necessary bytes already matched + return true; } private InetAddress parseAddress(String address) { diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java index 0708942432a..04d73adb936 100644 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java +++ b/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java @@ -49,14 +49,14 @@ public InetAddress[] filterAddresses(InetAddress[] addresses) throws HostBlocked for (InetAddress addr : addresses) { if (mode == FilterMode.BLOCK_LIST) { for (IpOrRange ipOrRange : matchingRules) { - if (ipOrRange.matches(addr)) { + if (ipOrRange.matches(addr.getHostName(), addr)) { continue outerLoop; } } result.add(addr); } else if (mode == FilterMode.ALLOW_LIST) { for (IpOrRange ipOrRange : matchingRules) { - if (ipOrRange.matches(addr)) { + if (ipOrRange.matches(addr.getHostName(), addr)) { result.add(addr); continue outerLoop; } diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/IpOrRangeTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/IpOrRangeTest.java index d0ce0a0346a..43947f01fc8 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/IpOrRangeTest.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/IpOrRangeTest.java @@ -7,21 +7,35 @@ import java.net.InetAddress; import java.net.UnknownHostException; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; public class IpOrRangeTest { + // Helper method to create mocked InetAddress instances + private InetAddress mockInetAddress(String hostname, String ipAddress) throws UnknownHostException { + InetAddress mocked = Mockito.mock(InetAddress.class); + Mockito.when(mocked.getHostName()).thenReturn(hostname); + Mockito.when(mocked.getHostAddress()).thenReturn(ipAddress); + // Resolve the byte array first. If InetAddress.getByName is statically mocked, + // this call will be intercepted by that static mock. + byte[] addressBytes = InetAddress.getByName(ipAddress).getAddress(); + Mockito.when(mocked.getAddress()).thenReturn(addressBytes); + return mocked; + } + @Test public void testSingleIpMatch() throws UnknownHostException { IpOrRange ipOrRange = new IpOrRange("192.168.1.1"); InetAddress address = InetAddress.getByName("192.168.1.1"); - assertTrue(ipOrRange.matches(address)); + assertTrue(ipOrRange.matches(address.getHostAddress(), address)); } @Test public void testSingleIpMismatch() throws UnknownHostException { IpOrRange ipOrRange = new IpOrRange("192.168.1.1"); InetAddress address = InetAddress.getByName("192.168.1.2"); - assertFalse(ipOrRange.matches(address)); + assertFalse(ipOrRange.matches(address.getHostAddress(), address)); } @Test @@ -29,29 +43,29 @@ public void testCidrMatch() throws UnknownHostException { IpOrRange ipOrRange = new IpOrRange("192.168.1.0/24"); InetAddress address1 = InetAddress.getByName("192.168.1.1"); InetAddress address2 = InetAddress.getByName("192.168.1.100"); - assertTrue(ipOrRange.matches(address1)); - assertTrue(ipOrRange.matches(address2)); + assertTrue(ipOrRange.matches(address1.getHostAddress(), address1)); + assertTrue(ipOrRange.matches(address2.getHostAddress(), address2)); } @Test public void testCidrMismatch() throws UnknownHostException { IpOrRange ipOrRange = new IpOrRange("192.168.1.0/24"); InetAddress address = InetAddress.getByName("192.168.2.1"); - assertFalse(ipOrRange.matches(address)); + assertFalse(ipOrRange.matches(address.getHostAddress(), address)); } @Test public void testCidrMatch_subnet() throws UnknownHostException { IpOrRange ipOrRange = new IpOrRange("10.0.0.0/8"); InetAddress address = InetAddress.getByName("10.10.10.10"); - assertTrue(ipOrRange.matches(address)); + assertTrue(ipOrRange.matches(address.getHostAddress(), address)); } @Test public void testCidrMismatch_subnet() throws UnknownHostException { IpOrRange ipOrRange = new IpOrRange("10.0.0.0/16"); InetAddress address = InetAddress.getByName("10.1.10.10"); - assertFalse(ipOrRange.matches(address)); + assertFalse(ipOrRange.matches(address.getHostAddress(), address)); } @Test @@ -67,22 +81,236 @@ public void testHostname() throws UnknownHostException { // Assuming "localhost" resolves to 127.0.0.1 IpOrRange ipOrRange = new IpOrRange("localhost"); InetAddress address = InetAddress.getByName("127.0.0.1"); - assertTrue(ipOrRange.matches(address)); + assertTrue(ipOrRange.matches(address.getHostAddress(), address)); } @Test public void testIpv6SingleIpMatch() throws UnknownHostException { IpOrRange ipOrRange = new IpOrRange("2001:db8::1"); InetAddress address = InetAddress.getByName("2001:db8::1"); - assertTrue(ipOrRange.matches(address)); + assertTrue(ipOrRange.matches(address.getHostAddress(), address)); } @Test public void testIpv6CidrMatch() throws UnknownHostException { IpOrRange ipOrRange = new IpOrRange("2001:db8::/32"); InetAddress address = InetAddress.getByName("2001:db8:1::1"); - assertTrue(ipOrRange.matches(address)); + assertTrue(ipOrRange.matches(address.getHostAddress(), address)); } -} + @Test + public void testHostnameMatch_AllowlistHostname_ToCheckHostname_ExactMatch() throws UnknownHostException { + try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { + // Prepare the static mock for the IP string "1.2.3.4" that mockInetAddress will use internally. + // This mock (ip1234_forBytes) is solely for providing the byte array for "1.2.3.4". + InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); + Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); + mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); + + // Now, mockInetAddress can be called. It will use the above stubbing for InetAddress.getByName("1.2.3.4"). + InetAddress exampleComIp = mockInetAddress("example.com", "1.2.3.4"); + + // This stubbing is for the IpOrRange constructor when it resolves "example.com". + mockedStaticInetAddress.when(() -> InetAddress.getByName("example.com")).thenReturn(exampleComIp); + + IpOrRange ipOrRange = new IpOrRange("example.com"); // Stores "example.com" as hostname + assertTrue(ipOrRange.matches("example.com", exampleComIp)); + } + } + + @Test + public void testHostnameMatch_AllowlistHostname_ToCheckHostname_CaseInsensitiveMatch() throws UnknownHostException { + try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { + // Prepare the static mock for the IP string "1.2.3.4" that mockInetAddress will use internally. + InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); + Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); + mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); + + // Now, mockInetAddress can be called. + InetAddress exampleComIp = mockInetAddress("EXAMPLE.COM", "1.2.3.4"); // rDNS might return uppercase + mockedStaticInetAddress.when(() -> InetAddress.getByName("example.com")).thenReturn(exampleComIp); + + IpOrRange ipOrRange = new IpOrRange("example.com"); + + assertTrue(ipOrRange.matches("EXAMPLE.COM", exampleComIp)); + } + } + + @Test + public void testHostnameMatch_AllowlistHostname_ToCheckHostname_SubdomainNoMatch() throws UnknownHostException { + try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { + // Prepare static mock for "1.2.3.5" (used by the first mockInetAddress call) + InetAddress ip1235_forBytes = Mockito.mock(InetAddress.class); + Mockito.when(ip1235_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 5}); + mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.5")).thenReturn(ip1235_forBytes); + InetAddress subExampleComIp = mockInetAddress("sub.example.com", "1.2.3.5"); + + // Prepare static mock for "1.2.3.4" (used by the second mockInetAddress call) + InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); + Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); + mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); + // Allowlist entry is for "example.com" + InetAddress exampleComIp = mockInetAddress("example.com", "1.2.3.4"); + mockedStaticInetAddress.when(() -> InetAddress.getByName("example.com")).thenReturn(exampleComIp); + mockedStaticInetAddress.when(() -> InetAddress.getByName("sub.example.com")).thenReturn(subExampleComIp); + + IpOrRange ipOrRange = new IpOrRange("example.com"); + + assertFalse(ipOrRange.matches("sub.example.com", subExampleComIp)); + } + } + + @Test + public void testHostnameMatch_AllowlistHostname_ToCheckIsIPOfHostname_ShouldMatchViaIPFallback() throws UnknownHostException { + try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { + // Prepare static mock for "1.2.3.4" that mockInetAddress will use internally + // to get the byte array. + InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); + Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); + mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); + + InetAddress exampleComIp = mockInetAddress("example.com", "1.2.3.4"); + // Now exampleComIp is fully mocked, including its getAddress() method. + + mockedStaticInetAddress.when(() -> InetAddress.getByName("example.com")).thenReturn(exampleComIp); + IpOrRange ipOrRange = new IpOrRange("example.com"); // hostname = "example.com", address = 1.2.3.4 + + // toCheckAddressString is an IP, toCheckInetAddress is the InetAddress for that IP + assertTrue(ipOrRange.matches("1.2.3.4", exampleComIp)); + } + } + + @Test + public void testHostnameMatch_AllowlistHostname_ToCheckIsDifferentIP_ShouldNotMatch() throws UnknownHostException { + try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { + // Prepare static mock for "1.2.3.4" (used by the first mockInetAddress call) + InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); + Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); + mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); + InetAddress exampleComIp = mockInetAddress("example.com", "1.2.3.4"); + + // Prepare static mock for "1.2.3.5" (used by the second mockInetAddress call) + InetAddress ip1235_forBytes = Mockito.mock(InetAddress.class); + Mockito.when(ip1235_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 5}); + mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.5")).thenReturn(ip1235_forBytes); + InetAddress differentIp = mockInetAddress("other.com", "1.2.3.5"); // or just an IP + + mockedStaticInetAddress.when(() -> InetAddress.getByName("example.com")).thenReturn(exampleComIp); + + IpOrRange ipOrRange = new IpOrRange("example.com"); + + assertFalse(ipOrRange.matches("1.2.3.5", differentIp)); + } + } + + @Test + public void testHostnameMatch_AllowlistIP_ToCheckIsHostnameResolvingToIP_ShouldMatchViaIP() throws UnknownHostException { + try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { + // Prepare static mock for "1.2.3.4" that mockInetAddress will use internally + // to get the byte array. + InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); + Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); + mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); + + InetAddress targetIp = mockInetAddress("example.com", "1.2.3.4"); // The IP we are interested in + // Mock resolution for "example.com" + mockedStaticInetAddress.when(() -> InetAddress.getByName("example.com")).thenReturn(targetIp); + // Mock resolution for "1.2.3.4" (used by IpOrRange constructor) + mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(targetIp); + + IpOrRange ipOrRange = new IpOrRange("1.2.3.4"); // hostname = null, address = 1.2.3.4 + + // toCheckAddressString is "example.com", toCheckInetAddress is the InetAddress for "example.com" + assertTrue(ipOrRange.matches("example.com", targetIp)); + } + } + + @Test + public void testHostnameMatch_AllowlistIP_ToCheckIsHostnameResolvingToDifferentIP_ShouldNotMatch() throws UnknownHostException { + try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { + // Prepare static mock for "1.2.3.4" (used by the first mockInetAddress call) + InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); + Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); + mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); + InetAddress allowlistIp = mockInetAddress("ip.only", "1.2.3.4"); + + // Prepare static mock for "5.6.7.8" (used by the second mockInetAddress call) + InetAddress ip5678_forBytes = Mockito.mock(InetAddress.class); + Mockito.when(ip5678_forBytes.getAddress()).thenReturn(new byte[]{5, 6, 7, 8}); + mockedStaticInetAddress.when(() -> InetAddress.getByName("5.6.7.8")).thenReturn(ip5678_forBytes); + InetAddress otherHostnameIp = mockInetAddress("other.com", "5.6.7.8"); + + + mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(allowlistIp); + mockedStaticInetAddress.when(() -> InetAddress.getByName("other.com")).thenReturn(otherHostnameIp); + + IpOrRange ipOrRange = new IpOrRange("1.2.3.4"); // hostname = null, address = 1.2.3.4 + + assertFalse(ipOrRange.matches("other.com", otherHostnameIp)); + } + } + + // It might be good to update the existing testHostname to reflect new signature and intent + @Test + public void testHostname_ResolvesToIp_MatchesViaIpFallback() throws UnknownHostException { + // This test now checks that an allowlist entry "localhost" (which stores "localhost" as hostname) + // correctly matches the IP "127.0.0.1" via IP fallback logic. + try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { + // 1. Prepare the static mock for the IP string "127.0.0.1" that mockInetAddress will use internally. + // This is needed because mockInetAddress calls InetAddress.getByName("127.0.0.1").getAddress(). + InetAddress ip127_forBytes = Mockito.mock(InetAddress.class); + Mockito.when(ip127_forBytes.getAddress()).thenReturn(new byte[]{127, 0, 0, 1}); + mockedStaticInetAddress.when(() -> InetAddress.getByName("127.0.0.1")).thenReturn(ip127_forBytes); + + // 2. Create the InetAddress object that represents "localhost" resolving to "127.0.0.1". + // This will be used both for the IpOrRange constructor (via stubbing) and for the matches() call. + InetAddress localhostIp = mockInetAddress("localhost", "127.0.0.1"); + + // 3. Mock what InetAddress.getByName("localhost") returns for the IpOrRange constructor. + mockedStaticInetAddress.when(() -> InetAddress.getByName("localhost")).thenReturn(localhostIp); + + IpOrRange ipOrRange = new IpOrRange("localhost"); // Constructor uses the stub above. + + // 4. Assert that matching "127.0.0.1" (IP string) against the `localhostIp` object works. + assertTrue(ipOrRange.matches("127.0.0.1", localhostIp)); + } + } + + @Test + public void testHostnameMatch_AllowlistDomain_ToCheckWwwDomain_ShouldMatch() throws UnknownHostException { + try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { + // Prepare static mock for "1.2.3.4" (used by mockInetAddress for "www.example.com") + InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); + Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); + mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); + InetAddress wwwExampleComIp = mockInetAddress("www.example.com", "1.2.3.4"); + + // Prepare static mock for "example.com" (used by IpOrRange constructor and potentially mockInetAddress) + // If mockInetAddress was called for "example.com", it would also use ip1234_forBytes via "1.2.3.4" + InetAddress exampleComIpForConstructor = mockInetAddress("example.com", "1.2.3.4"); + mockedStaticInetAddress.when(() -> InetAddress.getByName("example.com")).thenReturn(exampleComIpForConstructor); + + IpOrRange ipOrRange = new IpOrRange("example.com"); // Stored hostname: "example.com" + assertTrue(ipOrRange.matches("www.example.com", wwwExampleComIp)); // Check: "www.example.com" + } + } + + @Test + public void testHostnameMatch_AllowlistWwwDomain_ToCheckDomain_ShouldMatch() throws UnknownHostException { + try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { + // Prepare static mock for "1.2.3.4" (used by mockInetAddress for "example.com") + InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); + Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); + mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); + InetAddress exampleComIp = mockInetAddress("example.com", "1.2.3.4"); + + // Prepare static mock for "www.example.com" (used by IpOrRange constructor and potentially mockInetAddress) + InetAddress wwwExampleComIpForConstructor = mockInetAddress("www.example.com", "1.2.3.4"); + mockedStaticInetAddress.when(() -> InetAddress.getByName("www.example.com")).thenReturn(wwwExampleComIpForConstructor); + + IpOrRange ipOrRange = new IpOrRange("www.example.com"); // Stored hostname: "www.example.com" + assertTrue(ipOrRange.matches("example.com", exampleComIp)); // Check: "example.com" + } + } +} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java index afe39d083f9..7b339603ae1 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java @@ -257,12 +257,13 @@ public static void example3() { public static void example2() { System.out.println("Example 2"); - RestTemplate exampleTemplate = new SecureRestTemplate.Builder(). - networkMode(BLOCK_EXTERNAL) + RestTemplate exampleTemplate = new SecureRestTemplate.Builder() + .withAllowlist("google.com") .build(); try { exampleTemplate.getForEntity("https://google.com", String.class); + System.out.println("Success"); } catch (Exception e) { System.err.println("Access blocked: " + e.getMessage()); } From ce3cabe0aca909aa03ebefe3a8c21dba82166779 Mon Sep 17 00:00:00 2001 From: Kian Jamali Date: Thu, 17 Jul 2025 21:17:49 -0400 Subject: [PATCH 28/34] Refactor: Overhaul core architecture and simplify client integration (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Restructuring pr integration (WIP) (#7) * Draft of restructuring * Integration of Rossen's changes in the existing library * Adding tests for JettyHttpClientDnsResolverTest * Fix the implementation of JettyHttpClientDnsResolver The resolve() method of JettyHttpClientDnsResolver was not propagating the result to the outer promise. This change should fix the issue by passing the result to the outer promise. * Updating Jetty's DNS resolver tests following the previous commit/fix * Adding Netty client adapter * Add getter in SecurityDnsHandler * Fix logic in DefaultInetAddressFilter * Refactor SecureRestTemplate * Refactoring UsageExample * First non-working implementation of example 8 * Fix example8() of UsageExample * Nit improvements * Renamed Hc5ClientAdapter to HttpComponentsAdapter * Moving the resolvers to our internal client folder * Moved resolvers tests * Nit: Simplified DefaultInetAddressFilter * Using Netty 1.2.6 * Nit refactoring of the gradle build file as we don't need snapshots anymore * Fix the SecurityDnsHandler to ensure that the reportOnly mode does not filter any address * Add tests for SecurityDnsHandler * Refactoring client.dns package name * Added example 9 to test out our new approach * Uncomment example usages --------- Co-authored-by: rstoyanchev Co-authored-by: Kian Jamali Co-authored-by: Gábor Vaspöri * Fix JUnit dependencies (#10) * Fix JUnit dependencies * Update JUnit dependencies to version 5.10.2 * Refactor the library with the new architecture (#11) * Add overloaded constructor for HttpComponentsDnsResolver to support default DNS resolver * Refactor JettyHttpClientDnsResolver to improve promise handling and add constructor with a default resolver * Make DefaultInetAddressFilter public and update filtering logic for allow/deny lists * Make SecurityDnsHandler constructor public and add a getter to get the reportOnly mode * Update import statements in DNS resolver test files to use correct package paths * Update UsageExample to only keep the relevant example for this new approach * Remove obsolete classes and their associated tests due to architectural changes * Update filterAddress's doc based on our recent changes * Integrate Netty client (#12) * feat: Add NettyHttpClientAddressSelector for Reactor Netty This commit introduces NettyHttpClientAddressSelector, a component that allows filtering of resolved IP addresses for Reactor Netty's HttpClient. It leverages Netty's `resolvedAddressesSelector` API and uses the existing `SecurityDnsHandler` for the filtering logic. Key changes: - Added `NettyHttpClientAddressSelector.java` implementing the `BiFunction` interface for address selection. - Added `NettyHttpClientAddressSelectorTests.java` with comprehensive unit tests. - Updated `README.md` to include documentation and usage examples for the new component, as well as for the existing JettyHttpClientDnsResolver. * docs: Add usage examples for Netty and Jetty SSRF protection This commit adds new example methods to `UsageExample.java` to demonstrate the usage of: - `NettyHttpClientAddressSelector` with Reactor Netty's HttpClient. - `JettyHttpClientDnsResolver` with Jetty's HttpClient. These examples provide practical guidance for users looking to integrate SSRF protection with these HTTP clients. The main method in `UsageExample.java` has been updated to run these new examples. * Fixing build issues and tests * Fixing tests * Improve UsageExample.java debug msgs --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> * Remove duplicated code * Add missing import for HttpComponentsDnsResolver in UsageExample * Enhance README with detailed use cases for the library * Refactor NettyHttpClientAddressSelector and SecurityDnsHandler for improved socket address handling --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --------- Co-authored-by: rstoyanchev Co-authored-by: Kian Jamali Co-authored-by: Gábor Vaspöri Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- README.md | 148 +++++++- ssrf/build.gradle | 24 +- .../client/HttpComponentsDnsResolver.java | 5 + .../client/JettyHttpClientDnsResolver.java | 18 +- .../NettyHttpClientAddressSelector.java | 72 ++++ .../client/dns/DefaultInetAddressFilter.java | 41 ++- .../java/client/dns/InetAddressFilter.java | 4 +- .../java/client/dns/SecurityDnsHandler.java | 52 ++- .../web/client/BasicSsrfProtectionFilter.java | 61 ---- .../security/web/client/ClientAdapter.java | 19 - .../security/web/client/ClientType.java | 26 -- .../security/web/client/FilterUtils.java | 65 ---- .../security/web/client/Hc5ClientAdapter.java | 83 ----- .../web/client/Hc5SsrfDnsResolver.java | 83 ----- .../web/client/HostBlockedException.java | 35 -- .../security/web/client/IpOrRange.java | 136 ------- .../web/client/JettyClientAdapter.java | 41 --- .../web/client/JettySsrfDnsResolver.java | 79 ---- .../client/ListedSsrfProtectionFilter.java | 78 ---- .../web/client/NettyClientAdapter.java | 38 -- .../web/client/NettySsrfDnsResolver.java | 186 ---------- .../security/web/client/NetworkMode.java | 25 -- .../web/client/SecureRestTemplate.java | 318 ---------------- .../web/client/SsrfProtectionFilter.java | 37 -- .../NettyHttpClientAddressSelectorTests.java | 208 +++++++++++ .../client/BasicSsrfProtectionFilterTest.java | 82 ----- .../security/web/client/FilterUtilsTest.java | 66 ---- .../web/client/Hc5SsrfDnsResolverTest.java | 104 ------ .../client/HttpComponentsDnsResolverTest.java | 122 +++++++ .../security/web/client/IpOrRangeTest.java | 316 ---------------- .../JettyHttpClientDnsResolverTest.java | 120 +++++++ .../web/client/JettySsrfDnsResolverTest.java | 115 ------ .../ListedSsrfProtectionFilterTest.java | 96 ----- .../web/client/SecurityDnsHandlerTest.java | 229 ++++++++++++ .../security/web/client/UsageExample.java | 340 ++++++------------ 35 files changed, 1117 insertions(+), 2355 deletions(-) create mode 100644 ssrf/src/main/java/client/NettyHttpClientAddressSelector.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/ClientAdapter.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/FilterUtils.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/Hc5SsrfDnsResolver.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/HostBlockedException.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/JettyClientAdapter.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/JettySsrfDnsResolver.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/NettyClientAdapter.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/NetworkMode.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java delete mode 100644 ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java create mode 100644 ssrf/src/test/java/client/NettyHttpClientAddressSelectorTests.java delete mode 100644 ssrf/src/test/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilterTest.java delete mode 100644 ssrf/src/test/java/com/google/springframework/security/web/client/FilterUtilsTest.java delete mode 100644 ssrf/src/test/java/com/google/springframework/security/web/client/Hc5SsrfDnsResolverTest.java create mode 100644 ssrf/src/test/java/com/google/springframework/security/web/client/HttpComponentsDnsResolverTest.java delete mode 100644 ssrf/src/test/java/com/google/springframework/security/web/client/IpOrRangeTest.java create mode 100644 ssrf/src/test/java/com/google/springframework/security/web/client/JettyHttpClientDnsResolverTest.java delete mode 100644 ssrf/src/test/java/com/google/springframework/security/web/client/JettySsrfDnsResolverTest.java delete mode 100644 ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java create mode 100644 ssrf/src/test/java/com/google/springframework/security/web/client/SecurityDnsHandlerTest.java diff --git a/README.md b/README.md index 5b67c211475..deb6d7f7cd4 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,100 @@ This library provides a framework for preventing Server-Side Request Forgery (SS * **Customizable:** Easily integrate with your existing DNS resolution mechanism. * **Extensible:** Create your own custom filters to implement specific SSRF protection logic. +## Use Cases + +### Mitigating SSRF in a Webhook Feature + +**Scenario:** Imagine your application provides a webhook feature. Users can specify a URL, and your application's backend will send a POST request to that URL when a specific event occurs (e.g., a new order is placed). + +**The Vulnerability:** A malicious user could configure the webhook URL to point to an internal service within your infrastructure that is not exposed to the public internet. For example: + +* `http://169.254.169.254/latest/meta-data/`: To access the EC2 instance metadata service in an AWS environment and potentially steal credentials. +* `http://localhost:8080/admin/reset-database`: To access an internal administrative endpoint. +* `http://internal-monitoring-service:9090/`: To scan for or interact with other internal services. + +Without protection, your application's server would blindly make a request to these internal URLs, leading to a classic Server-Side Request Forgery (SSRF) vulnerability. + +**The Solution:** By using this library, you can configure your HTTP client to prevent requests to internal or private IP addresses. + +Here is how you would configure a `RestTemplate` with Apache's `HttpClient` to only allow requests to external, public IP addresses. This effectively blocks webhook calls to internal services: + +```java +// 1. Create a SecurityDnsHandler to block requests to internal IP addresses. +SecurityDnsHandler securityDnsHandler = new SecurityDnsHandler.Builder() + .blockAllInternal(true) // Block private, loopback, and site-local addresses. + .build(); + +// 2. Create an HttpComponentsDnsResolver with the security handler. +HttpComponentsDnsResolver dnsResolver = new HttpComponentsDnsResolver(securityDnsHandler); + +// 3. Configure a ConnectionManager with the custom DNS resolver. +// This ensures the resolver is used for both HTTP and HTTPS connections. +Registry socketFactoryRegistry = RegistryBuilder.create() + .register("http", PlainConnectionSocketFactory.getSocketFactory()) + .register("https", SSLConnectionSocketFactory.getSocketFactory()) + .build(); +BasicHttpClientConnectionManager connectionManager = new BasicHttpClientConnectionManager( + socketFactoryRegistry, null, null, dnsResolver); + +// 4. Build an Apache HttpClient with the custom connection manager. +CloseableHttpClient httpClient = HttpClientBuilder.create() + .setConnectionManager(connectionManager) + .build(); + +// 5. Create a RestTemplate that uses the secure HttpClient. +RestTemplate webhookTemplate = new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); + +// Now, use this template to send webhooks safely. +// Object payload = ...; +// This call will succeed if api.customer.com resolves to a public IP. +webhookTemplate.postForEntity("https://api.customer.com/webhook", payload, String.class); + +// This call will be blocked by the library and throw an exception. +webhookTemplate.postForEntity("http://169.254.169.254/latest/meta-data/", payload, String.class); +``` + +This configuration ensures that your webhook feature cannot be abused to attack your internal infrastructure. + +### Securing Outgoing API Calls to Trusted Partners + +**Scenario:** Your application integrates with a limited set of trusted, third-party APIs. For example, it might need to fetch data from a payment provider like Stripe and a CRM like Salesforce. For security and compliance reasons, you want to ensure your application server can *only* make outgoing requests to these specific services. + +**The Vulnerability:** Without strict egress controls, an SSRF vulnerability in your application could be exploited by an attacker. The attacker could force your server to make requests to an external, malicious server that they control. This could be used to: + +* Exfiltrate sensitive data (e.g., environment variables, database secrets). +* Use your server as a proxy to attack other systems. +* Download and execute malware onto your server. + +**The Solution:** You can use this library's allowlist feature to create a `RestTemplate` that is only capable of communicating with the domains of your trusted partners. All other outgoing requests will be blocked at the DNS resolution level. + +```java +// 1. Create a SecurityDnsHandler that only allows requests to specific domains. +SecurityDnsHandler securityDnsHandler = new SecurityDnsHandler.Builder() + .allowList("api.stripe.com", "my-company.my.salesforce.com") + .build(); + +// 2. Create an HttpComponentsDnsResolver with the security handler. +HttpComponentsDnsResolver dnsResolver = new HttpComponentsDnsResolver(securityDnsHandler); + +// 3. Configure and build an Apache HttpClient and RestTemplate as shown in the previous example. +// ... (full configuration omitted for brevity) +CloseableHttpClient httpClient = buildSecureHttpClient(dnsResolver); +RestTemplate trustedApiTemplate = new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); + +// This call will SUCCEED because api.stripe.com is in the allowlist. +trustedApiTemplate.getForEntity("https://api.stripe.com/v1/balance", String.class); + +// This call will be BLOCKED and throw an exception because the domain is not in the allowlist. +trustedApiTemplate.getForEntity("https://api.malicious-attacker.com/exfiltrate", String.class); +``` + +This "zero trust" approach ensures that even if a vulnerability is found, the potential damage is limited because the application is prevented from communicating with unauthorized hosts. + ## Limitations This is the first iteration of the library. Currently the `RestTemplate` is backed by an Apache Commons 5 HttpClient. +Support for Jetty's `HttpClient` and Reactor Netty's `HttpClient` is also available. ## Usage @@ -35,4 +126,59 @@ try { } ``` -See `test/java/com/google/springframework/security/web/client/UsageExample.java` for more examples +### Using with Jetty's HttpClient + +To use the SSRF protection with Jetty's `HttpClient`, you can configure it with `JettyHttpClientDnsResolver`: + +```java +import org.eclipse.jetty.client.HttpClient; +import client.JettyHttpClientDnsResolver; +import client.dns.SecurityDnsHandler; + +// ... + +SecurityDnsHandler securityDnsHandler = SecurityDnsHandler.builder() + .denyList("192.168.1.100") // Example: deny a specific IP + .blockAllInternal(true) // Example: block all internal IPs + .build(); + +HttpClient httpClient = new HttpClient(); +httpClient.setSocketAddressResolver(new JettyHttpClientDnsResolver(securityDnsHandler)); + +// Start the client before using it +// httpClient.start(); + +// Now use this httpClient to make requests +// httpClient.GET("http://example.com"); + +// Stop the client when done +// httpClient.stop(); +``` + +### Using with Reactor Netty's HttpClient + +To use the SSRF protection with Reactor Netty's `HttpClient`, you can configure it with `NettyHttpClientAddressSelector`: + +```java +import reactor.netty.http.client.HttpClient; +import client.NettyHttpClientAddressSelector; +import client.dns.SecurityDnsHandler; + +// ... + +SecurityDnsHandler securityDnsHandler = SecurityDnsHandler.builder() + .allowList("1.1.1.1", "8.8.8.8") // Example: only allow specific IPs + .reportOnly(true) // Example: log instead of blocking + .build(); + +NettyHttpClientAddressSelector addressSelector = new NettyHttpClientAddressSelector(securityDnsHandler); + +HttpClient httpClient = HttpClient.create() + .resolvedAddressesSelector(addressSelector); + +// Now use this httpClient to make requests +// httpClient.get().uri("http://example.com").response().block(); +``` + +See `test/java/com/google/springframework/security/web/client/UsageExample.java` for more examples of `SecureRestTemplate`. +For `JettyHttpClientDnsResolver` and `NettyHttpClientAddressSelector`, refer to their respective test files for usage examples. diff --git a/ssrf/build.gradle b/ssrf/build.gradle index 5dce2ee739f..4dd23a1439c 100644 --- a/ssrf/build.gradle +++ b/ssrf/build.gradle @@ -16,15 +16,14 @@ repositories { dependencies { // Use JUnit Jupiter for testing. - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' testImplementation 'org.mockito:mockito-core:5.14.2' - // testImplementation 'org.mockito:mockito-inline:5.14.2' testImplementation "org.mockito:mockito-junit-jupiter:5.14.2" testImplementation 'org.springframework:spring-context:' + springVersion testImplementation "org.apache.httpcomponents.client5:httpclient5:5.4.3" testImplementation "org.apache.httpcomponents.core5:httpcore5-reactive:5.2.5" - testImplementation 'org.eclipse.jetty:jetty-client:12.1.0.alpha2' + testImplementation 'org.eclipse.jetty:jetty-client:11.0.20' api 'org.springframework:spring-web:' + springVersion api 'org.springframework:spring-core:' + springVersion @@ -32,7 +31,7 @@ dependencies { api "org.apache.httpcomponents.client5:httpclient5:5.4.3" api "org.apache.httpcomponents.core5:httpcore5-reactive:5.2.5" - api 'org.eclipse.jetty:jetty-client:12.1.0.alpha2' + api 'org.eclipse.jetty:jetty-client:11.0.20' api 'io.projectreactor:reactor-core:3.6.11' @@ -52,7 +51,22 @@ dependencies { implementation "org.springframework:spring-context:$springVersion" // Reactor Netty HTTP Client (REQUIRED for reactor.netty.http.client.HttpClient) - api 'io.projectreactor.netty:reactor-netty-http:1.1.15' // Use latest version + // api 'io.projectreactor.netty:reactor-netty-http:1.2.5' // Use latest version + api 'io.projectreactor.netty:reactor-netty-http:1.2.6' + + // Import the BOM for Reactor Netty + implementation platform('io.projectreactor:reactor-bom:2025.0.0-M3') // Use the appropriate version + + // Add Reactor Netty dependencies + // implementation 'io.projectreactor.netty:reactor-netty-core' + // implementation 'io.projectreactor.netty:reactor-netty-transport' + + implementation 'io.projectreactor.netty:reactor-netty-core:1.1.13' + + implementation 'io.projectreactor.netty:reactor-netty:1.2.6' + + testImplementation 'org.eclipse.jetty:jetty-client:11.0.20' + } tasks.named('test') { diff --git a/ssrf/src/main/java/client/HttpComponentsDnsResolver.java b/ssrf/src/main/java/client/HttpComponentsDnsResolver.java index 8563dd991e2..89dfec2d765 100644 --- a/ssrf/src/main/java/client/HttpComponentsDnsResolver.java +++ b/ssrf/src/main/java/client/HttpComponentsDnsResolver.java @@ -23,6 +23,7 @@ import client.dns.SecurityDnsHandler; import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; public class HttpComponentsDnsResolver implements DnsResolver { @@ -36,6 +37,10 @@ public HttpComponentsDnsResolver(DnsResolver delegate, SecurityDnsHandler securi this.securityDnsHandler = securityDnsHandler; } + public HttpComponentsDnsResolver(SecurityDnsHandler securityDnsHandler) { + this(SystemDefaultDnsResolver.INSTANCE, securityDnsHandler); + } + @Override public InetAddress[] resolve(String host) throws UnknownHostException { diff --git a/ssrf/src/main/java/client/JettyHttpClientDnsResolver.java b/ssrf/src/main/java/client/JettyHttpClientDnsResolver.java index 02d8f6d6160..cf403506daa 100644 --- a/ssrf/src/main/java/client/JettyHttpClientDnsResolver.java +++ b/ssrf/src/main/java/client/JettyHttpClientDnsResolver.java @@ -36,19 +36,31 @@ public JettyHttpClientDnsResolver(SocketAddressResolver delegate, SecurityDnsHan this.securityDnsHandler = securityDnsHandler; } + /** + * Creates a new instance using Jetty's default + * as the delegate resolver. + * @param securityDnsHandler The handler to apply security rules to the resolved addresses. + */ + public JettyHttpClientDnsResolver(SecurityDnsHandler securityDnsHandler) { + // Call the primary constructor, using the fully qualified name for the nested static class + SocketAddressResolver resolver = new SocketAddressResolver.Sync(); + this.delegate = resolver; + this.securityDnsHandler = securityDnsHandler; + } + @Override - public void resolve(String host, int port, Promise> promise) { + public void resolve(String host, int port, Promise> outerPromise) { this.delegate.resolve(host, port, new Promise<>() { @Override public void succeeded(List candidates) { - Promise.super.succeeded(securityDnsHandler.handleInetSocketAddresses(candidates, port)); + outerPromise.succeeded(securityDnsHandler.handleInetSocketAddresses(candidates, port)); } @Override public void failed(Throwable ex) { - Promise.super.failed(ex); + outerPromise.failed(ex); } }); } diff --git a/ssrf/src/main/java/client/NettyHttpClientAddressSelector.java b/ssrf/src/main/java/client/NettyHttpClientAddressSelector.java new file mode 100644 index 00000000000..df7d94cafaa --- /dev/null +++ b/ssrf/src/main/java/client/NettyHttpClientAddressSelector.java @@ -0,0 +1,72 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 + * + * https://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 client; + +import client.dns.SecurityDnsHandler; +// import io.projectreactor.netty.transport.ClientTransportConfig; +// import io.projectreactor.netty.transport.ResolvedAddressSelector; +// import reactor.netty.transport.ResolvedAddressSelector; + +import reactor.netty.transport.ClientTransport.ResolvedAddressSelector; +import reactor.netty.transport.ClientTransportConfig; + +import java.net.SocketAddress; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; + +/** + * A {@link ResolvedAddressSelector} that uses a {@link SecurityDnsHandler} to filter a list of + * resolved {@link SocketAddress}es for a Reactor Netty client. + * + * @see reactor.netty.http.client.HttpClient#resolvedAddressesSelector(ResolvedAddressSelector) + */ +public class NettyHttpClientAddressSelector implements ResolvedAddressSelector> { + + private final SecurityDnsHandler securityDnsHandler; + + /** + * Creates a new instance. + * @param securityDnsHandler The handler to apply security rules to the resolved addresses. + */ + public NettyHttpClientAddressSelector(SecurityDnsHandler securityDnsHandler) { + Objects.requireNonNull(securityDnsHandler, "securityDnsHandler must not be null"); + this.securityDnsHandler = securityDnsHandler; + } + + public List select(ClientTransportConfig clientTransportConfig, + Supplier> addresses) { + + List socketAddresses = addresses.get(); + if (socketAddresses == null || socketAddresses.isEmpty()) { + return socketAddresses; + } + + return this.securityDnsHandler.handleSocketAddresses(socketAddresses); + } + + @Override + public @Nullable List apply(ClientTransportConfig config, + List resolvedAddresses) { + if (resolvedAddresses == null || resolvedAddresses.isEmpty()) { + return resolvedAddresses; + } + return this.securityDnsHandler.handleSocketAddresses(resolvedAddresses); + } +} diff --git a/ssrf/src/main/java/client/dns/DefaultInetAddressFilter.java b/ssrf/src/main/java/client/dns/DefaultInetAddressFilter.java index acb4f65e885..345e8659137 100644 --- a/ssrf/src/main/java/client/dns/DefaultInetAddressFilter.java +++ b/ssrf/src/main/java/client/dns/DefaultInetAddressFilter.java @@ -27,7 +27,7 @@ import org.jspecify.annotations.Nullable; -final class DefaultInetAddressFilter implements InetAddressFilter { +public final class DefaultInetAddressFilter implements InetAddressFilter { private final List allowList; @@ -38,10 +38,15 @@ final class DefaultInetAddressFilter implements InetAddressFilter { private final List customFilters; - DefaultInetAddressFilter( + public DefaultInetAddressFilter( List allowList, List denyList, boolean blockExternal, boolean blockInternal, List customFilters) { + if (!allowList.isEmpty() && !denyList.isEmpty()) { + throw new IllegalArgumentException( + "Logic inconsistency: allowList and denyList cannot be used at the same time"); + } + this.allowList = initIpList(allowList); this.denyList = initIpList(denyList); this.blockMode = BlockMode.from(blockExternal, blockInternal); @@ -55,24 +60,42 @@ private static List initIpList(List ipList) { @Override public boolean filterAddress(InetAddress address) { - if (!passBlockMode(address) || !passDenyList(address) && !passAllowList(address)) { - return false; + // A block is final. Check all block conditions first. + + // 1. Block mode + if (!passBlockMode(address)) { + return true; // Block + } + + // 2. Deny list + if (!passDenyList(address)) { + return true; // Block } + + // 3. Custom filters for (InetAddressFilter filter : this.customFilters) { - if (!filter.filterAddress(address)) { - return false; + if (filter.filterAddress(address)) { // true from custom filter means block + return true; // Block } } - return true; + + // If an allow list is configured, the address MUST be on it to be allowed. + // This check is done after block rules, so block rules take precedence. + if (!this.allowList.isEmpty()) { + return !passAllowList(address); // Block if it doesn't pass the allow list + } + + // If we reach here, no rules have blocked the address. + return false; // Allow } private boolean passBlockMode(InetAddress address) { if (this.blockMode != null) { if (this.blockMode == BlockMode.EXTERNAL) { - return !isInternalIp(address); + return isInternalIp(address); } if (this.blockMode == BlockMode.INTERNAL) { - return isInternalIp(address); + return !isInternalIp(address); } } return true; diff --git a/ssrf/src/main/java/client/dns/InetAddressFilter.java b/ssrf/src/main/java/client/dns/InetAddressFilter.java index 139059c8094..24a8dcc616e 100644 --- a/ssrf/src/main/java/client/dns/InetAddressFilter.java +++ b/ssrf/src/main/java/client/dns/InetAddressFilter.java @@ -23,8 +23,8 @@ public interface InetAddressFilter { /** - * Return {@code true} if the address should be used, and {@code false} if - * it should be filtered out. + * Return {@code true} if the address should be filtered out, and {@code false} if + * it should be used. */ boolean filterAddress(InetAddress address); diff --git a/ssrf/src/main/java/client/dns/SecurityDnsHandler.java b/ssrf/src/main/java/client/dns/SecurityDnsHandler.java index 75c082b119e..d68bc4c578d 100644 --- a/ssrf/src/main/java/client/dns/SecurityDnsHandler.java +++ b/ssrf/src/main/java/client/dns/SecurityDnsHandler.java @@ -18,6 +18,7 @@ import java.net.InetAddress; import java.net.InetSocketAddress; +import java.net.SocketAddress; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -41,11 +42,15 @@ public final class SecurityDnsHandler { private final boolean reportOnly; - private SecurityDnsHandler(InetAddressFilter filter, boolean reportOnly) { + public SecurityDnsHandler(InetAddressFilter filter, boolean reportOnly) { this.inetAddressFilter = filter; this.reportOnly = reportOnly; } + public boolean getReportMode() { + return this.reportOnly; + } + public List handleAddresses(List candidateAddresses) { List blocked = null; @@ -64,10 +69,9 @@ public List handleAddresses(List candidateAddresses) { logger.error("Blocked IP addresses: " + candidateAddresses); } - if (candidateAddresses.size() == blocked.size()) { - if (this.reportOnly) { - return candidateAddresses; - } + + if (this.reportOnly) { + return candidateAddresses; } ArrayList result = new ArrayList<>(candidateAddresses); @@ -75,12 +79,44 @@ public List handleAddresses(List candidateAddresses) { return result; } + public List handleInetSocketAddresses(List candidates) { + if (candidates == null || candidates.isEmpty()) { + return candidates; + } + List input = candidates.stream().map(InetSocketAddress::getAddress).distinct().toList(); + List output = handleAddresses(input); + // Use the original port for each address + return candidates.stream() + .filter(isa -> output.contains(isa.getAddress())) + .toList(); + } + public List handleInetSocketAddresses(List candidates, int port) { - List input = candidates.stream().map(InetSocketAddress::getAddress).toList(); + List input = candidates.stream().map(InetSocketAddress::getAddress).distinct().toList(); List output = handleAddresses(input); return output.stream().map(address -> new InetSocketAddress(address, port)).toList(); } + public List handleSocketAddresses(List candidates) { + if (candidates == null || candidates.isEmpty()) { + return new ArrayList<>(candidates); + } + // Extract InetSocketAddress instances + List inetCandidates = candidates.stream() + .filter(InetSocketAddress.class::isInstance) + .map(InetSocketAddress.class::cast) + .toList(); + + List filteredInet = handleInetSocketAddresses(inetCandidates); + + // Only keep those InetSocketAddress that passed the filter, and preserve order + return new ArrayList( + candidates.stream() + .filter(sa -> !(sa instanceof InetSocketAddress) || filteredInet.contains(sa)) + .toList() + ); + } + public static Builder builder() { return new Builder(); @@ -102,7 +138,7 @@ public static class Builder { private boolean reportOnly; - private Builder blockAllExternal(boolean block) { + public Builder blockAllExternal(boolean block) { this.blockAllExternal = block; return this; } @@ -142,4 +178,4 @@ public SecurityDnsHandler build() { } -} +} \ No newline at end of file diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java deleted file mode 100644 index 57fb8814c5c..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilter.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed 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 - * - * https://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 com.google.springframework.security.web.client; - -import static java.util.stream.Collectors.joining; - -import java.net.InetAddress; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -class BasicSsrfProtectionFilter implements SsrfProtectionFilter { - - private final NetworkMode mode; - - public BasicSsrfProtectionFilter(NetworkMode mode) { - this.mode = mode; - } - - @Override - public InetAddress[] filterAddresses(InetAddress[] addresses) throws HostBlockedException { - - List result = new ArrayList<>(addresses.length); - - for (InetAddress addr : addresses) { - boolean isInternal = FilterUtils.isInternalIp(addr); - boolean shouldAllow = switch (mode) { - case BLOCK_EXTERNAL -> isInternal; - case BLOCK_INTERNAL -> !isInternal; - }; - if (shouldAllow) { - result.add(addr); - } - - } - - if (result.isEmpty()) { - String addrFmt = Arrays.stream(addresses).map(InetAddress::toString).collect(joining(", ")); - throw new HostBlockedException( - String.format("The following address(es) were blocked due to violating %s, policy: %s", mode.name(), - addrFmt)); - } - - return result.toArray(new InetAddress[]{}); - } - - -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientAdapter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientAdapter.java deleted file mode 100644 index f02bf3c57bb..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientAdapter.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.google.springframework.security.web.client; - -import java.util.List; -import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.web.client.RestTemplate; - - -/** - * This interface is used to abstract away the underlying HTTP Client utilized for fetching the data. - */ -public interface ClientAdapter { - - ClientHttpRequestFactory buildToHttpRequestFactory(List filters, boolean reportOnly); - - RestTemplate buildRestTemplate(List filters, boolean reportOnly); - - ReactorClientHttpConnector buildToClientHttpConnector(List filters, boolean reportOnly); -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java deleted file mode 100644 index 87f7d081edd..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/ClientType.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed 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 - * - * https://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 com.google.springframework.security.web.client; - -/** - * Enum to be used in {@link com.google.springframework.security.web.client.SecureRestTemplate} to select - * the underlying HTTP client type. - */ -public enum ClientType { - HTTP_CLIENT_5, - JETTY_CLIENT, - NETTY_CLIENT -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/FilterUtils.java b/ssrf/src/main/java/com/google/springframework/security/web/client/FilterUtils.java deleted file mode 100644 index 4a16b26f8ea..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/FilterUtils.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed 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 - * - * https://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 com.google.springframework.security.web.client; - -import java.net.InetAddress; - -class FilterUtils { - - public static boolean isInternalIp(InetAddress addr) { - - if (addr.isLoopbackAddress()) { - return true; - } - - byte[] rawAddress = addr.getAddress(); - - // there is sadly no Stream support for byte arrays - int[] iAddr = new int[rawAddress.length]; - for (int i = 0; i < rawAddress.length; i++) { - iAddr[i] = Byte.toUnsignedInt(rawAddress[i]); - } - - // Ignoring Multicast addresses - if (addr.getAddress().length == 4) { - // IPv4 filtering - // 10.x.x.x , 192.168.x.x , 172.16.x.x - if (iAddr[0] == 10 || - (iAddr[0] == 192 && iAddr[1] == 168) || - (iAddr[0] == 172 && iAddr[1] == 16)) { - return true; - } - - } else if (addr.getAddress().length == 16) { - // IPv6, check for Unique Local Addresses - if (iAddr[0] == 0xfc || iAddr[0] == 0xfd) { - return true; - } - - // IPv4/IPv6 translation, 64:ff9b - if (iAddr[0] == 0x00 && iAddr[1] == 0x64 && iAddr[2] == 0xff && iAddr[3] == 0x9b) { - int[] ipv4Part = new int[]{iAddr[12], iAddr[13], iAddr[14], iAddr[15]}; - // same check as above plus a check for loopback - if (ipv4Part[0] == 10 || ipv4Part[0] == 127 || - (ipv4Part[0] == 192 && ipv4Part[1] == 168) || - (ipv4Part[0] == 172 && ipv4Part[1] == 16)) { - return true; - } - } - } - return false; - } -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java deleted file mode 100644 index 74fa07218db..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5ClientAdapter.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.google.springframework.security.web.client; - -import java.util.List; -import java.util.function.Function; -import org.apache.hc.client5.http.DnsResolver; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; -import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; -import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; -import org.apache.hc.client5.http.io.HttpClientConnectionManager; -import org.apache.hc.client5.http.socket.ConnectionSocketFactory; -import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; -import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; -import org.apache.hc.core5.http.config.Registry; -import org.apache.hc.core5.http.config.RegistryBuilder; -import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.web.client.RestTemplate; - -public class Hc5ClientAdapter implements ClientAdapter { - - private Function customBuilder = null; - - private Function, HttpClientConnectionManager> customConnectionMgr = null; - - - public Hc5ClientAdapter() { - } - - public static Hc5ClientAdapter withCustomBuilder(Function customBuilder) { - Hc5ClientAdapter hc5ClientAdapter = new Hc5ClientAdapter(); - hc5ClientAdapter.customBuilder = customBuilder; - return hc5ClientAdapter; - } - - public Hc5ClientAdapter withConnectionManager( - Function, HttpClientConnectionManager> customConnectionMgr) { - this.customConnectionMgr = customConnectionMgr; - return this; - } - - @Override - public ClientHttpRequestFactory buildToHttpRequestFactory(List filters, - boolean reportOnly) { - Registry registry = RegistryBuilder.create() - .register("http", PlainConnectionSocketFactory.getSocketFactory()) - .register("https", SSLConnectionSocketFactory.getSocketFactory()).build(); - - Hc5SsrfDnsResolver hc5SsrfDnsResolver = new Hc5SsrfDnsResolver(filters, reportOnly); - if (customBuilder != null) { - return customBuilder.apply(hc5SsrfDnsResolver); - } - - HttpClientConnectionManager connManager = null; - if (customConnectionMgr != null) { - connManager = customConnectionMgr.apply(registry); - } else { - connManager = PoolingHttpClientConnectionManagerBuilder.create().setDnsResolver(hc5SsrfDnsResolver).build(); - } - - // If connManager is null ( default ), this will use a PoolingHttpClientConnectionManager - // This behaviour corresponds to the HttpComponentsClientHttpRequestFactory default. - CloseableHttpClient httpClient = HttpClientBuilder.create().useSystemProperties() - .setConnectionManager(connManager) - .build(); - - HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory( - httpClient); - return requestFactory; - } - - @Override - public RestTemplate buildRestTemplate(List filters, boolean reportOnly) { - return new RestTemplate(buildToHttpRequestFactory(filters, reportOnly)); - } - - @Override - public ReactorClientHttpConnector buildToClientHttpConnector(List filters, - boolean reportOnly) { - throw new UnsupportedOperationException(); - } -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5SsrfDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5SsrfDnsResolver.java deleted file mode 100644 index 45bcd2ffe5d..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/Hc5SsrfDnsResolver.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed 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 - * - * https://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 com.google.springframework.security.web.client; - -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.util.Arrays; -import java.util.List; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.apache.hc.client5.http.DnsResolver; - -class Hc5SsrfDnsResolver implements DnsResolver { - - private static final Log logger = LogFactory.getLog(Hc5SsrfDnsResolver.class); - - protected final List filters; - protected boolean reportOnly; - - public Hc5SsrfDnsResolver(List filters, boolean reportOnly) { - this.filters = filters; - this.reportOnly = reportOnly; - } - - @Override - public InetAddress[] resolve(final String host) throws UnknownHostException { - - // Internally these results are cached for 30 seconds (by default) to prevent naive DNS rebinding - // It's important to fetch it from the cache before running checks and to not run resolution again. - // ( Otherwise this would make us vulnerable to high-frequency switching between valid-invalid addresses ) - InetAddress[] cachedResult = resolveAll(host); - InetAddress[] results = Arrays.copyOf(cachedResult, cachedResult.length); - - try { - for (SsrfProtectionFilter f : filters) { - // each filter can restrict the list of addresses resolved to a given host - results = f.filterAddresses(results); - } - return results; - } catch (HostBlockedException e) { - // log error as well, exception can't be chained - logger.error("DNS resolution for '" + host + "' resulted in error", e); - if ( !reportOnly ) { - throw new UnknownHostException( - "Access to " + host + " was blocked because it violates the SSRF protection config"); - } else { - return cachedResult; - } - } - - } - - // Address resolution moved to a helper function for testing purposes - protected InetAddress[] resolveAll(String host) throws UnknownHostException { - return InetAddress.getAllByName(host); - } - - @Override - public String resolveCanonicalHostname(String host) throws UnknownHostException { - if (host == null) { - return null; - } - final InetAddress in = InetAddress.getByName(host); - final String canonicalServer = in.getCanonicalHostName(); - if (in.getHostAddress().contentEquals(canonicalServer)) { - return host; - } - return canonicalServer; - } -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/HostBlockedException.java b/ssrf/src/main/java/com/google/springframework/security/web/client/HostBlockedException.java deleted file mode 100644 index ee256d7db39..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/HostBlockedException.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed 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 - * - * https://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 com.google.springframework.security.web.client; - -import java.io.IOException; - -/** - * Exception thrown when a request violates the security criteria specified in a - * {@link com.google.springframework.security.web.client.SsrfProtectionFilter} - */ -public class HostBlockedException extends IOException { - - private static final long serialVersionUID = 1; - - public HostBlockedException(String message) { - super(message); - } - - public HostBlockedException() { - } - -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java b/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java deleted file mode 100644 index 333a0313f59..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/IpOrRange.java +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed 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 - * - * https://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 com.google.springframework.security.web.client; - -import java.net.InetAddress; -import java.net.UnknownHostException; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - -/** - * Class to represent and IPv4 or IPv6 range to be used in filtering. Inspired by: - * org.springframework.security.web.util.matcher.IpAddressMatcher.java - */ -public final class IpOrRange { - - private static final Log logger = LogFactory.getLog(IpOrRange.class); - private final InetAddress address; - private final int nMaskBits; - private final String hostname; - - public IpOrRange(String addressOrRange) { - String addressToParse; - String originalInputAddress; - - if (addressOrRange.indexOf('/') > 0) { - String[] addressAndMask = addressOrRange.split("/"); - originalInputAddress = addressAndMask[0]; - addressToParse = addressAndMask[0]; - this.nMaskBits = Integer.parseInt(addressAndMask[1]); - } else { - originalInputAddress = addressOrRange; - addressToParse = addressOrRange; - this.nMaskBits = -1; - } - - if (originalInputAddress.matches(".*[a-zA-Z].*") && !originalInputAddress.contains(":")) { - this.hostname = originalInputAddress; - } else { - this.hostname = null; - } - - this.address = parseAddress(addressToParse); - } - - private String stripWww(String host) { - // host is expected to be non-null by callers in the matches method. - if (host.toLowerCase().startsWith("www.")) { - return host.substring(4); - } - return host; - } - - public boolean matches(String toCheckAddressString, InetAddress toCheckInetAddress) { - if (this.hostname != null) { - // Check if toCheckAddressString is a hostname - if (toCheckAddressString.matches(".*[a-zA-Z].*") && !toCheckAddressString.contains(":")) { - String normalizedStoredHostname = stripWww(this.hostname); - String normalizedToCheckHostname = stripWww(toCheckAddressString); - return normalizedStoredHostname.equalsIgnoreCase(normalizedToCheckHostname); - } - // If this.hostname is not null, but toCheckAddressString is an IP, fall through to IP matching - } - - // IP matching logic (either this.hostname is null, or it's a hostname but toCheckAddressString is an IP) - if (this.nMaskBits < 0) { - // This means this IpOrRange is a single IP address (not a range) - if (this.address == null) { // Should not happen if constructor logic is correct - return false; - } - return this.address.equals(toCheckInetAddress); - } - - // This is a range comparison - if (this.address == null || toCheckInetAddress == null) { // Should not happen - return false; - } - - byte[] remAddr = toCheckInetAddress.getAddress(); - byte[] reqAddr = this.address.getAddress(); - - // Ensure address families are the same - if (remAddr.length != reqAddr.length) { - return false; - } - - int nMaskFullBytes = this.nMaskBits / 8; - byte finalByte = (byte) (0xFF00 >> (this.nMaskBits & 0x07)); // MASK for last byte - - for (int i = 0; i < nMaskFullBytes; i++) { - if (remAddr[i] != reqAddr[i]) { - return false; - } - } - - if (finalByte != 0) { // Check if the mask covers a partial byte - return (remAddr[nMaskFullBytes] & finalByte) == (reqAddr[nMaskFullBytes] & finalByte); - } - - // If mask is a multiple of 8, then all necessary bytes already matched - return true; - } - - private InetAddress parseAddress(String address) { - try { - InetAddress result = InetAddress.getByName(address); - if (address.matches(".*[a-zA-Z\\-].*$") && !address.contains(":")) { - logger.warn("Hostname '" + address + "' resolved to " + result.toString() - + " will be used on IP address matching"); - } - return result; - } catch (UnknownHostException ex) { - throw new IllegalArgumentException(String.format("Failed to parse address '%s'", address), ex); - } - } - - @Override - public String toString() { - return "IpOrRange{" + - "address=" + address + - ", nMaskBits=" + nMaskBits + - '}'; - } -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/JettyClientAdapter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/JettyClientAdapter.java deleted file mode 100644 index dad38a7a2a8..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/JettyClientAdapter.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.google.springframework.security.web.client; - -import java.util.List; -import org.eclipse.jetty.client.HttpClient; -import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.http.client.JettyClientHttpRequestFactory; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.web.client.RestTemplate; - -public class JettyClientAdapter implements ClientAdapter { - - private HttpClient jettyClient = null; - - private ClientHttpRequestFactory makeJettyClient(JettySsrfDnsResolver dnsResolver) { - jettyClient.setSocketAddressResolver(dnsResolver); - JettyClientHttpRequestFactory requestFactory = new JettyClientHttpRequestFactory(jettyClient); - return requestFactory; - } - - public JettyClientAdapter(HttpClient jettyClient) { - this.jettyClient = jettyClient; - } - - public RestTemplate buildRestTemplate(List filters, boolean reportOnly) { - return new RestTemplate(makeJettyClient(new JettySsrfDnsResolver(filters, reportOnly))); - } - - @Override - public ReactorClientHttpConnector buildToClientHttpConnector(List filters, - boolean reportOnly) { - throw new UnsupportedOperationException(); - } - - public ClientHttpRequestFactory buildToHttpRequestFactory(List filters, boolean reportOnly) { - jettyClient.setSocketAddressResolver(new JettySsrfDnsResolver(filters, reportOnly)); - return new JettyClientHttpRequestFactory(jettyClient); - - } - - -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/JettySsrfDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/client/JettySsrfDnsResolver.java deleted file mode 100644 index 3c1d8489588..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/JettySsrfDnsResolver.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed 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 - * - * https://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 com.google.springframework.security.web.client; - - -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.UnknownHostException; -import java.util.Arrays; -import java.util.List; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.eclipse.jetty.util.Promise; -import org.eclipse.jetty.util.SocketAddressResolver; - -class JettySsrfDnsResolver implements SocketAddressResolver { - - private static final Log logger = LogFactory.getLog(JettySsrfDnsResolver.class); - - protected final List filters; - protected boolean reportOnly; - - public JettySsrfDnsResolver(List filters, boolean reportOnly) { - this.filters = filters; - this.reportOnly = reportOnly; - } - - // Address resolution moved to a helper function for testing purposes - protected InetAddress[] resolveAll(String host) throws UnknownHostException { - return InetAddress.getAllByName(host); - } - - @Override - public void resolve(String host, int port, Promise> promise) { - - InetAddress[] addresses = null; - try { - addresses = resolveAll(host); - - InetAddress[] filteredAddresses = addresses; - for (SsrfProtectionFilter f : filters) { - filteredAddresses = f.filterAddresses(filteredAddresses); - } - - List socketAddresses = Arrays.stream(filteredAddresses) - .map(address -> new InetSocketAddress(address, port)).toList(); - - promise.succeeded(socketAddresses); - } catch (HostBlockedException e) { - - logger.error("DNS resolution for '" + host + "' resulted in error", e); - - if (reportOnly) { - List socketAddresses = Arrays.stream(addresses) - .map(address -> new InetSocketAddress(address, port)).toList(); - promise.succeeded(socketAddresses); - } else { - promise.failed(new UnknownHostException( - String.format("Access to '%s' was blocked because it violates the SSRF protection config", - host))); - } - } catch (UnknownHostException e) { - promise.failed(e); - } - } -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java deleted file mode 100644 index 04d73adb936..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilter.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed 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 - * - * https://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 com.google.springframework.security.web.client; - -import static java.util.stream.Collectors.joining; - -import java.net.InetAddress; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -class ListedSsrfProtectionFilter implements SsrfProtectionFilter { - - /** - * FilterMode enum to make usage more intuitive ( practically this is just a bool ) - */ - public enum FilterMode { - BLOCK_LIST, - ALLOW_LIST, - } - - private List matchingRules; - - private FilterMode mode; - - public ListedSsrfProtectionFilter(List addressList, FilterMode mode) { - this.matchingRules = addressList; - this.mode = mode; - } - - @Override - public InetAddress[] filterAddresses(InetAddress[] addresses) throws HostBlockedException { - List result = new ArrayList<>(addresses.length); - - outerLoop: - for (InetAddress addr : addresses) { - if (mode == FilterMode.BLOCK_LIST) { - for (IpOrRange ipOrRange : matchingRules) { - if (ipOrRange.matches(addr.getHostName(), addr)) { - continue outerLoop; - } - } - result.add(addr); - } else if (mode == FilterMode.ALLOW_LIST) { - for (IpOrRange ipOrRange : matchingRules) { - if (ipOrRange.matches(addr.getHostName(), addr)) { - result.add(addr); - continue outerLoop; - } - } - } - } - - if (result.isEmpty()) { - String addrFmt = Arrays.stream(addresses).map(InetAddress::toString).collect(joining(", ")); - throw new HostBlockedException( - String.format("The following address(es) were blocked due to violating %s , policy: %s", - mode.name(), addrFmt)); - } - - return result.toArray(new InetAddress[]{}); - } - - -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/NettyClientAdapter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/NettyClientAdapter.java deleted file mode 100644 index c5f20e0895d..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/NettyClientAdapter.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.google.springframework.security.web.client; - -import java.util.List; -import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.http.client.ReactorNettyClientRequestFactory; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.web.client.RestTemplate; -import reactor.netty.http.client.HttpClient; - -public class NettyClientAdapter implements ClientAdapter { - - private HttpClient nettyClient; - - public NettyClientAdapter(HttpClient nettyClient) { - this.nettyClient = nettyClient; - } - - @Override - public ClientHttpRequestFactory buildToHttpRequestFactory(List filters, - boolean reportOnly) { - NettySsrfDnsResolver nettyResolver = new NettySsrfDnsResolver(filters, reportOnly); - return new ReactorNettyClientRequestFactory(nettyClient.resolver(nettyResolver)); - } - - @Override - public RestTemplate buildRestTemplate(List filters, boolean reportOnly) { - - return new RestTemplate(buildToHttpRequestFactory(filters, reportOnly)); - } - - public ReactorClientHttpConnector buildToClientHttpConnector(List filters, - boolean reportOnly) { - NettySsrfDnsResolver nettyResolver = new NettySsrfDnsResolver(filters, reportOnly); - return new ReactorClientHttpConnector(nettyClient.resolver(nettyResolver)); - - } - -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java b/ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java deleted file mode 100644 index e4e5b0c02db..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/NettySsrfDnsResolver.java +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed 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 - * - * https://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 com.google.springframework.security.web.client; - -import io.netty.resolver.AddressResolver; -import io.netty.resolver.AddressResolverGroup; -import io.netty.resolver.DefaultAddressResolverGroup; -import io.netty.util.concurrent.*; -import java.net.InetSocketAddress; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.SocketAddress; -import java.net.UnknownHostException; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - - -public class NettySsrfDnsResolver extends AddressResolverGroup { - - private static final Log logger = LogFactory.getLog(NettySsrfDnsResolver.class); - - private final List filters; - private final boolean reportOnly; - private final AddressResolverGroup defaultResolverGroup; - - public NettySsrfDnsResolver(List filters, boolean reportOnly) { - this(filters, reportOnly, DefaultAddressResolverGroup.INSTANCE); - } - - // For testing - protected NettySsrfDnsResolver(List filters, boolean reportOnly, - AddressResolverGroup defaultResolverGroup) { - this.filters = filters; - this.reportOnly = reportOnly; - this.defaultResolverGroup = defaultResolverGroup; - } - - @Override - protected AddressResolver newResolver(EventExecutor executor) { - return new AddressResolver() { - private final AddressResolver resolver = defaultResolverGroup.getResolver(executor); - - @Override - public boolean isSupported(SocketAddress address) { - if (address instanceof InetSocketAddress) { - return resolver.isSupported((InetSocketAddress) address); - } - return false; - } - - @Override - public boolean isResolved(SocketAddress address) { - return resolver.isResolved(address); - } - - @Override - public Future resolve(SocketAddress address) { - return resolver.resolve(address); - } - - @Override - public Future resolve(SocketAddress address, - Promise promise) { - return resolver.resolve(address, promise); - } - - @Override - public Future> resolveAll(SocketAddress address) { - if (address instanceof InetSocketAddress inetSocketAddress && inetSocketAddress.isUnresolved()) { - // This is where we apply our SSRF filtering. - return resolveAllUnresolved(inetSocketAddress.getHostName(), inetSocketAddress.getPort(), executor.newPromise()); - } - // If it's already resolved or not an InetSocketAddress, use the default resolver. - if(address instanceof InetSocketAddress) { - return resolver.resolveAll((InetSocketAddress) address); - } - return executor.newFailedFuture(new IllegalArgumentException("Unsupported address type: " + address.getClass())); - } - - @Override - public Future> resolveAll(SocketAddress address, Promise> promise) { - if (address instanceof InetSocketAddress inetSocketAddress && inetSocketAddress.isUnresolved()) { - return resolveAllUnresolved(inetSocketAddress.getHostName(), inetSocketAddress.getPort(), promise); - } - if(address instanceof InetSocketAddress){ - return resolver.resolveAll((InetSocketAddress) address, promise); - } - return promise.setFailure(new IllegalArgumentException("Unsupported address type: " + address.getClass())); - - } - - - // Helper method to handle the actual filtering logic (for unresolved addresses) - private Future> resolveAllUnresolved(String host, int port, Promise> promise) { - Future> future; - try{ - future = resolveWithDefaultResolver(host, port); - } catch(UnknownHostException e){ - return promise.setFailure(e); - } - - future.addListener((FutureListener>) f -> { - if (f.isSuccess()) { - // 1. Get the resolved addresses from the default resolver - List resolvedAddresses = f.getNow(); - - // 2. Convert to InetAddress array (for your filter interface) - InetAddress[] inetAddresses = resolvedAddresses.stream() - .map(InetSocketAddress::getAddress) - .toArray(InetAddress[]::new); - - // 3. Apply SSRF filters - try { - InetAddress[] filteredAddresses = inetAddresses; - for (SsrfProtectionFilter filter : filters) { - filteredAddresses = filter.filterAddresses(filteredAddresses); - } - - // 4. Convert back to InetSocketAddress list - List filteredSocketAddresses = Arrays.stream(filteredAddresses) - .map(addr -> new InetSocketAddress(addr, port)) // Use original port - .toList(); - - // 5. Fulfill the promise with the FILTERED results - promise.setSuccess(filteredSocketAddresses); - - } catch (HostBlockedException e) { - logger.error("DNS resolution for '" + host + "' blocked by SSRF filter", e); - if (reportOnly) { - // In report-only mode, we still succeed with the *original* addresses - promise.setSuccess(resolvedAddresses); - } else { - // Block the resolution - promise.setFailure(new UnknownHostException( - String.format("Access to '%s' was blocked: %s", host, e.getMessage()))); - } - } - } else { - // If the default resolver failed, propagate the failure - promise.setFailure(f.cause()); - } - }); - - return promise; // Return the promise to the caller - } - - - @Override - public void close() { - resolver.close(); // Close the underlying default resolver - } - }; - } - - //for testing - Future> resolveWithDefaultResolver(String host, int port) throws UnknownHostException { - // Use DefaultAddressResolverGroup to perform the actual DNS lookup - InetSocketAddress unresolvedAddress = InetSocketAddress.createUnresolved(host, port); - - //This is just to check to see if the default resolver will throw an exception - resolveAll(host); - - return defaultResolverGroup.getResolver(ImmediateEventExecutor.INSTANCE).resolveAll(unresolvedAddress); - } - - //for testing - protected InetAddress[] resolveAll(String host) throws UnknownHostException { - return InetAddress.getAllByName(host); - } -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/NetworkMode.java b/ssrf/src/main/java/com/google/springframework/security/web/client/NetworkMode.java deleted file mode 100644 index 0e47cf9863d..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/NetworkMode.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed 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 - * - * https://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 com.google.springframework.security.web.client; - -/** - * This specifies if the {@link com.google.springframework.security.web.client.SecureRestTemplate} should - * allow request to the local network only or only towards the internet only ( e.g. to prevent access to cloud VM metadata ). - */ -public enum NetworkMode { - BLOCK_EXTERNAL, - BLOCK_INTERNAL -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java deleted file mode 100644 index ffb6e9993e8..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SecureRestTemplate.java +++ /dev/null @@ -1,318 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed 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 - * - * https://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 com.google.springframework.security.web.client; - -import static java.util.stream.Collectors.toList; - -import com.google.springframework.security.web.client.ListedSsrfProtectionFilter.FilterMode; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.http.client.reactive.ClientHttpConnector; -import org.springframework.util.ClassUtils; -import org.springframework.web.client.RestTemplate; - -/** - * SecureRestTemplate provides a way to create a RestTemplate which protects against unintentional network access and - * provides mitigations against Server Side Resource Forgery via DNS rebinding. Use the associated - * {@link com.google.springframework.security.web.client.SecureRestTemplate.Builder} to create new instances, it also - * provides a method to create the underlying {@link org.springframework.http.client.ClientHttpRequestFactory}. - * Currently two flavours are supported, backed by Apache HttpClient 5 or Jetty. - */ -public class SecureRestTemplate { - - /** - * Helper enum to make configuring with system properties easier, when using {@link #buildDefault()} - * - * @see #ALLOW_LIST - * @see #DENY_LIST - * @see #BLOCK_EXTERNAL - * @see #BLOCK_INTERNAL - */ - private enum ProtectionMode { - /** - * Use the ssrf.protection.iplist property in {@link #buildDefault()} as an allow-list. - */ - ALLOW_LIST, - /** - * Use the ssrf.protection.iplist property in {@link #buildDefault()} as a deny-list. - */ - DENY_LIST, - - /** - * Block requests directed towards the non-local, non-loopback addresses. - */ - BLOCK_EXTERNAL, - - /** - * Block requests directed towards the local or loopback addresses. - */ - BLOCK_INTERNAL, - } - - private static final boolean hc5Present; - - static { - ClassLoader classLoader = RestTemplate.class.getClassLoader(); - hc5Present = ClassUtils.isPresent("org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager", - classLoader); - } - - /** - * Build {@link com.google.springframework.security.web.client.SecureRestTemplate} based on JVM global system - * properties. The following properties can be used to configured: - *

    - *
  • ssrf.protection.mode
  • Mandatory property to specify - * {@link com.google.springframework.security.web.client.SecureRestTemplate.ProtectionMode} if you would like to use this method. - *
  • ssrf.protection.iplist
  • - * The list of ip addresses or hostnames to use for allow-listing/block-listing based on the property above when it's set to - * ALLOW_LIST or DENY_LIST. - *
  • ssrf.protection.report_only
  • If set, request are not blocked just logged. Only the existence of the property is checked, the value is ignored. - * - *
- */ - - public static RestTemplate buildDefault() { - - String modeProperty = System.getProperty("ssrf.protection.mode"); - - SsrfProtectionFilter filter = null; - - if (modeProperty == null) { - throw new IllegalStateException("ssrf.protection.mode is not set but defaultFilter() requested"); - } - ProtectionMode mode = ProtectionMode.valueOf(modeProperty.toUpperCase()); - - boolean reportOnly = System.getProperty("ssrf.protection.report_only") != null; - - if (mode == ProtectionMode.ALLOW_LIST || mode == ProtectionMode.DENY_LIST) { - String ipList = System.getProperty("ssrf.protection.iplist"); - if (ipList == null) { - throw new IllegalStateException( - "ssrf.protection.iplist is required for ALLOW_LIST or DENY_LIST modes in comma separated CIDR format"); - } - FilterMode filterMode = (mode == ProtectionMode.ALLOW_LIST ? FilterMode.ALLOW_LIST : FilterMode.BLOCK_LIST); - filter = new ListedSsrfProtectionFilter( - Arrays.stream(ipList.strip().split(",")).map(IpOrRange::new).toList(), filterMode); - } else if (mode == ProtectionMode.BLOCK_INTERNAL || mode == ProtectionMode.BLOCK_EXTERNAL) { - NetworkMode filterMode = (mode == ProtectionMode.BLOCK_INTERNAL ? NetworkMode.BLOCK_INTERNAL - : NetworkMode.BLOCK_EXTERNAL); - - filter = new BasicSsrfProtectionFilter(filterMode); - } - - return new Builder().reportOnly(reportOnly).withCustomFilter(filter).build(); - } - - /** - * Builder class to create a {@link com.google.springframework.security.web.client.SecureRestTemplate} or an - * underlying {@link org.springframework.http.client.ClientHttpRequestFactory}. It also exposes ways to create the - * underlying DNS-resolvers which contain the heart of the protection logic. - */ - public static class Builder { - - private List customFilters = new ArrayList<>(); - - // Only one of the two can be used at the same time - private List ipAllowList = new ArrayList<>(); - private List ipBlockList = new ArrayList<>(); - private boolean isReportOnly = false; - private NetworkMode networkMode = null; - - private ClientType clientType = ClientType.HTTP_CLIENT_5; - - private ClientAdapter clientAdapter = null; - - /** - * Create a {@link com.google.springframework.security.web.client.SecureRestTemplate} by using a Jetty client. - * - * {@see UsageExample.java} - * - * @param jettyClient a {@link JettyClientAdapter} should be used here - */ - public Builder fromJettyClient(ClientAdapter jettyClient) { - // TODO(vaspori): make sure clientType and adapter are consistent or remove clientType - this.clientType = ClientType.JETTY_CLIENT; - this.clientAdapter = jettyClient; - return this; - } - - - /** - * Create a {@link com.google.springframework.security.web.client.SecureRestTemplate} by using a Netty client. - * - * {@see UsageExample.java} - * - * @param nettyClient a {@link NettyClientAdapter} should be used here - */ - public Builder fromNettyClient(ClientAdapter nettyClient) { - this.clientType = ClientType.NETTY_CLIENT; - this.clientAdapter = nettyClient; - return this; - } - - /** - * Create a {@link com.google.springframework.security.web.client.SecureRestTemplate} by using a Apache - * HttpClient . {@link Hc5ClientAdapter} makes it also possible to customize the parameters of the underlying - * client: {@see UsageExample.java} - * - * @param hc5Client a {@link Hc5ClientAdapter} should be used here - */ - public Builder fromApacheClient(ClientAdapter hc5Client) { - this.clientType = ClientType.HTTP_CLIENT_5; - this.clientAdapter = hc5Client; - return this; - } - - /** - * Create a default Builder, an Apache HttpClient will be used as a default. If the library is not in the - * classpath, the {@see build()} method will throw a RuntimeException(). - */ - public Builder() { - this.clientType = ClientType.HTTP_CLIENT_5; - - } - - /** - * When set to true rule violating requests are not blocked only logged. - */ - public Builder reportOnly(boolean isReportOnly) { - this.isReportOnly = isReportOnly; - return this; - } - - /** - * Set mode do block requests towards the internet or block requests towards the internet. - */ - public Builder networkMode(NetworkMode networkMode) { - this.networkMode = networkMode; - return this; - } - - /** - * List of ip-addresses or hostnames to use in an allow-list. - */ - public Builder withAllowlist(String... ipList) { - ipAllowList.addAll(List.of(ipList)); - return this; - } - - /** - * List of ip-addresses or hostnames to use in an allow-list. - */ - public Builder withAllowlist(Iterable ipList) { - ipList.forEach(ipAllowList::add); - return this; - } - - - /** - * List of ip-addresses or hostnames to use in an block-list. - */ - public Builder withBlocklist(String... ipList) { - ipBlockList.addAll(List.of(ipList)); - return this; - } - - /** - * List of ip-addresses or hostnames to use in an block-list. - */ - public Builder withBlocklist(Iterable ipList) { - ipList.forEach(ipBlockList::add); - return this; - } - - /** - * When very specific criteria are needed to block or allow a request a custom - * {@link com.google.springframework.security.web.client.SsrfProtectionFilter} implementation can be plugged - * in. - */ - public Builder withCustomFilter(SsrfProtectionFilter filter) { - customFilters.add(filter); - return this; - } - - private List makeFilters() { - List filters = new ArrayList<>(); - - if (ipAllowList.size() != 0 && ipBlockList.size() != 0) { - throw new IllegalArgumentException( - "Logic inconsistency: ipBlockList and -AllowList can not be used at the same time"); - } - - if (networkMode != null) { - filters.add(new BasicSsrfProtectionFilter(networkMode)); - } - - if (ipAllowList.size() > 0) { - filters.add(new ListedSsrfProtectionFilter(ipAllowList.stream().map(IpOrRange::new).collect(toList()), - FilterMode.ALLOW_LIST)); - } - if (ipBlockList.size() > 0) { - filters.add(new ListedSsrfProtectionFilter(ipBlockList.stream().map(IpOrRange::new).collect(toList()), - FilterMode.BLOCK_LIST)); - } - - filters.addAll(customFilters); - return filters; - } - - private void checkDependencies() { - if (clientType == ClientType.HTTP_CLIENT_5 && clientAdapter == null) { - if (SecureRestTemplate.hc5Present) { - try { - Class aClass = Class.forName( - "com.google.springframework.security.web.client.Hc5ClientAdapter"); - this.clientAdapter = (ClientAdapter) aClass.getDeclaredConstructor().newInstance(); - } catch (Exception e) { - throw new RuntimeException(e); - } - } else { - throw new RuntimeException( - "Dependency org.apache.httpcomponents.client5:httpclient5 required for this RestTemplate"); - } - } - - } - - public ClientHttpConnector buildToClientHttpConnector() { - if (clientType != ClientType.NETTY_CLIENT) { - throw new IllegalStateException("buildToClientHttpConnector() can only be used with NETTY_CLIENT."); - } - return clientAdapter.buildToClientHttpConnector(makeFilters(), isReportOnly); - } - - - /** - * Helper method to create a {@link org.springframework.http.client.ClientHttpRequestFactory} for more - * customization before creating a RestTemplate or RestClient. - */ - public ClientHttpRequestFactory buildToHttpRequestFactory() { - checkDependencies(); - return this.clientAdapter.buildToHttpRequestFactory(makeFilters(), isReportOnly); - } - - /** - * Create the {@link com.google.springframework.security.web.client.SecureRestTemplate} configured by this - * builder. - */ - public RestTemplate build() { - checkDependencies(); - return this.clientAdapter.buildRestTemplate(makeFilters(), isReportOnly); - } - } -} diff --git a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java b/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java deleted file mode 100644 index 080f1ac0154..00000000000 --- a/ssrf/src/main/java/com/google/springframework/security/web/client/SsrfProtectionFilter.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed 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 - * - * https://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 com.google.springframework.security.web.client; - -import java.net.InetAddress; - -/** - * The interface which is used to implement all filtering logic in the DNS resolvers used by {@link SecureRestTemplate}. - */ -public interface SsrfProtectionFilter { - - /** - * Because a hostname can be resolved to multiple addresses ( e.g. round-robin DNS ) all implementations - * must check that all the address conform to their internal filtering logic. - * - * @param addresses list addresses to checked against a filtering criteria. - * @return the list of InetAddress that pass through the filter - * - * @throws HostBlockedException when there are no addresses that pass the filtering criteria this exception should be thrown. - */ - - InetAddress[] filterAddresses(final InetAddress[] addresses) throws HostBlockedException; - -} diff --git a/ssrf/src/test/java/client/NettyHttpClientAddressSelectorTests.java b/ssrf/src/test/java/client/NettyHttpClientAddressSelectorTests.java new file mode 100644 index 00000000000..b1ea22811fa --- /dev/null +++ b/ssrf/src/test/java/client/NettyHttpClientAddressSelectorTests.java @@ -0,0 +1,208 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 + * + * https://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 client; + +import client.dns.SecurityDnsHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.netty.transport.ClientTransportConfig; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class NettyHttpClientAddressSelectorTests { + + private ClientTransportConfig mockConfig; + + @BeforeEach + void setUp() { + mockConfig = mock(ClientTransportConfig.class); + } + + private InetSocketAddress createInetSocketAddress(String hostname, int port) { + try { + return new InetSocketAddress(InetAddress.getByName(hostname), port); + } catch (UnknownHostException e) { + throw new RuntimeException(e); + } + } + + @Test + void selectWhenEmptyListShouldReturnEmptyList() { + SecurityDnsHandler handler = SecurityDnsHandler.builder().build(); + NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); + List result = selector.select(mockConfig, Collections::emptyList); + assertTrue(result.isEmpty()); + } + + @Test + void selectWhenNullListShouldReturnNull() { + SecurityDnsHandler handler = SecurityDnsHandler.builder().build(); + NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); + List result = selector.select(mockConfig, () -> null); + assertNull(result); + } + + @Test + void selectWithNoFilterShouldReturnOriginalList() { + SecurityDnsHandler handler = SecurityDnsHandler.builder().build(); + NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); + List addresses = List.of( + createInetSocketAddress("1.1.1.1", 80), + createInetSocketAddress("8.8.8.8", 80) + ); + List result = selector.select(mockConfig, () -> addresses); + assertEquals(addresses, result); + } + + @Test + void selectWithAllowListShouldFilter() { + SecurityDnsHandler handler = SecurityDnsHandler.builder().allowList("1.1.1.1").build(); + NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); + List addresses = List.of( + createInetSocketAddress("1.1.1.1", 80), + createInetSocketAddress("8.8.8.8", 80), + createInetSocketAddress("1.0.0.1", 80) + ); + List result = selector.select(mockConfig, () -> addresses); + assertEquals(List.of(createInetSocketAddress("1.1.1.1", 80)), result); + } + + @Test + void selectWithDenyListShouldFilter() { + SecurityDnsHandler handler = SecurityDnsHandler.builder().denyList("8.8.8.8").build(); + NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); + List addresses = List.of( + createInetSocketAddress("1.1.1.1", 80), + createInetSocketAddress("8.8.8.8", 80), + createInetSocketAddress("1.0.0.1", 80) + ); + List result = selector.select(mockConfig, () -> addresses); + assertEquals(List.of( + createInetSocketAddress("1.1.1.1", 80), + createInetSocketAddress("1.0.0.1", 80) + ), result); + } + + @Test + void selectWithBlockExternalShouldFilter() throws UnknownHostException { + // 10.0.0.1 is internal, 8.8.8.8 is external + SecurityDnsHandler handler = SecurityDnsHandler.builder().blockAllExternal(true).build(); + NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); + List addresses = List.of( + createInetSocketAddress("10.0.0.1", 80), + createInetSocketAddress("8.8.8.8", 80) + ); + List result = selector.select(mockConfig, () -> addresses); + assertEquals(List.of(createInetSocketAddress("10.0.0.1", 80)), result); + } + + @Test + void selectWithBlockInternalShouldFilter() throws UnknownHostException { + // 10.0.0.1 is internal, 8.8.8.8 is external + SecurityDnsHandler handler = SecurityDnsHandler.builder().blockAllInternal(true).build(); + NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); + List addresses = List.of( + createInetSocketAddress("10.0.0.1", 80), + createInetSocketAddress("8.8.8.8", 80) + ); + List result = selector.select(mockConfig, () -> addresses); + assertEquals(List.of(createInetSocketAddress("8.8.8.8", 80)), result); + } + + @Test + void selectWithReportOnlyModeShouldNotFilterButLog() { + // For logging, we can't directly assert logs here without more complex setup. + // We'll trust SecurityDnsHandler's tests for logging and just check that reportOnly doesn't filter. + SecurityDnsHandler handler = SecurityDnsHandler.builder().denyList("8.8.8.8").reportOnly(true).build(); + NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); + List addresses = List.of( + createInetSocketAddress("1.1.1.1", 80), + createInetSocketAddress("8.8.8.8", 80) + ); + List result = selector.select(mockConfig, () -> addresses); + assertEquals(addresses, result); + } + + @Test + void selectWithDifferentPortsShouldUseFirstPort() { + // This test highlights the behavior of using the port from the first address. + SecurityDnsHandler handler = SecurityDnsHandler.builder().allowList("1.1.1.1").build(); + NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); + List addresses = List.of( + createInetSocketAddress("1.1.1.1", 80), + createInetSocketAddress("1.1.1.1", 443), // Same IP, different port + createInetSocketAddress("8.8.8.8", 80) + ); + // SecurityDnsHandler.handleInetSocketAddresses uses the port passed to it, + // which our selector derives from the first element. + List result = selector.select(mockConfig, () -> addresses); + assertEquals(List.of( + createInetSocketAddress("1.1.1.1", 80) + ), result); + } + + @Test + void selectWithNonInetSocketAddressShouldReturnOriginalList() { + SecurityDnsHandler handler = SecurityDnsHandler.builder().build(); + NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); + + // Create a mock SocketAddress that is not an InetSocketAddress + SocketAddress nonInetAddress = mock(SocketAddress.class); + + List addresses = List.of(nonInetAddress); + + List result = selector.select(mockConfig, () -> addresses); + + // Expect the original list to be returned as per the implementation logic + assertEquals(addresses, result); + } + + @Test + void selectWithMixedAddressTypesShouldFilterInetSocketAddressesOnly() { + SecurityDnsHandler handler = SecurityDnsHandler.builder().denyList("8.8.8.8").build(); + NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); + + SocketAddress nonInetAddress = mock(SocketAddress.class); + InetSocketAddress allowedAddress = createInetSocketAddress("1.1.1.1", 80); + InetSocketAddress deniedAddress = createInetSocketAddress("8.8.8.8", 80); + + // The current implementation will only return filtered InetSocketAddress types. + // Non-InetSocketAddress types are effectively dropped if any filtering occurs, + // or if the list is reconstructed. This test verifies that. + // If the intention was to keep non-InetSocketAddress types, the implementation would need adjustment. + + List addresses = List.of( + allowedAddress, + deniedAddress, + nonInetAddress + ); + + List result = selector.select(mockConfig, () -> addresses); + + // Based on current implementation, non-InetSocketAddress are filtered out + // when the list is reconstructed from filtered InetSocketAddress(es). + assertEquals(List.of(allowedAddress), result); + } +} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilterTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilterTest.java deleted file mode 100644 index f3dd99005db..00000000000 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/BasicSsrfProtectionFilterTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.google.springframework.security.web.client; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.util.Arrays; -import java.util.List; -import org.junit.jupiter.api.Test; - -public class BasicSsrfProtectionFilterTest { - - @Test - void testAllowInternalBlockExternalWithInternalAllowed() throws UnknownHostException, HostBlockedException { - BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_EXTERNAL); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), - InetAddress.getByName("10.0.0.1")}; - InetAddress[] filtered = filter.filterAddresses(addresses); - assertEquals(2, filtered.length); - assertTrue(Arrays.asList(filtered).containsAll(List.of(addresses))); - } - - @Test - void testAllowInternalBlockExternalWithExternalBlocked() throws UnknownHostException, HostBlockedException { - BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_EXTERNAL); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), - InetAddress.getByName("8.8.8.8")}; - InetAddress[] filtered = filter.filterAddresses(addresses); - assertEquals(1, filtered.length); - assertEquals(InetAddress.getByName("192.168.1.1"), filtered[0]); - } - - @Test - void testAllowInternalBlockExternalWithAllBlocked() throws UnknownHostException { - BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_EXTERNAL); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("8.8.8.8"), InetAddress.getByName("1.1.1.1")}; - assertThrows(HostBlockedException.class, () -> filter.filterAddresses(addresses)); - } - - @Test - void testBlockInternalAllowExternalWithInternalBlocked() throws UnknownHostException, HostBlockedException { - BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), - InetAddress.getByName("8.8.8.8")}; - InetAddress[] filtered = filter.filterAddresses(addresses); - assertEquals(1, filtered.length); - assertEquals(InetAddress.getByName("8.8.8.8"), filtered[0]); - } - - @Test - void testBlockInternalAllowExternalWithExternalAllowed() throws UnknownHostException, HostBlockedException { - BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("8.8.8.8"), InetAddress.getByName("1.1.1.1")}; - InetAddress[] filtered = filter.filterAddresses(addresses); - assertEquals(2, filtered.length); - assertTrue(Arrays.asList(filtered).containsAll(List.of(addresses))); - } - - @Test - void testBlockInternalAllowExternalWithAllBlocked() throws UnknownHostException { - BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), - InetAddress.getByName("10.0.0.1")}; - assertThrows(HostBlockedException.class, () -> filter.filterAddresses(addresses)); - } - - @Test - void testHostBlockedExceptionMessage() throws UnknownHostException { - BasicSsrfProtectionFilter filter = new BasicSsrfProtectionFilter(NetworkMode.BLOCK_INTERNAL); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), - InetAddress.getByName("10.0.0.1")}; - HostBlockedException exception = assertThrows(HostBlockedException.class, - () -> filter.filterAddresses(addresses)); - assertTrue(exception.getMessage().contains("192.168.1.1")); - assertTrue(exception.getMessage().contains("10.0.0.1")); - assertTrue(exception.getMessage().contains("BLOCK_INTERNAL")); - } - - -} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/FilterUtilsTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/FilterUtilsTest.java deleted file mode 100644 index dd6ba633ec8..00000000000 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/FilterUtilsTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.google.springframework.security.web.client; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.net.InetAddress; -import java.net.UnknownHostException; -import org.junit.jupiter.api.Test; - -class FilterUtilsTest { - - @Test - public void testIsInternalIpOnLoopback() throws UnknownHostException { - InetAddress addr = InetAddress.getByName("127.0.0.1"); - assertTrue(FilterUtils.isInternalIp(addr)); - } - - @Test - public void testIsInternalIpOnIpv4_10x() throws UnknownHostException { - InetAddress addr = InetAddress.getByName("10.1.2.3"); - assertTrue(FilterUtils.isInternalIp(addr)); - } - - @Test - public void testIsInternalIpOnIpv4_192x() throws UnknownHostException { - InetAddress addr = InetAddress.getByName("192.168.10.20"); - assertTrue(FilterUtils.isInternalIp(addr)); - } - - @Test - public void testIsInternalIpOnIpv4_172x() throws UnknownHostException { - InetAddress addr = InetAddress.getByName("172.16.0.1"); - assertTrue(FilterUtils.isInternalIp(addr)); - } - - @Test - public void testIsInternalIp_ipv6() throws UnknownHostException { - InetAddress addr = InetAddress.getByName("fd00::1"); - assertTrue(FilterUtils.isInternalIp(addr)); - } - - @Test - public void testIsInternalIpOnPublicIpv4() throws UnknownHostException { - InetAddress addr = InetAddress.getByName("8.8.8.8"); - assertFalse(FilterUtils.isInternalIp(addr)); - } - - @Test - public void testIsInternalIpOnPublicIpv6() throws UnknownHostException { - InetAddress addr = InetAddress.getByName("2001:4860:4860::8888"); - assertFalse(FilterUtils.isInternalIp(addr)); - } - - @Test - public void testIsInternalUniqueLocalAddresses() throws UnknownHostException { - InetAddress addr = InetAddress.getByName("fcaa::4860:4860:8888"); - assertTrue(FilterUtils.isInternalIp(addr)); - } - - @Test - public void testIsTranslatedLocalAddress() throws UnknownHostException { - InetAddress addr = InetAddress.getByName("0064:ff9b::127.0.0.1"); - assertTrue(FilterUtils.isInternalIp(addr)); - } - -} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/Hc5SsrfDnsResolverTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/Hc5SsrfDnsResolverTest.java deleted file mode 100644 index be48dbebbc7..00000000000 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/Hc5SsrfDnsResolverTest.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.google.springframework.security.web.client; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.when; - -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.util.ArrayList; -import java.util.List; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -public class Hc5SsrfDnsResolverTest { - - @Mock - private SsrfProtectionFilter ssrfProtectionFilter; - - static class TestableHcSsrfDnsResolver extends Hc5SsrfDnsResolver { - - InetAddress[] addressesToReturn = null; - - public TestableHcSsrfDnsResolver(List filterList) { - super(filterList, false); - } - - @Override - protected InetAddress[] resolveAll(String host) throws UnknownHostException { - return addressesToReturn; - } - - - public void setFilters(List filterList) { - filters.clear(); - filters.addAll(filterList); - } - - public void setReportOnly(boolean b) { - reportOnly = b; - } - } - - @InjectMocks - private TestableHcSsrfDnsResolver customDnsResolver = new TestableHcSsrfDnsResolver(new ArrayList<>()); - - @Test - void testResolveWithValidHost() throws UnknownHostException, HostBlockedException { - String host = "www.example.com"; - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("93.184.216.34")}; - when(ssrfProtectionFilter.filterAddresses(addresses)).thenReturn(addresses); - customDnsResolver.addressesToReturn = addresses; - customDnsResolver.setReportOnly(false); - customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); - - InetAddress[] resolvedAddresses = customDnsResolver.resolve(host); - - assertEquals(1, resolvedAddresses.length); - assertEquals(addresses[0], resolvedAddresses[0]); - } - - @Test - void testResolveBlockedHost() throws UnknownHostException, HostBlockedException { - String host = "192.168.1.1"; - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName(host)}; - when(ssrfProtectionFilter.filterAddresses(addresses)).thenThrow(new HostBlockedException("Blocked")); - customDnsResolver.addressesToReturn = addresses; - customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); - customDnsResolver.setReportOnly(false); - - UnknownHostException exception = assertThrows(UnknownHostException.class, - () -> customDnsResolver.resolve(host)); - assertTrue(exception.getMessage().contains("blocked")); - - } - - @Test - void testResolveBlockedHostInRerpotOnlyMode() throws UnknownHostException, HostBlockedException { - String host = "192.168.1.1"; - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName(host)}; - when(ssrfProtectionFilter.filterAddresses(addresses)).thenThrow(new HostBlockedException("Blocked")); - customDnsResolver.addressesToReturn = addresses; - customDnsResolver.setReportOnly(true); - customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); - - InetAddress[] resolvedAddresses = customDnsResolver.resolve(host); - - assertEquals(1, resolvedAddresses.length); - assertEquals(addresses[0], resolvedAddresses[0]); - - } - - - @Test - void testResolveCanonicalHostname() throws UnknownHostException { - String host = "localhost"; - String resolvedHostname = customDnsResolver.resolveCanonicalHostname(host); - assertEquals(host, resolvedHostname); - } -} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/HttpComponentsDnsResolverTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/HttpComponentsDnsResolverTest.java new file mode 100644 index 00000000000..8f594690bfa --- /dev/null +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/HttpComponentsDnsResolverTest.java @@ -0,0 +1,122 @@ +package com.google.springframework.security.web.client; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import client.dns.SecurityDnsHandler; +import client.HttpComponentsDnsResolver; +import org.apache.hc.client5.http.DnsResolver; +import org.junit.jupiter.api.Test; + +class HttpComponentsDnsResolverTests { + + @Test + void resolveDelegatesAndHandlesAddresses() throws UnknownHostException { + DnsResolver delegate = mock(DnsResolver.class); + SecurityDnsHandler securityDnsHandler = mock(SecurityDnsHandler.class); + HttpComponentsDnsResolver resolver = new HttpComponentsDnsResolver(delegate, securityDnsHandler); + + InetAddress address1 = InetAddress.getByName("192.168.1.1"); + InetAddress address2 = InetAddress.getByName("10.0.0.1"); + InetAddress[] resolvedAddresses = new InetAddress[]{address1, address2}; + when(delegate.resolve("example.com")).thenReturn(resolvedAddresses); + + InetAddress filteredAddress = InetAddress.getByName("127.0.0.1"); + List handledAddresses = Collections.singletonList(filteredAddress); + when(securityDnsHandler.handleAddresses(Arrays.asList(resolvedAddresses))).thenReturn(handledAddresses); + + InetAddress[] result = resolver.resolve("example.com"); + + assertEquals(1, result.length); + assertEquals(filteredAddress, result[0]); + verify(delegate, times(1)).resolve("example.com"); + verify(securityDnsHandler, times(1)).handleAddresses(Arrays.asList(resolvedAddresses)); + } + + @Test + void resolveUsesSystemDefaultDnsResolverAndHandlesAddresses() throws UnknownHostException { + SecurityDnsHandler securityDnsHandler = mock(SecurityDnsHandler.class); + HttpComponentsDnsResolver resolver = new HttpComponentsDnsResolver(securityDnsHandler); + + InetAddress resolvedAddress = InetAddress.getByName("8.8.8.8"); + // We can't directly mock SystemDefaultDnsResolver's resolve method, + // so we'll rely on its actual behavior for this test. + // For a more isolated test, we'd mock the delegate. + + InetAddress filteredAddress = InetAddress.getByName("1.1.1.1"); + List handledAddresses = Collections.singletonList(filteredAddress); + when(securityDnsHandler.handleAddresses(anyList())).thenReturn(handledAddresses); + + InetAddress[] result = resolver.resolve("google.com"); // Using a real domain for SystemDefaultDnsResolver + + assertEquals(1, result.length); + assertEquals(filteredAddress, result[0]); + // We can't easily verify the delegate call here without more complex mocking. + verify(securityDnsHandler, times(1)).handleAddresses(anyList()); + } + + @Test + void resolveCanonicalHostnameDelegates() throws UnknownHostException { + DnsResolver delegate = mock(DnsResolver.class); + SecurityDnsHandler securityDnsHandler = mock(SecurityDnsHandler.class); + HttpComponentsDnsResolver resolver = new HttpComponentsDnsResolver(delegate, securityDnsHandler); + + when(delegate.resolveCanonicalHostname("example.com")).thenReturn("canonical.example.com"); + + String result = resolver.resolveCanonicalHostname("example.com"); + + assertEquals("canonical.example.com", result); + verify(delegate, times(1)).resolveCanonicalHostname("example.com"); + verify(securityDnsHandler, times(0)).handleAddresses(anyList()); + } + + @Test + void resolveCanonicalHostnameUsesSystemDefaultDnsResolver() throws UnknownHostException { + SecurityDnsHandler securityDnsHandler = mock(SecurityDnsHandler.class); + HttpComponentsDnsResolver resolver = new HttpComponentsDnsResolver(securityDnsHandler); + + // We can't directly mock SystemDefaultDnsResolver's resolveCanonicalHostname, + // so we'll rely on its actual behavior. + + String result = resolver.resolveCanonicalHostname("localhost"); // Should resolve to "localhost" or similar + + assertEquals("localhost", result); // Assuming default behavior for localhost + verify(securityDnsHandler, times(0)).handleAddresses(anyList()); + } + + @Test + void resolveDelegatesUnknownHostException() throws UnknownHostException { + DnsResolver delegate = mock(DnsResolver.class); + SecurityDnsHandler securityDnsHandler = mock(SecurityDnsHandler.class); + HttpComponentsDnsResolver resolver = new HttpComponentsDnsResolver(delegate, securityDnsHandler); + + when(delegate.resolve("unknown.host")).thenThrow(new UnknownHostException("Host not found")); + + assertThrows(UnknownHostException.class, () -> resolver.resolve("unknown.host")); + verify(securityDnsHandler, times(0)).handleAddresses(anyList()); + } + + @Test + void resolveCanonicalHostnameDelegatesUnknownHostException() throws UnknownHostException { + DnsResolver delegate = mock(DnsResolver.class); + SecurityDnsHandler securityDnsHandler = mock(SecurityDnsHandler.class); + HttpComponentsDnsResolver resolver = new HttpComponentsDnsResolver(delegate, securityDnsHandler); + + when(delegate.resolveCanonicalHostname("unknown.host")).thenThrow(new UnknownHostException("Host not found")); + + assertThrows(UnknownHostException.class, () -> resolver.resolveCanonicalHostname("unknown.host")); + verify(securityDnsHandler, times(0)).handleAddresses(anyList()); + } +} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/IpOrRangeTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/IpOrRangeTest.java deleted file mode 100644 index 43947f01fc8..00000000000 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/IpOrRangeTest.java +++ /dev/null @@ -1,316 +0,0 @@ -package com.google.springframework.security.web.client; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.net.InetAddress; -import java.net.UnknownHostException; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.mockito.Mockito; - -public class IpOrRangeTest { - - // Helper method to create mocked InetAddress instances - private InetAddress mockInetAddress(String hostname, String ipAddress) throws UnknownHostException { - InetAddress mocked = Mockito.mock(InetAddress.class); - Mockito.when(mocked.getHostName()).thenReturn(hostname); - Mockito.when(mocked.getHostAddress()).thenReturn(ipAddress); - // Resolve the byte array first. If InetAddress.getByName is statically mocked, - // this call will be intercepted by that static mock. - byte[] addressBytes = InetAddress.getByName(ipAddress).getAddress(); - Mockito.when(mocked.getAddress()).thenReturn(addressBytes); - return mocked; - } - - @Test - public void testSingleIpMatch() throws UnknownHostException { - IpOrRange ipOrRange = new IpOrRange("192.168.1.1"); - InetAddress address = InetAddress.getByName("192.168.1.1"); - assertTrue(ipOrRange.matches(address.getHostAddress(), address)); - } - - @Test - public void testSingleIpMismatch() throws UnknownHostException { - IpOrRange ipOrRange = new IpOrRange("192.168.1.1"); - InetAddress address = InetAddress.getByName("192.168.1.2"); - assertFalse(ipOrRange.matches(address.getHostAddress(), address)); - } - - @Test - public void testCidrMatch() throws UnknownHostException { - IpOrRange ipOrRange = new IpOrRange("192.168.1.0/24"); - InetAddress address1 = InetAddress.getByName("192.168.1.1"); - InetAddress address2 = InetAddress.getByName("192.168.1.100"); - assertTrue(ipOrRange.matches(address1.getHostAddress(), address1)); - assertTrue(ipOrRange.matches(address2.getHostAddress(), address2)); - } - - @Test - public void testCidrMismatch() throws UnknownHostException { - IpOrRange ipOrRange = new IpOrRange("192.168.1.0/24"); - InetAddress address = InetAddress.getByName("192.168.2.1"); - assertFalse(ipOrRange.matches(address.getHostAddress(), address)); - } - - @Test - public void testCidrMatch_subnet() throws UnknownHostException { - IpOrRange ipOrRange = new IpOrRange("10.0.0.0/8"); - InetAddress address = InetAddress.getByName("10.10.10.10"); - assertTrue(ipOrRange.matches(address.getHostAddress(), address)); - } - - @Test - public void testCidrMismatch_subnet() throws UnknownHostException { - IpOrRange ipOrRange = new IpOrRange("10.0.0.0/16"); - InetAddress address = InetAddress.getByName("10.1.10.10"); - assertFalse(ipOrRange.matches(address.getHostAddress(), address)); - } - - @Test - public void testInvalidAddress() { - Exception ex = assertThrows(IllegalArgumentException.class, () -> - new IpOrRange("invalid address"), "Exception not triggered"); - - assertTrue(ex.getMessage().contains("Failed to parse address")); - } - - @Test - public void testHostname() throws UnknownHostException { - // Assuming "localhost" resolves to 127.0.0.1 - IpOrRange ipOrRange = new IpOrRange("localhost"); - InetAddress address = InetAddress.getByName("127.0.0.1"); - assertTrue(ipOrRange.matches(address.getHostAddress(), address)); - } - - @Test - public void testIpv6SingleIpMatch() throws UnknownHostException { - IpOrRange ipOrRange = new IpOrRange("2001:db8::1"); - InetAddress address = InetAddress.getByName("2001:db8::1"); - assertTrue(ipOrRange.matches(address.getHostAddress(), address)); - } - - @Test - public void testIpv6CidrMatch() throws UnknownHostException { - IpOrRange ipOrRange = new IpOrRange("2001:db8::/32"); - InetAddress address = InetAddress.getByName("2001:db8:1::1"); - assertTrue(ipOrRange.matches(address.getHostAddress(), address)); - } - - @Test - public void testHostnameMatch_AllowlistHostname_ToCheckHostname_ExactMatch() throws UnknownHostException { - try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { - // Prepare the static mock for the IP string "1.2.3.4" that mockInetAddress will use internally. - // This mock (ip1234_forBytes) is solely for providing the byte array for "1.2.3.4". - InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); - Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); - mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); - - // Now, mockInetAddress can be called. It will use the above stubbing for InetAddress.getByName("1.2.3.4"). - InetAddress exampleComIp = mockInetAddress("example.com", "1.2.3.4"); - - // This stubbing is for the IpOrRange constructor when it resolves "example.com". - mockedStaticInetAddress.when(() -> InetAddress.getByName("example.com")).thenReturn(exampleComIp); - - IpOrRange ipOrRange = new IpOrRange("example.com"); // Stores "example.com" as hostname - assertTrue(ipOrRange.matches("example.com", exampleComIp)); - } - } - - @Test - public void testHostnameMatch_AllowlistHostname_ToCheckHostname_CaseInsensitiveMatch() throws UnknownHostException { - try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { - // Prepare the static mock for the IP string "1.2.3.4" that mockInetAddress will use internally. - InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); - Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); - mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); - - // Now, mockInetAddress can be called. - InetAddress exampleComIp = mockInetAddress("EXAMPLE.COM", "1.2.3.4"); // rDNS might return uppercase - mockedStaticInetAddress.when(() -> InetAddress.getByName("example.com")).thenReturn(exampleComIp); - - IpOrRange ipOrRange = new IpOrRange("example.com"); - - assertTrue(ipOrRange.matches("EXAMPLE.COM", exampleComIp)); - } - } - - @Test - public void testHostnameMatch_AllowlistHostname_ToCheckHostname_SubdomainNoMatch() throws UnknownHostException { - try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { - // Prepare static mock for "1.2.3.5" (used by the first mockInetAddress call) - InetAddress ip1235_forBytes = Mockito.mock(InetAddress.class); - Mockito.when(ip1235_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 5}); - mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.5")).thenReturn(ip1235_forBytes); - InetAddress subExampleComIp = mockInetAddress("sub.example.com", "1.2.3.5"); - - // Prepare static mock for "1.2.3.4" (used by the second mockInetAddress call) - InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); - Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); - mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); - // Allowlist entry is for "example.com" - InetAddress exampleComIp = mockInetAddress("example.com", "1.2.3.4"); - - mockedStaticInetAddress.when(() -> InetAddress.getByName("example.com")).thenReturn(exampleComIp); - mockedStaticInetAddress.when(() -> InetAddress.getByName("sub.example.com")).thenReturn(subExampleComIp); - - IpOrRange ipOrRange = new IpOrRange("example.com"); - - assertFalse(ipOrRange.matches("sub.example.com", subExampleComIp)); - } - } - - @Test - public void testHostnameMatch_AllowlistHostname_ToCheckIsIPOfHostname_ShouldMatchViaIPFallback() throws UnknownHostException { - try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { - // Prepare static mock for "1.2.3.4" that mockInetAddress will use internally - // to get the byte array. - InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); - Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); - mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); - - InetAddress exampleComIp = mockInetAddress("example.com", "1.2.3.4"); - // Now exampleComIp is fully mocked, including its getAddress() method. - - mockedStaticInetAddress.when(() -> InetAddress.getByName("example.com")).thenReturn(exampleComIp); - IpOrRange ipOrRange = new IpOrRange("example.com"); // hostname = "example.com", address = 1.2.3.4 - - // toCheckAddressString is an IP, toCheckInetAddress is the InetAddress for that IP - assertTrue(ipOrRange.matches("1.2.3.4", exampleComIp)); - } - } - - @Test - public void testHostnameMatch_AllowlistHostname_ToCheckIsDifferentIP_ShouldNotMatch() throws UnknownHostException { - try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { - // Prepare static mock for "1.2.3.4" (used by the first mockInetAddress call) - InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); - Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); - mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); - InetAddress exampleComIp = mockInetAddress("example.com", "1.2.3.4"); - - // Prepare static mock for "1.2.3.5" (used by the second mockInetAddress call) - InetAddress ip1235_forBytes = Mockito.mock(InetAddress.class); - Mockito.when(ip1235_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 5}); - mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.5")).thenReturn(ip1235_forBytes); - InetAddress differentIp = mockInetAddress("other.com", "1.2.3.5"); // or just an IP - - mockedStaticInetAddress.when(() -> InetAddress.getByName("example.com")).thenReturn(exampleComIp); - - IpOrRange ipOrRange = new IpOrRange("example.com"); - - assertFalse(ipOrRange.matches("1.2.3.5", differentIp)); - } - } - - @Test - public void testHostnameMatch_AllowlistIP_ToCheckIsHostnameResolvingToIP_ShouldMatchViaIP() throws UnknownHostException { - try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { - // Prepare static mock for "1.2.3.4" that mockInetAddress will use internally - // to get the byte array. - InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); - Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); - mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); - - InetAddress targetIp = mockInetAddress("example.com", "1.2.3.4"); // The IP we are interested in - // Mock resolution for "example.com" - mockedStaticInetAddress.when(() -> InetAddress.getByName("example.com")).thenReturn(targetIp); - // Mock resolution for "1.2.3.4" (used by IpOrRange constructor) - mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(targetIp); - - IpOrRange ipOrRange = new IpOrRange("1.2.3.4"); // hostname = null, address = 1.2.3.4 - - // toCheckAddressString is "example.com", toCheckInetAddress is the InetAddress for "example.com" - assertTrue(ipOrRange.matches("example.com", targetIp)); - } - } - - @Test - public void testHostnameMatch_AllowlistIP_ToCheckIsHostnameResolvingToDifferentIP_ShouldNotMatch() throws UnknownHostException { - try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { - // Prepare static mock for "1.2.3.4" (used by the first mockInetAddress call) - InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); - Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); - mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); - InetAddress allowlistIp = mockInetAddress("ip.only", "1.2.3.4"); - - // Prepare static mock for "5.6.7.8" (used by the second mockInetAddress call) - InetAddress ip5678_forBytes = Mockito.mock(InetAddress.class); - Mockito.when(ip5678_forBytes.getAddress()).thenReturn(new byte[]{5, 6, 7, 8}); - mockedStaticInetAddress.when(() -> InetAddress.getByName("5.6.7.8")).thenReturn(ip5678_forBytes); - InetAddress otherHostnameIp = mockInetAddress("other.com", "5.6.7.8"); - - - mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(allowlistIp); - mockedStaticInetAddress.when(() -> InetAddress.getByName("other.com")).thenReturn(otherHostnameIp); - - IpOrRange ipOrRange = new IpOrRange("1.2.3.4"); // hostname = null, address = 1.2.3.4 - - assertFalse(ipOrRange.matches("other.com", otherHostnameIp)); - } - } - - // It might be good to update the existing testHostname to reflect new signature and intent - @Test - public void testHostname_ResolvesToIp_MatchesViaIpFallback() throws UnknownHostException { - // This test now checks that an allowlist entry "localhost" (which stores "localhost" as hostname) - // correctly matches the IP "127.0.0.1" via IP fallback logic. - try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { - // 1. Prepare the static mock for the IP string "127.0.0.1" that mockInetAddress will use internally. - // This is needed because mockInetAddress calls InetAddress.getByName("127.0.0.1").getAddress(). - InetAddress ip127_forBytes = Mockito.mock(InetAddress.class); - Mockito.when(ip127_forBytes.getAddress()).thenReturn(new byte[]{127, 0, 0, 1}); - mockedStaticInetAddress.when(() -> InetAddress.getByName("127.0.0.1")).thenReturn(ip127_forBytes); - - // 2. Create the InetAddress object that represents "localhost" resolving to "127.0.0.1". - // This will be used both for the IpOrRange constructor (via stubbing) and for the matches() call. - InetAddress localhostIp = mockInetAddress("localhost", "127.0.0.1"); - - // 3. Mock what InetAddress.getByName("localhost") returns for the IpOrRange constructor. - mockedStaticInetAddress.when(() -> InetAddress.getByName("localhost")).thenReturn(localhostIp); - - IpOrRange ipOrRange = new IpOrRange("localhost"); // Constructor uses the stub above. - - // 4. Assert that matching "127.0.0.1" (IP string) against the `localhostIp` object works. - assertTrue(ipOrRange.matches("127.0.0.1", localhostIp)); - } - } - - @Test - public void testHostnameMatch_AllowlistDomain_ToCheckWwwDomain_ShouldMatch() throws UnknownHostException { - try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { - // Prepare static mock for "1.2.3.4" (used by mockInetAddress for "www.example.com") - InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); - Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); - mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); - InetAddress wwwExampleComIp = mockInetAddress("www.example.com", "1.2.3.4"); - - // Prepare static mock for "example.com" (used by IpOrRange constructor and potentially mockInetAddress) - // If mockInetAddress was called for "example.com", it would also use ip1234_forBytes via "1.2.3.4" - InetAddress exampleComIpForConstructor = mockInetAddress("example.com", "1.2.3.4"); - mockedStaticInetAddress.when(() -> InetAddress.getByName("example.com")).thenReturn(exampleComIpForConstructor); - - IpOrRange ipOrRange = new IpOrRange("example.com"); // Stored hostname: "example.com" - assertTrue(ipOrRange.matches("www.example.com", wwwExampleComIp)); // Check: "www.example.com" - } - } - - @Test - public void testHostnameMatch_AllowlistWwwDomain_ToCheckDomain_ShouldMatch() throws UnknownHostException { - try (MockedStatic mockedStaticInetAddress = Mockito.mockStatic(InetAddress.class)) { - // Prepare static mock for "1.2.3.4" (used by mockInetAddress for "example.com") - InetAddress ip1234_forBytes = Mockito.mock(InetAddress.class); - Mockito.when(ip1234_forBytes.getAddress()).thenReturn(new byte[]{1, 2, 3, 4}); - mockedStaticInetAddress.when(() -> InetAddress.getByName("1.2.3.4")).thenReturn(ip1234_forBytes); - InetAddress exampleComIp = mockInetAddress("example.com", "1.2.3.4"); - - // Prepare static mock for "www.example.com" (used by IpOrRange constructor and potentially mockInetAddress) - InetAddress wwwExampleComIpForConstructor = mockInetAddress("www.example.com", "1.2.3.4"); - mockedStaticInetAddress.when(() -> InetAddress.getByName("www.example.com")).thenReturn(wwwExampleComIpForConstructor); - - IpOrRange ipOrRange = new IpOrRange("www.example.com"); // Stored hostname: "www.example.com" - assertTrue(ipOrRange.matches("example.com", exampleComIp)); // Check: "example.com" - } - } -} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/JettyHttpClientDnsResolverTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/JettyHttpClientDnsResolverTest.java new file mode 100644 index 00000000000..cb61fb771d4 --- /dev/null +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/JettyHttpClientDnsResolverTest.java @@ -0,0 +1,120 @@ +package com.google.springframework.security.web.client; + +import client.dns.SecurityDnsHandler; +import client.JettyHttpClientDnsResolver; +import org.eclipse.jetty.util.Promise; +import org.eclipse.jetty.util.SocketAddressResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.List; +import java.util.ArrayList; +import java.util.Arrays; + +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +class JettyHttpClientDnsResolverTest { + + @Mock + private SocketAddressResolver mockDelegateResolver; + + @Mock + private SecurityDnsHandler mockSecurityDnsHandler; + + @Mock + private Promise> mockResultPromise; + + @Captor + private ArgumentCaptor>> delegatePromiseCaptor; + + @Captor + private ArgumentCaptor> resultListCaptor; + + private JettyHttpClientDnsResolver jettyHttpClientDnsResolver; + + private final String HOST = "example.com"; + private final int PORT = 80; + + private List initialAddresses; + private List filteredAddresses; + + @BeforeEach + void setUp() throws UnknownHostException { + // Initialize the class under test with mocked dependencies + jettyHttpClientDnsResolver = new JettyHttpClientDnsResolver(mockDelegateResolver, mockSecurityDnsHandler); + + // Prepare sample data + InetSocketAddress address1 = new InetSocketAddress(InetAddress.getByAddress(HOST, new byte[]{1, 2, 3, 4}), PORT); + InetSocketAddress address2 = new InetSocketAddress(InetAddress.getByAddress(HOST, new byte[]{1, 2, 3, 5}), PORT); + InetSocketAddress address3 = new InetSocketAddress(InetAddress.getByAddress(HOST, new byte[]{1, 2, 3, 6}), PORT); + + initialAddresses = new ArrayList<>(Arrays.asList(address1, address2, address3)); + // SecurityDnsHandler will remove address2 + filteredAddresses = new ArrayList<>(Arrays.asList(address1, address3)); + } + + @Test + void resolve_whenDelegateSucceeds_shouldApplySecurityHandlerAndReturnFilteredAddresses() { + // 1. Configure mock SecurityDnsHandler + // When handleInetSocketAddresses is called with the initial list, return the filtered list + when(mockSecurityDnsHandler.handleInetSocketAddresses(initialAddresses, PORT)) + .thenReturn(filteredAddresses); + + // 2. Call the method under test + jettyHttpClientDnsResolver.resolve(HOST, PORT, mockResultPromise); + + // 3. Verify that the delegate resolver's resolve method was called + // and capture the Promise passed to it. + verify(mockDelegateResolver).resolve(eq(HOST), eq(PORT), delegatePromiseCaptor.capture()); + // 4. Simulate the delegate resolver succeeding + // Get the captured promise and call its 'succeeded' method with the initial addresses. + Promise> capturedPromiseForDelegate = delegatePromiseCaptor.getValue(); + assertNotNull(capturedPromiseForDelegate, "Promise passed to delegate resolver should not be null"); + capturedPromiseForDelegate.succeeded(initialAddresses); + // 5. Verify that SecurityDnsHandler.handleInetSocketAddresses was called + verify(mockSecurityDnsHandler).handleInetSocketAddresses(initialAddresses, PORT); + // 6. Verify that the original mockResultPromise.succeeded was called with the filtered list + verify(mockResultPromise).succeeded(resultListCaptor.capture()); + assertEquals(filteredAddresses, resultListCaptor.getValue(), "The final list of addresses should be the one filtered by the security handler."); + assertTrue(resultListCaptor.getValue().containsAll(filteredAddresses) && filteredAddresses.containsAll(resultListCaptor.getValue()), + "Resulting list should exactly match the filtered list."); + assertFalse(resultListCaptor.getValue().stream().anyMatch(addr -> addr.getAddress().getHostAddress().equals("1.2.3.5")), + "The IP address '1.2.3.5' should have been filtered out."); + // Ensure no failures were propagated + verify(mockResultPromise, never()).failed(any(Throwable.class)); + } + + @Test + void resolve_whenDelegateFails_shouldPropagateFailureAndNotCallSecurityHandler() { + // 1. Prepare a sample exception + Throwable expectedException = new RuntimeException("DNS resolution failed by delegate"); + + // 2. Call the method under test + jettyHttpClientDnsResolver.resolve(HOST, PORT, mockResultPromise); + + // 3. Verify that the delegate resolver's resolve method was called + // and capture the Promise passed to it. + verify(mockDelegateResolver).resolve(eq(HOST), eq(PORT), delegatePromiseCaptor.capture()); + // 4. Simulate the delegate resolver failing + // Get the captured promise and call its 'failed' method with the exception. + Promise> capturedPromiseForDelegate = delegatePromiseCaptor.getValue(); + assertNotNull(capturedPromiseForDelegate, "Promise passed to delegate resolver should not be null"); + capturedPromiseForDelegate.failed(expectedException); + // 5. Verify that SecurityDnsHandler.handleInetSocketAddresses was NOT called + verify(mockSecurityDnsHandler, never()).handleInetSocketAddresses(anyList(), anyInt()); + // 6. Verify that the original mockResultPromise.failed was called with the same exception + verify(mockResultPromise).failed(expectedException); + // Ensure succeeded was not called + verify(mockResultPromise, never()).succeeded(anyList()); + } +} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/JettySsrfDnsResolverTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/JettySsrfDnsResolverTest.java deleted file mode 100644 index f71c6ea9508..00000000000 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/JettySsrfDnsResolverTest.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.google.springframework.security.web.client; - - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.when; - -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.UnknownHostException; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.ExecutionException; -import org.eclipse.jetty.util.Promise; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -public class JettySsrfDnsResolverTest { - - @Mock - private SsrfProtectionFilter ssrfProtectionFilter; - - static class TestableJettySsrfDnsResolver extends JettySsrfDnsResolver { - - InetAddress[] addressesToReturn = null; - - public TestableJettySsrfDnsResolver(List filters) { - super(filters, false); - } - - @Override - protected InetAddress[] resolveAll(String host) throws UnknownHostException { - return addressesToReturn; - } - - public void setFilters(List filterList) { - filters.clear(); - filters.addAll(filterList); - } - - public void setReportOnly(boolean b) { - reportOnly = b; - } - } - - @InjectMocks - private TestableJettySsrfDnsResolver customDnsResolver = new TestableJettySsrfDnsResolver(new ArrayList<>()); - - @Test - void testResolveWithValidHost() - throws UnknownHostException, HostBlockedException, ExecutionException, InterruptedException { - String host = "www.example.com"; - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("93.184.216.34")}; - when(ssrfProtectionFilter.filterAddresses(addresses)).thenReturn(addresses); - customDnsResolver.addressesToReturn = addresses; - customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); - - Promise.Completable> promise = new Promise.Completable<>(); - - customDnsResolver.resolve(host, 80, promise); - - assertTrue(promise.isDone()); - List resolvedAddresses = promise.get(); - assertEquals(1, resolvedAddresses.size()); - assertEquals(addresses[0], resolvedAddresses.get(0).getAddress()); - assertEquals(80, resolvedAddresses.get(0).getPort()); - } - - @Test - void testResolveBlockedHostInRerpotOnlyMode() - throws UnknownHostException, HostBlockedException, ExecutionException, InterruptedException { - String host = "192.168.1.1"; - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName(host)}; - when(ssrfProtectionFilter.filterAddresses(addresses)).thenThrow(new HostBlockedException("Blocked")); - customDnsResolver.addressesToReturn = addresses; - customDnsResolver.setReportOnly(true); - customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); - - Promise.Completable> promise = new Promise.Completable<>(); - - customDnsResolver.resolve(host, 443, promise); - - assertTrue(promise.isDone()); - List resolvedAddresses = promise.get(); - - assertEquals(1, resolvedAddresses.size()); - assertEquals(addresses[0], resolvedAddresses.get(0).getAddress()); - assertEquals(443, resolvedAddresses.get(0).getPort()); - } - - @Test - void testResolveBlockedHost() - throws UnknownHostException, HostBlockedException, ExecutionException, InterruptedException { - String host = "192.168.1.1"; - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName(host)}; - when(ssrfProtectionFilter.filterAddresses(addresses)).thenThrow(new HostBlockedException("Blocked")); - customDnsResolver.addressesToReturn = addresses; - customDnsResolver.setFilters(List.of(ssrfProtectionFilter)); - customDnsResolver.setReportOnly(false); - - Promise.Completable> promise = new Promise.Completable<>(); - - customDnsResolver.resolve(host, 443, promise); - promise.whenComplete((res, exc) -> { - assertTrue(exc.getMessage().contains("was blocked")); - }); - assertTrue(promise.isCompletedExceptionally()); - } -} - - diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java deleted file mode 100644 index 817d6ff9008..00000000000 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/ListedSsrfProtectionFilterTest.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.google.springframework.security.web.client; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.util.Arrays; -import java.util.List; -import org.junit.jupiter.api.Test; - -public class ListedSsrfProtectionFilterTest { - - @Test - void testBlockListWithBlockedAddress() throws UnknownHostException, HostBlockedException { - List blockList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); - ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, - ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), - InetAddress.getByName("8.8.8.8")}; - InetAddress[] filtered = filter.filterAddresses(addresses); - assertEquals(1, filtered.length); - assertEquals(InetAddress.getByName("8.8.8.8"), filtered[0]); - } - - @Test - void testBlockListWithAllowedAddress() throws UnknownHostException, HostBlockedException { - List blockList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); - ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, - ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.2"), - InetAddress.getByName("8.8.8.8")}; - InetAddress[] filtered = filter.filterAddresses(addresses); - assertEquals(2, filtered.length); - assertTrue(Arrays.asList(filtered).containsAll(List.of(addresses))); - } - - @Test - void testBlockListWithAllBlocked() throws UnknownHostException { - List blockList = List.of(new IpOrRange("192.168.1.0/24"), new IpOrRange("8.8.8.8")); - ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, - ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), - InetAddress.getByName("8.8.8.8")}; - assertThrows(HostBlockedException.class, () -> filter.filterAddresses(addresses)); - } - - @Test - void testAllowListWithAllowedAddress() throws UnknownHostException, HostBlockedException { - List allowList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); - ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(allowList, - ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), - InetAddress.getByName("8.8.8.8")}; - InetAddress[] filtered = filter.filterAddresses(addresses); - assertEquals(1, filtered.length); - assertEquals(InetAddress.getByName("192.168.1.1"), filtered[0]); - } - - @Test - void testAllowListWithBlockedAddress() throws UnknownHostException, HostBlockedException { - List allowList = List.of(new IpOrRange("192.168.1.1"), new IpOrRange("10.0.0.0/24")); - ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(allowList, - ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.200"), - InetAddress.getByName("8.8.8.8")}; - HostBlockedException ex = assertThrows(HostBlockedException.class, () -> filter.filterAddresses(addresses), - "This should throw an exception"); - assertTrue(ex.getMessage().contains("blocked due to violating ALLOW_LIST")); - - } - - @Test - void testAllowListWithAllBlocked() throws UnknownHostException { - List allowList = List.of(new IpOrRange("172.16.0.0/16")); - ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(allowList, - ListedSsrfProtectionFilter.FilterMode.ALLOW_LIST); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1"), - InetAddress.getByName("8.8.8.8")}; - assertThrows(HostBlockedException.class, () -> filter.filterAddresses(addresses)); - } - - @Test - void testHostBlockedExceptionMessage() throws UnknownHostException { - List blockList = List.of(new IpOrRange("192.168.1.0/24")); - ListedSsrfProtectionFilter filter = new ListedSsrfProtectionFilter(blockList, - ListedSsrfProtectionFilter.FilterMode.BLOCK_LIST); - InetAddress[] addresses = new InetAddress[]{InetAddress.getByName("192.168.1.1")}; - HostBlockedException exception = assertThrows(HostBlockedException.class, - () -> filter.filterAddresses(addresses)); - assertTrue(exception.getMessage().contains("192.168.1.1")); - assertTrue(exception.getMessage().contains("BLOCK_LIST")); - } - -} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/SecurityDnsHandlerTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/SecurityDnsHandlerTest.java new file mode 100644 index 00000000000..40623abff22 --- /dev/null +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/SecurityDnsHandlerTest.java @@ -0,0 +1,229 @@ +package com.google.springframework.security.web.client; + +import client.dns.InetAddressFilter; +import client.dns.SecurityDnsHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class SecurityDnsHandlerTest { + + private InetAddress INTERNAL_IP_1; + private InetAddress INTERNAL_IP_2; + private InetAddress EXTERNAL_IP_1; + private InetAddress EXTERNAL_IP_2; + private InetAddress LOCALHOST_IP; + + @BeforeEach + void setUp() throws UnknownHostException { + INTERNAL_IP_1 = InetAddress.getByName("192.168.1.1"); // site-local + INTERNAL_IP_2 = InetAddress.getByName("10.0.0.1"); // site-local + EXTERNAL_IP_1 = InetAddress.getByName("8.8.8.8"); // public + EXTERNAL_IP_2 = InetAddress.getByName("1.1.1.1"); // public + LOCALHOST_IP = InetAddress.getByName("127.0.0.1"); // loopback + } + + // --- Builder Tests --- + + @Test + void testBuilder_defaults() { + SecurityDnsHandler handler = SecurityDnsHandler.builder().build(); + assertFalse(handler.getReportMode(), "Default reportOnly mode should be false"); + + // Assuming default DefaultInetAddressFilter allows all if no rules are set + List addresses = Arrays.asList(INTERNAL_IP_1, EXTERNAL_IP_1); + List result = handler.handleAddresses(addresses); + assertEquals(addresses, result, "With default builder, all addresses should be allowed"); + } + + @Test + void testBuilder_reportOnlyTrue() { + SecurityDnsHandler handler = SecurityDnsHandler.builder().reportOnly(true).build(); + assertTrue(handler.getReportMode()); + } + + @Test + void testBuilder_reportOnlyFalse() { + SecurityDnsHandler handler = SecurityDnsHandler.builder().reportOnly(false).build(); + assertFalse(handler.getReportMode()); + } + + @Test + void testBuilder_blockAllExternal_blocksExternalOnly() { + SecurityDnsHandler handler = SecurityDnsHandler.builder().blockAllExternal(true).build(); + List candidates = Arrays.asList(INTERNAL_IP_1, EXTERNAL_IP_1, LOCALHOST_IP, EXTERNAL_IP_2); + + List result = handler.handleAddresses(candidates); + + assertTrue(result.contains(INTERNAL_IP_1)); + assertTrue(result.contains(LOCALHOST_IP)); // Loopback is not external + assertFalse(result.contains(EXTERNAL_IP_1)); + assertFalse(result.contains(EXTERNAL_IP_2)); + assertEquals(2, result.size(), "Only internal and loopback IPs should pass. Result: " + resultToString(result)); + } + + @Test + void testBuilder_blockAllInternal_blocksInternalAndLoopback() { + // Assuming DefaultInetAddressFilter treats loopback as internal for blocking purposes + SecurityDnsHandler handler = SecurityDnsHandler.builder().blockAllInternal(true).build(); + List candidates = Arrays.asList(INTERNAL_IP_1, EXTERNAL_IP_1, LOCALHOST_IP, INTERNAL_IP_2); + + List result = handler.handleAddresses(candidates); + + assertTrue(result.contains(EXTERNAL_IP_1)); + assertFalse(result.contains(INTERNAL_IP_1)); + assertFalse(result.contains(INTERNAL_IP_2)); + assertFalse(result.contains(LOCALHOST_IP)); // Loopback should be blocked + assertEquals(1, result.size(), "Only external IPs should pass. Result: " + resultToString(result)); + } + + @Test + void testBuilder_customFilter_blocksSpecificAddress() { + InetAddressFilter customBlocker = address -> address.equals(EXTERNAL_IP_1); + SecurityDnsHandler handler = SecurityDnsHandler.builder().customFilter(customBlocker).build(); + List candidates = Arrays.asList(INTERNAL_IP_1, EXTERNAL_IP_1, EXTERNAL_IP_2); + + List result = handler.handleAddresses(candidates); + + assertTrue(result.contains(INTERNAL_IP_1)); + assertTrue(result.contains(EXTERNAL_IP_2)); + assertFalse(result.contains(EXTERNAL_IP_1)); + assertEquals(2, result.size()); + } + + @Test + void handleAddresses_someAddressesBlocked_byFilter() { + InetAddressFilter blockExternal1Filter = address -> address.equals(EXTERNAL_IP_1); + SecurityDnsHandler handler = new SecurityDnsHandler(blockExternal1Filter, false); + List candidates = Arrays.asList(INTERNAL_IP_1, EXTERNAL_IP_1, EXTERNAL_IP_2); + + List result = handler.handleAddresses(candidates); + + assertTrue(result.contains(INTERNAL_IP_1)); + assertTrue(result.contains(EXTERNAL_IP_2)); + assertFalse(result.contains(EXTERNAL_IP_1)); + assertEquals(2, result.size()); + } + + @Test + void handleAddresses_allAddressesBlocked_reportOnlyFalse_returnsEmpty() { + SecurityDnsHandler handler = SecurityDnsHandler.builder().blockAllInternal(true).reportOnly(false).build(); + List candidates = Arrays.asList(INTERNAL_IP_1, INTERNAL_IP_2); + + List result = handler.handleAddresses(candidates); + + assertTrue(result.isEmpty(), "Should return empty list when all are blocked and not in reportOnly mode"); + } + + @Test + void handleAddresses_allAddressesBlocked_reportOnlyTrue_returnsAllCandidates() { + SecurityDnsHandler handler = SecurityDnsHandler.builder().blockAllInternal(true).reportOnly(true).build(); + List candidates = Arrays.asList(INTERNAL_IP_1, EXTERNAL_IP_1); + + List result = handler.handleAddresses(candidates); + + assertEquals(candidates, result, "Should return all candidates when all are blocked and in reportOnly mode"); + } + + @Test + void handleAddresses_emptyCandidateList_returnsEmpty() { + SecurityDnsHandler handler = SecurityDnsHandler.builder().blockAllInternal(true).reportOnly(true).build(); + List candidates = Collections.emptyList(); + + List result = handler.handleAddresses(candidates); + + assertTrue(result.isEmpty()); + } + + @Test + void handleInetSocketAddresses_filtersBasedOnAddress_rewritesPort() { + InetAddressFilter blockExternal1Filter = address -> address.equals(EXTERNAL_IP_1); // This should only filter EXTERNAL_IP_1 + SecurityDnsHandler handler = new SecurityDnsHandler(blockExternal1Filter, false); + + int targetPort = 8080; + List candidates = Arrays.asList( + new InetSocketAddress(INTERNAL_IP_1, 9000), // original port 9000 + new InetSocketAddress(EXTERNAL_IP_1, targetPort), // original port 8080 (blocked) + new InetSocketAddress(EXTERNAL_IP_2, 9001) // original port 9001 + ); + + List result = handler.handleInetSocketAddresses(candidates, targetPort); + + assertEquals(2, result.size()); + assertTrue(result.stream().anyMatch(sa -> sa.getAddress().equals(INTERNAL_IP_1) && sa.getPort() == targetPort), + "INTERNAL_IP_1 should be present with targetPort"); + assertTrue(result.stream().anyMatch(sa -> sa.getAddress().equals(EXTERNAL_IP_2) && sa.getPort() == targetPort), + "EXTERNAL_IP_2 should be present with targetPort"); + assertFalse(result.stream().anyMatch(sa -> sa.getAddress().equals(EXTERNAL_IP_1)), + "EXTERNAL_IP_1 should be filtered out"); + } + + @Test + void handleInetSocketAddresses_allAddressesBlocked_reportOnlyFalse_returnsEmpty() { + InetAddressFilter blockAllFilter = address -> true; + SecurityDnsHandler handler = new SecurityDnsHandler(blockAllFilter, false); + int port = 443; + List candidates = Arrays.asList( + new InetSocketAddress(INTERNAL_IP_1, port), + new InetSocketAddress(EXTERNAL_IP_1, port) + ); + List result = handler.handleInetSocketAddresses(candidates, port); + + assertTrue(result.isEmpty()); + } + + @Test + void handleInetSocketAddresses_allAddressesBlocked_reportOnlyTrue_returnsAllCandidatesWithNewPort() { + InetAddressFilter blockAllFilter = address -> true; + SecurityDnsHandler handler = new SecurityDnsHandler(blockAllFilter, true); + int targetPort = 443; + List candidates = Arrays.asList( + new InetSocketAddress(INTERNAL_IP_1, 8000), + new InetSocketAddress(EXTERNAL_IP_1, 8001) + ); + + List result = handler.handleInetSocketAddresses(candidates, targetPort); + + assertEquals(candidates.size(), result.size()); + for (int i = 0; i < candidates.size(); i++) { + assertEquals(candidates.get(i).getAddress(), result.get(i).getAddress(), "Address should match original candidate"); + assertEquals(targetPort, result.get(i).getPort(), "Port should be rewritten to targetPort"); + } + } + + // --- Mockito based test for direct InetAddressFilter interaction --- + @Test + void handleAddresses_withMockFilter_verifiesFilteringLogic() { + InetAddressFilter mockFilter = mock(InetAddressFilter.class); + // Define behavior for mock filter + when(mockFilter.filterAddress(INTERNAL_IP_1)).thenReturn(false); // Not blocked + when(mockFilter.filterAddress(EXTERNAL_IP_1)).thenReturn(true); // Blocked + when(mockFilter.filterAddress(EXTERNAL_IP_2)).thenReturn(false); // Not blocked + + SecurityDnsHandler handler = new SecurityDnsHandler(mockFilter, false); + List candidates = Arrays.asList(INTERNAL_IP_1, EXTERNAL_IP_1, EXTERNAL_IP_2); + + List result = handler.handleAddresses(candidates); + + assertTrue(result.contains(INTERNAL_IP_1)); + assertFalse(result.contains(EXTERNAL_IP_1)); + assertTrue(result.contains(EXTERNAL_IP_2)); + assertEquals(2, result.size()); + } + + // Helper for better assertion messages + private String resultToString(List addresses) { + return addresses.stream().map(InetAddress::getHostAddress).collect(Collectors.joining(", ")); + } +} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java index 7b339603ae1..8df13b94288 100644 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java +++ b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java @@ -1,13 +1,8 @@ package com.google.springframework.security.web.client; -import static com.google.springframework.security.web.client.NetworkMode.BLOCK_EXTERNAL; -import static org.springframework.http.MediaType.TEXT_HTML; -import java.net.InetAddress; -import java.util.Arrays; -import org.apache.hc.client5.http.DnsResolver; -import org.apache.hc.client5.http.config.ConnectionConfig; -import org.apache.hc.client5.http.config.TlsConfig; +import client.dns.SecurityDnsHandler; +import client.HttpComponentsDnsResolver; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; @@ -16,287 +11,156 @@ import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; import org.apache.hc.core5.http.config.Registry; import org.apache.hc.core5.http.config.RegistryBuilder; -import org.apache.hc.core5.util.Timeout; import org.eclipse.jetty.client.HttpClient; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.AnnotationConfigApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.http.ResponseEntity; -import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; -import org.springframework.http.client.JettyClientHttpRequestFactory; -import org.springframework.http.client.reactive.ClientHttpConnector; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; public class UsageExample { - public static void example8() { - System.out.println("Example 8 (WebClient - Netty - should be blocked)"); + public static void exampleApache() { + System.out.println("Example Apache - Apache HttpClient with HttpComponentsDnsResolver"); + // 1. Create SecurityDnsHandler with custom logic + SecurityDnsHandler securityDnsHandler = new SecurityDnsHandler.Builder().blockAllExternal( true).build(); - // 2. Create a Reactor Netty HttpClient (default settings are fine for this example) - reactor.netty.http.client.HttpClient nettyClient = reactor.netty.http.client.HttpClient.create(); + // 2. Create HttpComponentsDnsResolver with the custom SecurityDnsHandler + HttpComponentsDnsResolver httpComponentsDnsResolver = new HttpComponentsDnsResolver(securityDnsHandler); - // 3. Create the SecureRestTemplate.Builder and configure it for Netty - SecureRestTemplate.Builder builder = new SecureRestTemplate.Builder() - .fromNettyClient(new NettyClientAdapter(nettyClient)) // Use the Netty client - .reportOnly(false) // 'false' to block, 'true' to report only - .withCustomFilter(new BasicSsrfProtectionFilter(BLOCK_EXTERNAL)); - - // 4. Get the ClientHttpConnector (this integrates the SSRF protection) - ClientHttpConnector connector = builder.buildToClientHttpConnector(); - - // 5. Create a WebClient using the connector - WebClient webClient = WebClient.builder() - .clientConnector(connector) + // 3. Create a RestTemplate with the custom DnsResolver + Registry socketFactoryRegistry = RegistryBuilder.create() + .register("http", PlainConnectionSocketFactory.getSocketFactory()) + .register("https", SSLConnectionSocketFactory.getSocketFactory()) .build(); - // 6. Make a request to a BLOCKED URL (e.g., google.com) - Mono blockedResponseMono = webClient.get() - .uri("https://www.google.com") // This *should* be blocked - .retrieve() - .bodyToMono(String.class); - - blockedResponseMono.subscribe( - response -> { - // Should NOT be reached if blocking is enabled - System.out.println("BLOCKED Request - Unexpected Success: " + response); - }, - error -> { - // *Should* be reached if blocking is enabled - System.err.println("BLOCKED Request - Expected Failure: " + error.getMessage()); - } - ); + BasicHttpClientConnectionManager connectionManager = new BasicHttpClientConnectionManager( + socketFactoryRegistry, null, null, httpComponentsDnsResolver); - // Keep the application running (for demonstration purposes only) - try { - Thread.sleep(5000); // Wait for the asynchronous request to complete - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } + CloseableHttpClient httpClient = HttpClientBuilder.create() + .setConnectionManager(connectionManager) + .build(); + + RestTemplate restTemplate = new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); - public static void example7() { - System.out.println("Example 7"); - RestClient exampleClient = RestClient.create(new SecureRestTemplate.Builder(). - networkMode(BLOCK_EXTERNAL).build()); + // 4. Use the RestTemplate to make a request + System.out.println("Attempting to access http://google.com\n"); try { - exampleClient.get() - .uri("https://google.com") - .accept(TEXT_HTML) - .retrieve() - .body(String.class); + ResponseEntity response = restTemplate.getForEntity("https://google.com", String.class); + System.out.println("FAILURE - Response: " + response.getBody()); } catch (Exception e) { - System.err.println("Access blocked: " + e.getMessage()); + System.err.println("SUCCESS - Access blocked: " + e.getMessage()); } } - public static void example6() { - System.out.println("Example 6"); - - ClientHttpRequestFactory clientHttpRequestFactory = new SecureRestTemplate.Builder() - .reportOnly(true) // Log warning about blocking, but don't block - .networkMode(BLOCK_EXTERNAL) - .withCustomFilter( - addresses -> Arrays.stream(addresses).filter(a -> !a.isMCNodeLocal()) - .toArray(InetAddress[]::new)).withBlocklist("evil.com", "6.6.6.9/16", "123.123.123.123") - .buildToHttpRequestFactory(); - - if (clientHttpRequestFactory instanceof HttpComponentsClientHttpRequestFactory) { - HttpComponentsClientHttpRequestFactory factory = (HttpComponentsClientHttpRequestFactory) clientHttpRequestFactory; - factory.setConnectTimeout(1000); - } - // a secure RestTemplate can be still built - RestTemplate secureRestTemplate = new RestTemplate(clientHttpRequestFactory); - - try { - ResponseEntity result = secureRestTemplate.getForEntity("https://google.com", String.class); - System.out.println(result); - } catch (Exception e) { - // This should not run - System.err.println("Access blocked: " + e.getMessage()); - } + public static void main(String[] args) { + exampleApache(); + exampleNetty(); + exampleJetty(); } - public static void example5() { - System.out.println("Example 5"); - - // For SSRF prevention the "magic is built into the DNS resolver" - RestTemplate secureRestTemplate = new SecureRestTemplate.Builder() - .fromApacheClient(Hc5ClientAdapter.withCustomBuilder((DnsResolver dnsResolver) -> { - - // When a very custom client is needed - Registry registry = RegistryBuilder.create() - .register("http", PlainConnectionSocketFactory.getSocketFactory()) - .register("https", SSLConnectionSocketFactory.getSocketFactory()).build(); + public static void exampleJetty() { + System.out.println("\nExample Jetty - Jetty HttpClient with JettyHttpClientDnsResolver"); - BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager(registry, null, - null, - dnsResolver); - - // with custom timeout - connManager.setConnectionConfig( - ConnectionConfig.custom().setConnectTimeout(Timeout.ofMinutes(2)).build()); - // with custom TLS config - connManager.setTlsConfig(TlsConfig.custom().setSupportedCipherSuites("TLSv1-3").build()); - - CloseableHttpClient httpClient = HttpClientBuilder.create().setConnectionManager(connManager) - .build(); + // 1. Create SecurityDnsHandler with custom logic + SecurityDnsHandler securityDnsHandler = SecurityDnsHandler.builder() + .denyList("192.168.1.100") // Example: deny a specific private IP + .blockAllInternal(true) // Example: block all internal IPs (like 127.0.0.1) + .reportOnly(false) + .build(); - HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory( - httpClient); + // 2. Create JettyHttpClientDnsResolver with the custom SecurityDnsHandler + // Jetty's default SocketAddressResolver will be used as the delegate. + client.JettyHttpClientDnsResolver jettyDnsResolver = new client.JettyHttpClientDnsResolver(securityDnsHandler); - return requestFactory; - })) - .reportOnly(true) // Log warning about blocking, but don't block - .networkMode(BLOCK_EXTERNAL) - .withCustomFilter( - addresses -> Arrays.stream(addresses).filter(a -> !a.isMCNodeLocal()) - .toArray(InetAddress[]::new)).withBlocklist("evil.com", "6.6.6.9/16", "123.123.123.123") - .build(); // a secure RestTemplate can be still built + // 3. Create and configure Jetty's HttpClient + HttpClient jettyHttpClient = new HttpClient(); + jettyHttpClient.setSocketAddressResolver(jettyDnsResolver); try { - ResponseEntity result = secureRestTemplate.getForEntity("https://google.com", String.class); - System.out.println(result); - } catch (Exception e) { - // This should not run - System.err.println("Access blocked: " + e.getMessage()); - } - } - - /** - * Showcasing the configuration to be used for a Jetty HttpClient based RestTemplate - */ - @Configuration - public static class ExampleConfig4 { - - @Bean - HttpClient jettyClient() { - return new HttpClient(); - } - - @Bean - JettyClientHttpRequestFactory jettyClientHttpRequestFactory(HttpClient jettyClient) { - // This bean manages the lifecycle of (starts/stops) the HttpClient - return new JettyClientHttpRequestFactory(jettyClient); - } + jettyHttpClient.start(); // Jetty HttpClient must be started - @Bean("secureRestTemplate") - RestTemplate secureRestTemplate(HttpClient jettyClient) { - return new SecureRestTemplate.Builder() - .fromJettyClient(new JettyClientAdapter(jettyClient)) - .reportOnly(true) // Log warning about blocking, but don't block - .networkMode(BLOCK_EXTERNAL) - .withCustomFilter(addresses -> Arrays.stream(addresses).filter(a -> !a.isMCNodeLocal()) - .toArray(InetAddress[]::new)).withBlocklist("evil.com", "6.6.6.9/16", "123.123.123.123") - .build(); - } - } - - @Component - public static class Example4App { - - @Autowired - RestTemplate secureRestTemplate; - - public void run() { + // 4. Use the HttpClient to make a request + // Example: trying to access a blocked internal address + System.out.println("\nAttempting to access http://localhost (should be blocked by blockAllInternal=true)\n"); + try { + org.eclipse.jetty.client.api.ContentResponse response = jettyHttpClient.newRequest("http://localhost:8080").send(); // Jetty 11.x API + System.out.println("FAILURE: Response from http://localhost: " + response.getContentAsString()); + } catch (Exception e) { + // We expect an exception here, often a form of ConnectTimeoutException or similar + // because the resolution will yield no valid addresses if localhost is blocked. + // Jetty's behavior on no resolvable address might vary (e.g. timeout or specific exception). + System.err.println("SUCCESS: Access to http://localhost blocked or failed as expected: " + e.getClass().getName() + " - " + e.getMessage()); + } + // Example: trying to access an external address (should be allowed if not in deny list and DNS resolves) + System.out.println("\nAttempting to access http://example.com (should be allowed)\n"); try { - ResponseEntity result = secureRestTemplate.getForEntity("https://google.com", String.class); - System.out.println(result); + org.eclipse.jetty.client.api.ContentResponse response = jettyHttpClient.newRequest("http://example.com").send(); // Jetty 11.x API + System.out.println("SUCCESS: Response from http://example.com: " + response.getContentAsString().substring(0, Math.min(response.getContentAsString().length(), 100)) + "..."); } catch (Exception e) { - // This should not run - System.err.println("Access blocked: " + e.getMessage()); + System.err.println("FAILURE: Access to http://example.com failed: " + e.getMessage()); } + jettyHttpClient.stop(); // Stop the client + + } catch (Exception e) { + System.err.println("Error setting up or using Jetty HttpClient: " + e.getMessage()); + e.printStackTrace(); } } - public static void example4() { - System.out.println("Example 4"); - // Barebone spring application to demonstrate Jetty client usage above - AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); - ctx.register(ExampleConfig4.class); - ctx.register(Example4App.class); - ctx.refresh(); - ctx.start(); + public static void exampleNetty() { + System.out.println("\nExample Netty - Reactor Netty HttpClient with NettyHttpClientAddressSelector\n"); - Example4App app = ctx.getBean(Example4App.class); - app.run(); + // 1. Create SecurityDnsHandler with custom logic + SecurityDnsHandler securityDnsHandler = SecurityDnsHandler.builder() + .blockAllExternal(false) + .allowList("1.1.1.1", "8.8.4.4","google.com") // Example: only allow specific public IPs + .reportOnly(false) // Block if rules are not met + .build(); - ctx.close(); - } + // 2. Create NettyHttpClientAddressSelector with the custom SecurityDnsHandler + client.NettyHttpClientAddressSelector addressSelector = new client.NettyHttpClientAddressSelector(securityDnsHandler); + // 3. Create a Reactor Netty HttpClient with the custom addressSelector + reactor.netty.http.client.HttpClient nettyHttpClient = reactor.netty.http.client.HttpClient.create() + .resolvedAddressesSelector(addressSelector); - public static void example3() { - System.out.println("Example 3"); - RestTemplate exampleTemplate = new SecureRestTemplate.Builder() - .reportOnly(true) // Log warning about blocking, but don't block - .networkMode(BLOCK_EXTERNAL) - .withCustomFilter( - addresses -> Arrays.stream(addresses).filter(a -> !a.isMCNodeLocal()) - .toArray(InetAddress[]::new)).withBlocklist("evil.com", "6.6.6.9/16", "123.123.123.123") + // 4. Use the WebClient with the configured HttpClient to make a request + // The following WebClient setup requires spring-webflux dependency. + // Commenting out to allow compilation without it, to focus on the selector's role. + WebClient webClient = WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(nettyHttpClient)) .build(); + System.out.println("Attempting to access http://example.com (should be blocked as not in allowList)\n"); try { - ResponseEntity result = exampleTemplate.getForEntity("https://google.com", String.class); - System.out.println(result); + String response = webClient.get() + .uri("http://example.com") // example.com IP is likely not 1.1.1.1 or 8.8.8.8 + .retrieve() + .bodyToMono(String.class) + .block(); + System.out.println("FAILURE: Response from http://example.com: " + response.substring(0, Math.min(response.length(), 100)) + "..."); } catch (Exception e) { - // This should not run - System.err.println("Access blocked: " + e.getMessage()); + System.err.println("SUCCESS: Access to http://example.com blocked or failed as expected: " + e.getMessage()); } - } - - - public static void example2() { - System.out.println("Example 2"); - RestTemplate exampleTemplate = new SecureRestTemplate.Builder() - .withAllowlist("google.com") - .build(); + System.out.println("\nAttempting to access http://1.1.1.1 (should be allowed if 1.1.1.1 is reachable)\n"); try { - exampleTemplate.getForEntity("https://google.com", String.class); - System.out.println("Success"); + // Note: 1.1.1.1 might not be serving HTTP or could be firewalled. + // This is to demonstrate the selector allowing the attempt. + String response = webClient.get() + .uri("http://1.1.1.1") + .retrieve() + .bodyToMono(String.class) + .block(java.time.Duration.ofSeconds(5)); // Timeout for potentially unresponsive IP + System.out.println("SUCCESS: Response from http://1.1.1.1: " + response.substring(0, Math.min(response.length(), 100)) + "..."); } catch (Exception e) { - System.err.println("Access blocked: " + e.getMessage()); + System.err.println("FAILURE: Access to http://1.1.1.1 blocked or failed as expected: " + e.getMessage()); } - - // This should print: - // Access blocked: I/O error on GET request for "https://google.com": Access to google.com was blocked because it violates the SSRF protection config - } - - public static void example1() { - System.out.println("Example 1"); - // run with `-Dssrf.protection.mode=deny_list -Dssrf.protection.iplist=127.0.0.1,192.168.0.0/16` - // if the properties are not set accordingly it will fail with IllegalStateException - - // for this example: - System.setProperty("ssrf.protection.mode", "deny_list"); - System.setProperty("ssrf.protection.iplist", "127.0.0.1,192.168.0.0/16"); - - RestTemplate exampleTemplate = SecureRestTemplate.buildDefault(); - exampleTemplate.getForEntity("https://google.com", String.class); - } - - public static void main(String[] args) { - example1(); - example2(); - example3(); - example4(); - example5(); - example6(); - example7(); - example8(); - } - - } - From 40fa2fea3dcd4f6b5a3bb8094ab999e9be7be6bc Mon Sep 17 00:00:00 2001 From: Kian Jamali Date: Fri, 18 Jul 2025 06:15:36 +0000 Subject: [PATCH 29/34] Fix 2 Netty tests --- .../client/NettyHttpClientAddressSelectorTests.java | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/ssrf/src/test/java/client/NettyHttpClientAddressSelectorTests.java b/ssrf/src/test/java/client/NettyHttpClientAddressSelectorTests.java index b1ea22811fa..fab0b2f8927 100644 --- a/ssrf/src/test/java/client/NettyHttpClientAddressSelectorTests.java +++ b/ssrf/src/test/java/client/NettyHttpClientAddressSelectorTests.java @@ -159,7 +159,8 @@ void selectWithDifferentPortsShouldUseFirstPort() { // which our selector derives from the first element. List result = selector.select(mockConfig, () -> addresses); assertEquals(List.of( - createInetSocketAddress("1.1.1.1", 80) + createInetSocketAddress("1.1.1.1", 80), + createInetSocketAddress("1.1.1.1", 443) ), result); } @@ -188,11 +189,6 @@ void selectWithMixedAddressTypesShouldFilterInetSocketAddressesOnly() { InetSocketAddress allowedAddress = createInetSocketAddress("1.1.1.1", 80); InetSocketAddress deniedAddress = createInetSocketAddress("8.8.8.8", 80); - // The current implementation will only return filtered InetSocketAddress types. - // Non-InetSocketAddress types are effectively dropped if any filtering occurs, - // or if the list is reconstructed. This test verifies that. - // If the intention was to keep non-InetSocketAddress types, the implementation would need adjustment. - List addresses = List.of( allowedAddress, deniedAddress, @@ -201,8 +197,7 @@ void selectWithMixedAddressTypesShouldFilterInetSocketAddressesOnly() { List result = selector.select(mockConfig, () -> addresses); - // Based on current implementation, non-InetSocketAddress are filtered out - // when the list is reconstructed from filtered InetSocketAddress(es). - assertEquals(List.of(allowedAddress), result); + // The current implementation keeps non-InetSocketAddress types in the result. + assertEquals(List.of(allowedAddress, nonInetAddress), result); } } From f39a45309723919f74b761617e0dbf1da06a5712 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Tue, 13 Jan 2026 23:01:50 +0000 Subject: [PATCH 30/34] Candidate for Spring Security (#14) * Add draft of proposal for Spring Security * Update draft of proposal for Spring Security --- gradle.properties | 1 + gradle/wrapper/gradle-wrapper.properties | 2 +- ssrf/build.gradle | 7 +- .../HttpComponentsFilteringDnsResolver.java | 55 ++++++ .../JettyHttpClientFilteringDnsResolver.java | 73 ++++++++ ...ttyHttpClientFilteringAddressSelector.java | 83 +++++++++ .../matcher/AllowedInetAddressFilter.java | 32 ++++ .../DefaultInetAddressVerifierBuilder.java | 110 ++++++++++++ .../matcher/DisallowedInetAddressFilter.java | 29 ++++ .../web/util/matcher/InetAddressFilter.java | 46 +++++ .../InternalExternalInetAddressFilter.java | 72 ++++++++ .../web/util/matcher/IpAddressMatcher.java | 70 ++++++++ .../web/util/matcher/package-info.java | 4 + ...ttyHttpClientFilteringDnsResolverTest.java | 130 ++++++++++++++ .../web/util/matcher/UsageExample.java | 159 ++++++++++++++++++ 15 files changed, 869 insertions(+), 4 deletions(-) create mode 100644 ssrf/src/main/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolver.java create mode 100644 ssrf/src/main/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolver.java create mode 100644 ssrf/src/main/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelector.java create mode 100644 ssrf/src/main/java/org/springframework/security/web/util/matcher/AllowedInetAddressFilter.java create mode 100644 ssrf/src/main/java/org/springframework/security/web/util/matcher/DefaultInetAddressVerifierBuilder.java create mode 100644 ssrf/src/main/java/org/springframework/security/web/util/matcher/DisallowedInetAddressFilter.java create mode 100644 ssrf/src/main/java/org/springframework/security/web/util/matcher/InetAddressFilter.java create mode 100644 ssrf/src/main/java/org/springframework/security/web/util/matcher/InternalExternalInetAddressFilter.java create mode 100644 ssrf/src/main/java/org/springframework/security/web/util/matcher/IpAddressMatcher.java create mode 100644 ssrf/src/main/java/org/springframework/security/web/util/matcher/package-info.java create mode 100644 ssrf/src/test/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolverTest.java create mode 100644 ssrf/src/test/java/org/springframework/security/web/util/matcher/UsageExample.java diff --git a/gradle.properties b/gradle.properties index b5c59e277b3..49abc104716 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,2 @@ springVersion=6.1.4 +springSecurityVersion=6.5.7 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1e2fbf0d458..d706aba609b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/ssrf/build.gradle b/ssrf/build.gradle index 4dd23a1439c..873926b7091 100644 --- a/ssrf/build.gradle +++ b/ssrf/build.gradle @@ -17,7 +17,8 @@ repositories { dependencies { // Use JUnit Jupiter for testing. testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' + testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.2' + testImplementation 'org.junit.platform:junit-platform-launcher:1.14.2' testImplementation 'org.mockito:mockito-core:5.14.2' testImplementation "org.mockito:mockito-junit-jupiter:5.14.2" testImplementation 'org.springframework:spring-context:' + springVersion @@ -27,12 +28,11 @@ dependencies { api 'org.springframework:spring-web:' + springVersion api 'org.springframework:spring-core:' + springVersion + api 'org.springframework.security:spring-security-web:' + springSecurityVersion api "org.apache.httpcomponents.client5:httpclient5:5.4.3" api "org.apache.httpcomponents.core5:httpcore5-reactive:5.2.5" - api 'org.eclipse.jetty:jetty-client:11.0.20' - api 'io.projectreactor:reactor-core:3.6.11' // Core Netty functionality (EventLoop, Channel, etc.) @@ -44,6 +44,7 @@ dependencies { api 'io.netty:netty-resolver-dns:4.1.107.Final' api "org.jspecify:jspecify:1.0.0" + api "commons-logging:commons-logging:1.3.5" // Spring WebFlux (REQUIRED for WebClient, make available to tests) implementation "org.springframework:spring-webflux:$springVersion" diff --git a/ssrf/src/main/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolver.java b/ssrf/src/main/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolver.java new file mode 100644 index 00000000000..a5f9fdc5fb1 --- /dev/null +++ b/ssrf/src/main/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolver.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.http.client; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; + +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; + +import org.springframework.security.web.util.matcher.InetAddressFilter; + +public class HttpComponentsFilteringDnsResolver implements DnsResolver { + + private final DnsResolver delegate; + + private final InetAddressFilter filter; + + + public HttpComponentsFilteringDnsResolver(InetAddressFilter filter) { + this(SystemDefaultDnsResolver.INSTANCE, filter); + } + + public HttpComponentsFilteringDnsResolver(DnsResolver delegate, InetAddressFilter filter) { + this.delegate = delegate; + this.filter = filter; + } + + + @Override + public InetAddress[] resolve(String host) throws UnknownHostException { + return Arrays.stream(this.delegate.resolve(host)) + .filter(this.filter::filter).toArray(InetAddress[]::new); + } + + @Override + public String resolveCanonicalHostname(String host) throws UnknownHostException { + return this.delegate.resolveCanonicalHostname(host); + } +} diff --git a/ssrf/src/main/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolver.java b/ssrf/src/main/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolver.java new file mode 100644 index 00000000000..2f443cc6127 --- /dev/null +++ b/ssrf/src/main/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolver.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.http.client; + + +import java.net.InetSocketAddress; +import java.util.List; + +import org.eclipse.jetty.util.Promise; +import org.eclipse.jetty.util.SocketAddressResolver; + +import org.springframework.security.web.util.matcher.InetAddressFilter; + +public class JettyHttpClientFilteringDnsResolver implements SocketAddressResolver { + + private final SocketAddressResolver delegate; + + private final InetAddressFilter filter; + + + public JettyHttpClientFilteringDnsResolver(SocketAddressResolver delegate, InetAddressFilter filter) { + this.delegate = delegate; + this.filter = filter; + } + + /** + * Creates a new instance using Jetty's default + * as the delegate resolver. + * @param filter the filter to apply security rules to the resolved addresses. + */ + public JettyHttpClientFilteringDnsResolver(InetAddressFilter filter) { + // Call the primary constructor, using the fully qualified name for the nested static class + SocketAddressResolver resolver = new Sync(); + this.delegate = resolver; + this.filter = filter; + } + + + @Override + public void resolve(String host, int port, Promise> outerPromise) { + this.delegate.resolve(host, port, new Promise<>() { + + @Override + public void succeeded(List candidates) { + outerPromise.succeeded(candidates.stream() + .map(InetSocketAddress::getAddress) + .filter(filter::filter) + .map(address -> new InetSocketAddress(address, port)) + .toList()); + } + + @Override + public void failed(Throwable ex) { + outerPromise.failed(ex); + } + }); + } + +} diff --git a/ssrf/src/main/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelector.java b/ssrf/src/main/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelector.java new file mode 100644 index 00000000000..b2b96efa1a5 --- /dev/null +++ b/ssrf/src/main/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelector.java @@ -0,0 +1,83 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.http.client; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import org.jspecify.annotations.Nullable; +import reactor.netty.transport.ClientTransport.ResolvedAddressSelector; +import reactor.netty.transport.ClientTransportConfig; + +import org.springframework.security.web.util.matcher.InetAddressFilter; + +/** + * A {@link ResolvedAddressSelector} that uses a {@link InetAddressFilter} to filter a list of + * resolved {@link SocketAddress}es for a Reactor Netty client. + * + * @see reactor.netty.http.client.HttpClient#resolvedAddressesSelector(ResolvedAddressSelector) + */ +public class NettyHttpClientFilteringAddressSelector implements ResolvedAddressSelector> { + + private final InetAddressFilter filter; + + /** + * Creates a new instance. + * @param filter The filter to apply security rules to the resolved addresses. + */ + public NettyHttpClientFilteringAddressSelector(InetAddressFilter filter) { + Objects.requireNonNull(filter, "InetAddressFilter must not be null"); + this.filter = filter; + } + + public List select(ClientTransportConfig clientTransportConfig, + Supplier> addresses) { + + return filterInternal(addresses.get()); + } + + @Override + public @Nullable List apply(ClientTransportConfig config, + List resolvedAddresses) { + + return filterInternal(resolvedAddresses); + } + + private List filterInternal(List socketAddresses) { + + if (socketAddresses == null || socketAddresses.isEmpty()) { + return (List) socketAddresses; + } + + List filteredIn = socketAddresses.stream() + .filter(InetSocketAddress.class::isInstance) + .map(InetSocketAddress.class::cast) + .map(InetSocketAddress::getAddress) + .filter(this.filter::filter) + .toList(); + + return socketAddresses.stream() + .filter(sa -> !(sa instanceof InetSocketAddress) || filteredIn.contains(sa)) + .collect(Collectors.toList()); + } + +} diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/AllowedInetAddressFilter.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/AllowedInetAddressFilter.java new file mode 100644 index 00000000000..525c0a2f530 --- /dev/null +++ b/ssrf/src/main/java/org/springframework/security/web/util/matcher/AllowedInetAddressFilter.java @@ -0,0 +1,32 @@ +package org.springframework.security.web.util.matcher; + +import java.net.InetAddress; +import java.util.List; + +final class AllowedInetAddressFilter implements InetAddressFilter { + + private final List allowList; + + public AllowedInetAddressFilter(List allowList) { + this.allowList = allowList.stream().map(IpAddressMatcher::new).toList(); + } + + @Override + public boolean filter(InetAddress address) { + if (this.allowList.isEmpty()) { + return true; + } + for (IpAddressMatcher matcher : this.allowList) { + if (matcher.matches(address)) { + return true; + } + } + return false; + } + + @Override + public String toString() { + return "AllowedInetAddressVerifier[\"" + this.allowList + "\"]"; + } + +} diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/DefaultInetAddressVerifierBuilder.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/DefaultInetAddressVerifierBuilder.java new file mode 100644 index 00000000000..0f0db250e63 --- /dev/null +++ b/ssrf/src/main/java/org/springframework/security/web/util/matcher/DefaultInetAddressVerifierBuilder.java @@ -0,0 +1,110 @@ +package org.springframework.security.web.util.matcher; + + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.util.Assert; + + +final class DefaultInetAddressVerifierBuilder implements InetAddressFilter.Builder { + + private static final String ALLOWED_DISALLOWED_MESSAGE = "allowed and disallowed are mutually exclusive"; + + private static final String INTERNAL_EXTERNAL_ONLY_MESSAGE = "internalOnly and externalOnly are mutually exclusive"; + + private final List filters = new ArrayList<>(); + + private boolean reportOnly; + + @Override + public InetAddressFilter.Builder allowList(List addresses) { + assertNoneMatch(filter -> filter instanceof DisallowedInetAddressFilter, ALLOWED_DISALLOWED_MESSAGE); + this.filters.add(new AllowedInetAddressFilter(addresses)); + return this; + } + + @Override + public InetAddressFilter.Builder denyList(List addresses) { + assertNoneMatch(filter -> filter instanceof AllowedInetAddressFilter, ALLOWED_DISALLOWED_MESSAGE); + this.filters.add(new DisallowedInetAddressFilter(addresses)); + return this; + } + + @Override + public InetAddressFilter.Builder blockExternal() { + return addInternalOrExternalFilter(true); + } + + @Override + public InetAddressFilter.Builder blockInternal() { + return addInternalOrExternalFilter(false); + } + + private InetAddressFilter.Builder addInternalOrExternalFilter(boolean blockExternal) { + + assertNoneMatch( + f -> f instanceof InternalExternalInetAddressFilter ief && blockExternal != ief.shouldBlockExternal(), + INTERNAL_EXTERNAL_ONLY_MESSAGE); + + this.filters.add(new InternalExternalInetAddressFilter(blockExternal)); + return this; + } + + @Override + public InetAddressFilter.Builder addCustomFilter(InetAddressFilter filter) { + this.filters.add(filter); + return this; + } + + @Override + public InetAddressFilter.Builder reportOnly() { + this.reportOnly = true; + return this; + } + + @Override + public InetAddressFilter build() { + return new CompositeInetAddressFilter(this.filters, this.reportOnly); + } + + private void assertNoneMatch(Predicate predicate, String message) { + Assert.state(this.filters.stream().noneMatch(predicate), message); + } + + + private record CompositeInetAddressFilter( + List filters, boolean reportOnly) implements InetAddressFilter { + + private static final Log logger = LogFactory.getLog(InetAddressFilter.class); + + private CompositeInetAddressFilter(List filters, boolean reportOnly) { + this.filters = new ArrayList<>(filters); + this.reportOnly = reportOnly; + } + + @Override + public boolean filter(InetAddress address) { + boolean result = doFilter(address); + return (this.reportOnly || result); + } + + private boolean doFilter(InetAddress address) { + for (InetAddressFilter filter : this.filters) { + if (!filter.filter(address)) { + if (logger.isDebugEnabled()) { + logger.debug("InetAddress " + address + " blocked by " + filter); + } + return false; + } + } + return true; + } + } + +} diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/DisallowedInetAddressFilter.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/DisallowedInetAddressFilter.java new file mode 100644 index 00000000000..5b58206af7f --- /dev/null +++ b/ssrf/src/main/java/org/springframework/security/web/util/matcher/DisallowedInetAddressFilter.java @@ -0,0 +1,29 @@ +package org.springframework.security.web.util.matcher; + +import java.net.InetAddress; +import java.util.List; + +final class DisallowedInetAddressFilter implements InetAddressFilter { + + private final List disallowList; + + public DisallowedInetAddressFilter(List disallowList) { + this.disallowList = disallowList.stream().map(IpAddressMatcher::new).toList(); + } + + @Override + public boolean filter(InetAddress address) { + for (IpAddressMatcher matcher : this.disallowList) { + if (matcher.matches(address)) { + return false; + } + } + return true; + } + + @Override + public String toString() { + return "DisallowedInetAddressVerifier[\"" + this.disallowList + "\"]"; + } + +} diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/InetAddressFilter.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/InetAddressFilter.java new file mode 100644 index 00000000000..b914d5cf409 --- /dev/null +++ b/ssrf/src/main/java/org/springframework/security/web/util/matcher/InetAddressFilter.java @@ -0,0 +1,46 @@ +package org.springframework.security.web.util.matcher; + +import java.net.InetAddress; +import java.util.List; + +/** + * Component that helps to filter an {@link InetAddress} in or out. + */ +public interface InetAddressFilter { + + /** + * Whether the given address should be filtered in or out. + * @return {@code true} if the address is allowed for use, and {@code false} + * if it is restricted and should not be used. + */ + boolean filter(InetAddress address); + + + /** + * Return a builder to for a composite {@link InetAddressFilter} that + * delegates to any number of other filters. + */ + static Builder builder() { + return new DefaultInetAddressVerifierBuilder(); + } + + + interface Builder { + + Builder allowList(List addresses); + + Builder denyList(List addresses); + + Builder blockExternal(); + + Builder blockInternal(); + + Builder addCustomFilter(InetAddressFilter verifier); + + Builder reportOnly(); + + InetAddressFilter build(); + + } + +} diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/InternalExternalInetAddressFilter.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/InternalExternalInetAddressFilter.java new file mode 100644 index 00000000000..17b6f2e1ea6 --- /dev/null +++ b/ssrf/src/main/java/org/springframework/security/web/util/matcher/InternalExternalInetAddressFilter.java @@ -0,0 +1,72 @@ +package org.springframework.security.web.util.matcher; + +import java.net.InetAddress; + +final class InternalExternalInetAddressFilter implements InetAddressFilter { + + private final boolean blockExternal; + + InternalExternalInetAddressFilter(boolean blockExternal) { + this.blockExternal = blockExternal; + } + + public boolean shouldBlockExternal() { + return this.blockExternal; + } + + @Override + public boolean filter(InetAddress address) { + return (this.blockExternal == isLocal(address)); + } + + private static boolean isLocal(InetAddress address) { + if (address.isLoopbackAddress()) { + return true; + } + + byte[] rawAddress = address.getAddress(); + + // there is sadly no Stream support for byte arrays + int[] iAddr = new int[rawAddress.length]; + for (int i = 0; i < rawAddress.length; i++) { + iAddr[i] = Byte.toUnsignedInt(rawAddress[i]); + } + + // Ignoring Multicast addresses + if (address.getAddress().length == 4) { + // IPv4 filtering + // 10.x.x.x , 192.168.x.x , 172.16.x.x + if (iAddr[0] == 10 || + (iAddr[0] == 192 && iAddr[1] == 168) || + (iAddr[0] == 172 && iAddr[1] == 16)) { + return true; + } + + } + else if (address.getAddress().length == 16) { + // IPv6, check for Unique Local Addresses + if (iAddr[0] == 0xfc || iAddr[0] == 0xfd) { + return true; + } + + // IPv4/IPv6 translation, 64:ff9b + if (iAddr[0] == 0x00 && iAddr[1] == 0x64 && iAddr[2] == 0xff && iAddr[3] == 0x9b) { + int[] ipv4Part = new int[] {iAddr[12], iAddr[13], iAddr[14], iAddr[15]}; + // same check as above plus a check for loopback + if (ipv4Part[0] == 10 || ipv4Part[0] == 127 || + (ipv4Part[0] == 192 && ipv4Part[1] == 168) || + (ipv4Part[0] == 172 && ipv4Part[1] == 16)) { + return true; + } + } + } + + return false; + } + + @Override + public String toString() { + return getClass().getSimpleName() + " (" + (this.blockExternal ? "blockExternal" : "blockInternal") + ")"; + } + +} diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/IpAddressMatcher.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/IpAddressMatcher.java new file mode 100644 index 00000000000..e4e3197e02b --- /dev/null +++ b/ssrf/src/main/java/org/springframework/security/web/util/matcher/IpAddressMatcher.java @@ -0,0 +1,70 @@ +package org.springframework.security.web.util.matcher; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +// Inspired by and to be merged back into +// org.springframework.security.web.util.matcher.IpAddressMatcher.java + +class IpAddressMatcher { + + private static final Log logger = LogFactory.getLog(IpAddressMatcher.class); + + private final InetAddress address; + + private final int nMaskBits; + + + public IpAddressMatcher(String addressOrRange) { + if (addressOrRange.indexOf('/') > 0) { + String[] addressAndMask = addressOrRange.split("/"); + address = parse(addressAndMask[0]); + this.nMaskBits = Integer.parseInt(addressAndMask[1]); + } else { + this.nMaskBits = -1; + address = parse(addressOrRange); + } + } + + private static InetAddress parse(String address) { + try { + InetAddress result = InetAddress.getByName(address); + if (address.matches(".*[a-zA-Z\\-].*$") && !address.contains(":")) { + logger.warn("Hostname '" + address + "' resolved to " + result.toString() + + " will be used on IP address matching"); + } + return result; + } catch (UnknownHostException ex) { + throw new IllegalArgumentException(String.format("Failed to parse address '%s'", address), ex); + } + } + + + public boolean matches(InetAddress toCheck) { + if (this.nMaskBits < 0) { + return toCheck.equals(this.address); + } + byte[] remAddr = toCheck.getAddress(); + byte[] reqAddr = this.address.getAddress(); + int nMaskFullBytes = this.nMaskBits / 8; + byte finalByte = (byte) (0xFF00 >> (this.nMaskBits & 0x07)); + for (int i = 0; i < nMaskFullBytes; i++) { + if (remAddr[i] != reqAddr[i]) { + return false; + } + } + if (finalByte != 0) { + return (remAddr[nMaskFullBytes] & finalByte) == (reqAddr[nMaskFullBytes] & finalByte); + } + return true; + } + + @Override + public String toString() { + return "IpAddressMatcher{address=" + this.address + ", nMaskBits=" + this.nMaskBits + '}'; + } + +} diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/package-info.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/package-info.java new file mode 100644 index 00000000000..55b34c26a2d --- /dev/null +++ b/ssrf/src/main/java/org/springframework/security/web/util/matcher/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package org.springframework.security.web.util.matcher; + +import org.jspecify.annotations.NullMarked; diff --git a/ssrf/src/test/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolverTest.java b/ssrf/src/test/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolverTest.java new file mode 100644 index 00000000000..577a83e33ce --- /dev/null +++ b/ssrf/src/test/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolverTest.java @@ -0,0 +1,130 @@ +package org.springframework.boot.http.client; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.eclipse.jetty.util.Promise; +import org.eclipse.jetty.util.SocketAddressResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.security.web.util.matcher.InetAddressFilter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyList; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JettyHttpClientFilteringDnsResolverTest { + + @Mock + private SocketAddressResolver mockDelegateResolver; + + @Mock + private InetAddressFilter mockFilter; + + @Mock + private Promise> mockResultPromise; + + @Captor + private ArgumentCaptor>> delegatePromiseCaptor; + + @Captor + private ArgumentCaptor> resultListCaptor; + + private JettyHttpClientFilteringDnsResolver dnsResolver; + + private final String HOST = "example.com"; + private final int PORT = 80; + + private List initialAddresses; + private List filteredAddresses; + + @BeforeEach + void setUp() throws UnknownHostException { + // Initialize the class under test with mocked dependencies + dnsResolver = new JettyHttpClientFilteringDnsResolver(mockDelegateResolver, mockFilter); + + // Prepare sample data + InetSocketAddress address1 = new InetSocketAddress(InetAddress.getByAddress(HOST, new byte[]{1, 2, 3, 4}), PORT); + InetSocketAddress address2 = new InetSocketAddress(InetAddress.getByAddress(HOST, new byte[]{1, 2, 3, 5}), PORT); + InetSocketAddress address3 = new InetSocketAddress(InetAddress.getByAddress(HOST, new byte[]{1, 2, 3, 6}), PORT); + + initialAddresses = new ArrayList<>(Arrays.asList(address1, address2, address3)); + // SecurityDnsHandler will remove address2 + filteredAddresses = new ArrayList<>(Arrays.asList(address1, address3)); + } + + @Test + void resolve_whenDelegateSucceeds_shouldApplySecurityHandlerAndReturnFilteredAddresses() { + // 1. Configure mock SecurityDnsHandler + // When handleInetSocketAddresses is called with the initial list, return the filtered list + for (InetSocketAddress address : initialAddresses) { + when(mockFilter.filter(address.getAddress())) + .thenReturn(filteredAddresses.contains(address)); + } + + // 2. Call the method under test + dnsResolver.resolve(HOST, PORT, mockResultPromise); + + // 3. Verify that the delegate resolver's resolve method was called + // and capture the Promise passed to it. + verify(mockDelegateResolver).resolve(eq(HOST), eq(PORT), delegatePromiseCaptor.capture()); + // 4. Simulate the delegate resolver succeeding + // Get the captured promise and call its 'succeeded' method with the initial addresses. + Promise> capturedPromiseForDelegate = delegatePromiseCaptor.getValue(); + assertNotNull(capturedPromiseForDelegate, "Promise passed to delegate resolver should not be null"); + capturedPromiseForDelegate.succeeded(initialAddresses); + // 5. Verify that SecurityDnsHandler.handleInetSocketAddresses was called + for (InetSocketAddress address : initialAddresses) { + verify(mockFilter).filter(address.getAddress()); + } + // 6. Verify that the original mockResultPromise.succeeded was called with the filtered list + verify(mockResultPromise).succeeded(resultListCaptor.capture()); + assertEquals(filteredAddresses, resultListCaptor.getValue(), "The final list of addresses should be the one filtered by the security handler."); + assertTrue(resultListCaptor.getValue().containsAll(filteredAddresses) && filteredAddresses.containsAll(resultListCaptor.getValue()), + "Resulting list should exactly match the filtered list."); + assertFalse(resultListCaptor.getValue().stream().anyMatch(addr -> addr.getAddress().getHostAddress().equals("1.2.3.5")), + "The IP address '1.2.3.5' should have been filtered out."); + // Ensure no failures were propagated + verify(mockResultPromise, never()).failed(any(Throwable.class)); + } + + @Test + void resolve_whenDelegateFails_shouldPropagateFailureAndNotCallSecurityHandler() { + // 1. Prepare a sample exception + Throwable expectedException = new RuntimeException("DNS resolution failed by delegate"); + + // 2. Call the method under test + dnsResolver.resolve(HOST, PORT, mockResultPromise); + + // 3. Verify that the delegate resolver's resolve method was called + // and capture the Promise passed to it. + verify(mockDelegateResolver).resolve(eq(HOST), eq(PORT), delegatePromiseCaptor.capture()); + // 4. Simulate the delegate resolver failing + // Get the captured promise and call its 'failed' method with the exception. + Promise> capturedPromiseForDelegate = delegatePromiseCaptor.getValue(); + assertNotNull(capturedPromiseForDelegate, "Promise passed to delegate resolver should not be null"); + capturedPromiseForDelegate.failed(expectedException); + // 5. Verify that the original mockResultPromise.failed was called with the same exception + verify(mockResultPromise).failed(expectedException); + // Ensure succeeded was not called + verify(mockResultPromise, never()).succeeded(anyList()); + } +} diff --git a/ssrf/src/test/java/org/springframework/security/web/util/matcher/UsageExample.java b/ssrf/src/test/java/org/springframework/security/web/util/matcher/UsageExample.java new file mode 100644 index 00000000000..bce2876d062 --- /dev/null +++ b/ssrf/src/test/java/org/springframework/security/web/util/matcher/UsageExample.java @@ -0,0 +1,159 @@ +package org.springframework.security.web.util.matcher; + + +import java.util.List; + +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; +import org.apache.hc.client5.http.socket.ConnectionSocketFactory; +import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.core5.http.config.Registry; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.eclipse.jetty.client.HttpClient; + +import org.springframework.boot.http.client.HttpComponentsFilteringDnsResolver; +import org.springframework.boot.http.client.JettyHttpClientFilteringDnsResolver; +import org.springframework.boot.http.client.NettyHttpClientFilteringAddressSelector; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; + + +public class UsageExample { + + public static void exampleApache() { + System.out.println("Example Apache - Apache HttpClient with HttpComponentsDnsResolver"); + + InetAddressFilter filter = InetAddressFilter.builder().blockExternal().build(); + HttpComponentsFilteringDnsResolver dnsResolver = new HttpComponentsFilteringDnsResolver(filter); + + Registry factoryRegistry = RegistryBuilder.create() + .register("http", PlainConnectionSocketFactory.getSocketFactory()) + .register("https", SSLConnectionSocketFactory.getSocketFactory()) + .build(); + + CloseableHttpClient httpClient = HttpClientBuilder.create() + .setConnectionManager(new BasicHttpClientConnectionManager(factoryRegistry, null, null, dnsResolver)) + .build(); + + RestTemplate restTemplate = new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); + + // 4. Use the RestTemplate to make a request + System.out.println("Attempting to access http://google.com\n"); + + try { + ResponseEntity response = restTemplate.getForEntity("https://google.com", String.class); + System.out.println("FAILURE - Response: " + response.getBody()); + } catch (Exception e) { + System.err.println("SUCCESS - Access blocked: " + e.getMessage()); + } + } + + + static void main(String[] args) { + exampleApache(); + exampleNetty(); + exampleJetty(); + } + + public static void exampleJetty() { + System.out.println("\nExample Jetty - Jetty HttpClient with JettyHttpClientDnsResolver"); + + InetAddressFilter filter = InetAddressFilter.builder() + .denyList(List.of("192.168.1.100")) // Example: deny a specific private IP + .blockInternal() // Example: block all internal IPs (like 127.0.0.1) + .build(); + + JettyHttpClientFilteringDnsResolver dnsResolver = new JettyHttpClientFilteringDnsResolver(filter); + + // 3. Create and configure Jetty's HttpClient + HttpClient client = new HttpClient(); + client.setSocketAddressResolver(dnsResolver); + + try { + client.start(); // Jetty HttpClient must be started + + // 4. Use the HttpClient to make a request + // Example: trying to access a blocked internal address + System.out.println("\nAttempting to access http://localhost (should be blocked by blockAllInternal=true)\n"); + try { + org.eclipse.jetty.client.api.ContentResponse response = client.newRequest("http://localhost:8080").send(); // Jetty 11.x API + System.out.println("FAILURE: Response from http://localhost: " + response.getContentAsString()); + } catch (Exception e) { + // We expect an exception here, often a form of ConnectTimeoutException or similar + // because the resolution will yield no valid addresses if localhost is blocked. + // Jetty's behavior on no resolvable address might vary (e.g. timeout or specific exception). + System.err.println("SUCCESS: Access to http://localhost blocked or failed as expected: " + e.getClass().getName() + " - " + e.getMessage()); + } + + // Example: trying to access an external address (should be allowed if not in deny list and DNS resolves) + System.out.println("\nAttempting to access http://example.com (should be allowed)\n"); + try { + org.eclipse.jetty.client.api.ContentResponse response = client.newRequest("http://example.com").send(); // Jetty 11.x API + System.out.println("SUCCESS: Response from http://example.com: " + response.getContentAsString().substring(0, Math.min(response.getContentAsString().length(), 100)) + "..."); + } catch (Exception e) { + System.err.println("FAILURE: Access to http://example.com failed: " + e.getMessage()); + } + + client.stop(); // Stop the client + + } catch (Exception e) { + System.err.println("Error setting up or using Jetty HttpClient: " + e.getMessage()); + e.printStackTrace(); + } + } + + public static void exampleNetty() { + System.out.println("\nExample Netty - Reactor Netty HttpClient with NettyHttpClientAddressSelector\n"); + + // 1. Create SecurityDnsHandler with custom logic + InetAddressFilter filter = InetAddressFilter.builder() + .blockExternal() + .allowList(List.of("1.1.1.1", "8.8.4.4","google.com")) // Example: only allow specific public IPs + .build(); + + // 2. Create NettyHttpClientAddressSelector with the custom SecurityDnsHandler + NettyHttpClientFilteringAddressSelector selector = new NettyHttpClientFilteringAddressSelector(filter); + + // 3. Create a Reactor Netty HttpClient with the custom addressSelector + reactor.netty.http.client.HttpClient nettyHttpClient = reactor.netty.http.client.HttpClient.create() + .resolvedAddressesSelector(selector); + + // 4. Use the WebClient with the configured HttpClient to make a request + // The following WebClient setup requires spring-webflux dependency. + // Commenting out to allow compilation without it, to focus on the selector's role. + WebClient webClient = WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(nettyHttpClient)) + .build(); + + System.out.println("Attempting to access http://example.com (should be blocked as not in allowList)\n"); + try { + String response = webClient.get() + .uri("http://example.com") // example.com IP is likely not 1.1.1.1 or 8.8.8.8 + .retrieve() + .bodyToMono(String.class) + .block(); + System.out.println("FAILURE: Response from http://example.com: " + response.substring(0, Math.min(response.length(), 100)) + "..."); + } catch (Exception e) { + System.err.println("SUCCESS: Access to http://example.com blocked or failed as expected: " + e.getMessage()); + } + + System.out.println("\nAttempting to access http://1.1.1.1 (should be allowed if 1.1.1.1 is reachable)\n"); + try { + // Note: 1.1.1.1 might not be serving HTTP or could be firewalled. + // This is to demonstrate the selector allowing the attempt. + String response = webClient.get() + .uri("http://1.1.1.1") + .retrieve() + .bodyToMono(String.class) + .block(java.time.Duration.ofSeconds(5)); // Timeout for potentially unresponsive IP + System.out.println("SUCCESS: Response from http://1.1.1.1: " + response.substring(0, Math.min(response.length(), 100)) + "..."); + } catch (Exception e) { + System.err.println("FAILURE: Access to http://1.1.1.1 blocked or failed as expected: " + e.getMessage()); + } + } +} From c2d6809fedc1c1806c89209d0eb5416d540b8964 Mon Sep 17 00:00:00 2001 From: Kian Jamali Date: Thu, 15 Jan 2026 20:30:44 -0500 Subject: [PATCH 31/34] Backporting test for Netty and Apache resolvers (#15) * Backporting test for Netty and Apache resolvers * Update ssrf/src/main/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelector.java Co-authored-by: Rossen Stoyanchev --------- Co-authored-by: Rossen Stoyanchev --- ...ttyHttpClientFilteringAddressSelector.java | 2 +- ...ttpComponentsFilteringDnsResolverTest.java | 114 +++++++++++ ...ttpClientFilteringAddressSelectorTest.java | 183 ++++++++++++++++++ 3 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 ssrf/src/test/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolverTest.java create mode 100644 ssrf/src/test/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelectorTest.java diff --git a/ssrf/src/main/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelector.java b/ssrf/src/main/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelector.java index b2b96efa1a5..8483ea0abe3 100644 --- a/ssrf/src/main/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelector.java +++ b/ssrf/src/main/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelector.java @@ -76,7 +76,7 @@ private List filterInternal(List socketA .toList(); return socketAddresses.stream() - .filter(sa -> !(sa instanceof InetSocketAddress) || filteredIn.contains(sa)) + .filter(sa -> !(sa instanceof InetSocketAddress isa) || filteredIn.contains(isa.getAddress())) .collect(Collectors.toList()); } diff --git a/ssrf/src/test/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolverTest.java b/ssrf/src/test/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolverTest.java new file mode 100644 index 00000000000..cb088257477 --- /dev/null +++ b/ssrf/src/test/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolverTest.java @@ -0,0 +1,114 @@ +package org.springframework.boot.http.client; + +import org.apache.hc.client5.http.DnsResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.web.util.matcher.InetAddressFilter; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HttpComponentsFilteringDnsResolverTest { + + @Mock + private DnsResolver delegateDnsResolver; + + @Mock + private InetAddressFilter inetAddressFilter; + + private HttpComponentsFilteringDnsResolver dnsResolver; + + @BeforeEach + void setUp() { + dnsResolver = new HttpComponentsFilteringDnsResolver(this.delegateDnsResolver, this.inetAddressFilter); + } + + @Test + void resolve_whenDelegateSucceeds_shouldApplyFilterAndReturnFilteredAddresses() throws UnknownHostException { + String host = "example.com"; + InetAddress address1 = InetAddress.getByName("192.168.1.1"); // Allowed + InetAddress address2 = InetAddress.getByName("10.0.0.1"); // Disallowed + InetAddress address3 = InetAddress.getByName("172.16.0.1"); // Allowed + + InetAddress[] resolvedAddresses = new InetAddress[]{address1, address2, address3}; + List expectedFilteredAddresses = Arrays.asList(address1, address3); + + when(this.delegateDnsResolver.resolve(host)).thenReturn(resolvedAddresses); + when(this.inetAddressFilter.filter(address1)).thenReturn(true); + when(this.inetAddressFilter.filter(address2)).thenReturn(false); + when(this.inetAddressFilter.filter(address3)).thenReturn(true); + + InetAddress[] result = this.dnsResolver.resolve(host); + + assertArrayEquals(expectedFilteredAddresses.toArray(), result); + verify(this.delegateDnsResolver, times(1)).resolve(host); + verify(this.inetAddressFilter, times(1)).filter(address1); + verify(this.inetAddressFilter, times(1)).filter(address2); + verify(this.inetAddressFilter, times(1)).filter(address3); + } + + @Test + void resolve_whenDelegateFails_shouldPropagateFailure() throws UnknownHostException { + String host = "unknown.host"; + when(this.delegateDnsResolver.resolve(host)).thenThrow(new UnknownHostException("Host not found")); + + assertThrows(UnknownHostException.class, () -> this.dnsResolver.resolve(host)); + verify(this.inetAddressFilter, times(0)).filter(any()); // Filter should not be called + } + + @Test + void resolve_whenNoAddressesAreAllowed_shouldReturnEmptyArray() throws UnknownHostException { + String host = "example.com"; + InetAddress address1 = InetAddress.getByName("10.0.0.1"); + InetAddress address2 = InetAddress.getByName("10.0.0.2"); + + InetAddress[] resolvedAddresses = new InetAddress[]{address1, address2}; + + when(this.delegateDnsResolver.resolve(host)).thenReturn(resolvedAddresses); + when(this.inetAddressFilter.filter(address1)).thenReturn(false); + when(this.inetAddressFilter.filter(address2)).thenReturn(false); + + InetAddress[] result = this.dnsResolver.resolve(host); + + assertEquals(0, result.length); + verify(this.delegateDnsResolver, times(1)).resolve(host); + verify(this.inetAddressFilter, times(1)).filter(address1); + verify(this.inetAddressFilter, times(1)).filter(address2); + } + + @Test + void resolveCanonicalHostname_shouldDelegateWithoutFiltering() throws UnknownHostException { + String host = "example.com"; + String canonicalHost = "canonical.example.com"; + when(this.delegateDnsResolver.resolveCanonicalHostname(host)).thenReturn(canonicalHost); + + String result = this.dnsResolver.resolveCanonicalHostname(host); + + assertEquals(canonicalHost, result); + verify(this.delegateDnsResolver, times(1)).resolveCanonicalHostname(host); + verify(this.inetAddressFilter, times(0)).filter(any()); // Filter should not be called + } + + @Test + void resolveCanonicalHostname_whenDelegateFails_shouldPropagateFailure() throws UnknownHostException { + String host = "unknown.host"; + when(this.delegateDnsResolver.resolveCanonicalHostname(host)).thenThrow(new UnknownHostException("Host not found")); + + assertThrows(UnknownHostException.class, () -> this.dnsResolver.resolveCanonicalHostname(host)); + verify(this.inetAddressFilter, times(0)).filter(any()); // Filter should not be called + } + +} diff --git a/ssrf/src/test/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelectorTest.java b/ssrf/src/test/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelectorTest.java new file mode 100644 index 00000000000..3e388f65a38 --- /dev/null +++ b/ssrf/src/test/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelectorTest.java @@ -0,0 +1,183 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.http.client; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.web.util.matcher.InetAddressFilter; +import reactor.netty.transport.ClientTransportConfig; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.List; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NettyHttpClientFilteringAddressSelectorTest { + + @Mock + private ClientTransportConfig mockConfig; + + @Mock + private InetAddressFilter inetAddressFilter; + + private NettyHttpClientFilteringAddressSelector selector; + + @BeforeEach + void setUp() { + selector = new NettyHttpClientFilteringAddressSelector(inetAddressFilter); + } + + private InetSocketAddress createInetSocketAddress(String hostname, int port) { + try { + return new InetSocketAddress(InetAddress.getByName(hostname), port); + } + catch (UnknownHostException e) { + throw new RuntimeException(e); + } + } + + @Test + void selectWhenEmptyListShouldReturnEmptyList() { + List result = selector.select(mockConfig, Collections::emptyList); + assertTrue(result.isEmpty()); + verify(this.inetAddressFilter, never()).filter(any()); + } + + @Test + void selectWhenNullListShouldReturnNull() { + List result = selector.select(mockConfig, () -> null); + assertNull(result); + verify(this.inetAddressFilter, never()).filter(any()); + } + + @Test + void selectWhenAllAddressesAreAllowed() { + InetSocketAddress address1 = createInetSocketAddress("1.1.1.1", 80); + InetSocketAddress address2 = createInetSocketAddress("8.8.8.8", 80); + List addresses = List.of(address1, address2); + + when(this.inetAddressFilter.filter(address1.getAddress())).thenReturn(true); + when(this.inetAddressFilter.filter(address2.getAddress())).thenReturn(true); + + List result = selector.select(mockConfig, () -> addresses); + List resultStrings = result.stream() + .map(sa -> ((InetSocketAddress) sa).getAddress().getHostAddress() + ":" + ((InetSocketAddress) sa).getPort()) + .collect(java.util.stream.Collectors.toList()); + List expectedStrings = addresses.stream() + .map(sa -> ((InetSocketAddress) sa).getAddress().getHostAddress() + ":" + ((InetSocketAddress) sa).getPort()) + .collect(java.util.stream.Collectors.toList()); + assertEquals(expectedStrings, resultStrings); + verify(this.inetAddressFilter, times(1)).filter(address1.getAddress()); + verify(this.inetAddressFilter, times(1)).filter(address2.getAddress()); + } + + @Test + void selectWhenSomeAddressesAreDenied() { + InetSocketAddress allowedAddress = createInetSocketAddress("1.1.1.1", 80); + InetSocketAddress deniedAddress = createInetSocketAddress("8.8.8.8", 80); + InetSocketAddress anotherAllowedAddress = createInetSocketAddress("1.0.0.1", 80); + List addresses = List.of(allowedAddress, deniedAddress, anotherAllowedAddress); + + when(this.inetAddressFilter.filter(allowedAddress.getAddress())).thenReturn(true); + when(this.inetAddressFilter.filter(deniedAddress.getAddress())).thenReturn(false); + when(this.inetAddressFilter.filter(anotherAllowedAddress.getAddress())).thenReturn(true); + + List result = selector.select(mockConfig, () -> addresses); + List resultStrings = result.stream() + .map(sa -> ((InetSocketAddress) sa).getAddress().getHostAddress() + ":" + ((InetSocketAddress) sa).getPort()) + .collect(java.util.stream.Collectors.toList()); + List expectedStrings = List.of(allowedAddress, anotherAllowedAddress).stream() + .map(sa -> ((InetSocketAddress) sa).getAddress().getHostAddress() + ":" + ((InetSocketAddress) sa).getPort()) + .collect(java.util.stream.Collectors.toList()); + assertEquals(expectedStrings, resultStrings); + verify(this.inetAddressFilter, times(1)).filter(allowedAddress.getAddress()); + verify(this.inetAddressFilter, times(1)).filter(deniedAddress.getAddress()); + verify(this.inetAddressFilter, times(1)).filter(anotherAllowedAddress.getAddress()); + } + + @Test + void selectWhenAllAddressesAreDenied() { + InetSocketAddress deniedAddress1 = createInetSocketAddress("1.1.1.1", 80); + InetSocketAddress deniedAddress2 = createInetSocketAddress("8.8.8.8", 80); + List addresses = List.of(deniedAddress1, deniedAddress2); + + when(this.inetAddressFilter.filter(deniedAddress1.getAddress())).thenReturn(false); + when(this.inetAddressFilter.filter(deniedAddress2.getAddress())).thenReturn(false); + + List result = selector.select(mockConfig, () -> addresses); + List resultStrings = result.stream() + .map(sa -> ((InetSocketAddress) sa).getAddress().getHostAddress() + ":" + ((InetSocketAddress) sa).getPort()) + .collect(java.util.stream.Collectors.toList()); + List expectedStrings = Collections.emptyList(); // All denied, so expected is empty + assertEquals(expectedStrings, resultStrings); + verify(this.inetAddressFilter, times(1)).filter(deniedAddress1.getAddress()); + verify(this.inetAddressFilter, times(1)).filter(deniedAddress2.getAddress()); + } + + + @Test + void selectWithNonInetSocketAddressShouldReturnOriginalIfFilterPasses() { + SocketAddress nonInetAddress = mock(SocketAddress.class); + InetSocketAddress allowedAddress = createInetSocketAddress("1.1.1.1", 80); + List addresses = List.of(nonInetAddress, allowedAddress); + + when(this.inetAddressFilter.filter(allowedAddress.getAddress())).thenReturn(true); + + List result = selector.select(mockConfig, () -> addresses); + + assertEquals(2, result.size()); + assertTrue(result.contains(nonInetAddress)); + assertTrue(result.contains(allowedAddress)); + verify(this.inetAddressFilter, times(1)).filter(allowedAddress.getAddress()); + } + + @Test + void selectWithMixedAddressTypesAndFiltering() { + SocketAddress nonInetAddress = mock(SocketAddress.class); + InetSocketAddress allowedAddress = createInetSocketAddress("1.1.1.1", 80); + InetSocketAddress deniedAddress = createInetSocketAddress("8.8.8.8", 80); + + List addresses = List.of(allowedAddress, deniedAddress, nonInetAddress); + + when(this.inetAddressFilter.filter(allowedAddress.getAddress())).thenReturn(true); + when(this.inetAddressFilter.filter(deniedAddress.getAddress())).thenReturn(false); + + List result = selector.select(mockConfig, () -> addresses); + + assertEquals(2, result.size()); + assertTrue(result.contains(nonInetAddress)); + assertTrue(result.contains(allowedAddress)); + verify(this.inetAddressFilter, times(1)).filter(allowedAddress.getAddress()); + verify(this.inetAddressFilter, times(1)).filter(deniedAddress.getAddress()); + } + +} From 49de988e39c6f2c7259af5726f75bc4a1375bfaa Mon Sep 17 00:00:00 2001 From: Kian Jamali Date: Fri, 16 Jan 2026 12:54:38 -0500 Subject: [PATCH 32/34] Remove DNS filtering components and related tests (#17) - Deleted InetAddressFilter interface and its implementation in SecurityDnsHandler. - Removed package-info.java files for client and client.dns packages. - Eliminated all test cases related to DNS filtering, including NettyHttpClientAddressSelectorTests, HttpComponentsDnsResolverTest, JettyHttpClientDnsResolverTest, and SecurityDnsHandlerTest. - Removed example usage code demonstrating DNS filtering with various HTTP clients. --- .../client/HttpComponentsDnsResolver.java | 56 ---- .../client/JettyHttpClientDnsResolver.java | 68 ----- .../NettyHttpClientAddressSelector.java | 72 ----- .../client/dns/DefaultInetAddressFilter.java | 252 ------------------ .../java/client/dns/InetAddressFilter.java | 31 --- .../java/client/dns/SecurityDnsHandler.java | 181 ------------- .../main/java/client/dns/package-info.java | 4 - ssrf/src/main/java/client/package-info.java | 4 - .../NettyHttpClientAddressSelectorTests.java | 203 -------------- .../client/HttpComponentsDnsResolverTest.java | 122 --------- .../JettyHttpClientDnsResolverTest.java | 120 --------- .../web/client/SecurityDnsHandlerTest.java | 229 ---------------- .../security/web/client/UsageExample.java | 166 ------------ 13 files changed, 1508 deletions(-) delete mode 100644 ssrf/src/main/java/client/HttpComponentsDnsResolver.java delete mode 100644 ssrf/src/main/java/client/JettyHttpClientDnsResolver.java delete mode 100644 ssrf/src/main/java/client/NettyHttpClientAddressSelector.java delete mode 100644 ssrf/src/main/java/client/dns/DefaultInetAddressFilter.java delete mode 100644 ssrf/src/main/java/client/dns/InetAddressFilter.java delete mode 100644 ssrf/src/main/java/client/dns/SecurityDnsHandler.java delete mode 100644 ssrf/src/main/java/client/dns/package-info.java delete mode 100644 ssrf/src/main/java/client/package-info.java delete mode 100644 ssrf/src/test/java/client/NettyHttpClientAddressSelectorTests.java delete mode 100644 ssrf/src/test/java/com/google/springframework/security/web/client/HttpComponentsDnsResolverTest.java delete mode 100644 ssrf/src/test/java/com/google/springframework/security/web/client/JettyHttpClientDnsResolverTest.java delete mode 100644 ssrf/src/test/java/com/google/springframework/security/web/client/SecurityDnsHandlerTest.java delete mode 100644 ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java diff --git a/ssrf/src/main/java/client/HttpComponentsDnsResolver.java b/ssrf/src/main/java/client/HttpComponentsDnsResolver.java deleted file mode 100644 index 89dfec2d765..00000000000 --- a/ssrf/src/main/java/client/HttpComponentsDnsResolver.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2002-2025 the original author or authors. - * - * Licensed 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 - * - * https://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 client; - -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.util.Arrays; -import java.util.List; - -import client.dns.SecurityDnsHandler; -import org.apache.hc.client5.http.DnsResolver; -import org.apache.hc.client5.http.SystemDefaultDnsResolver; - -public class HttpComponentsDnsResolver implements DnsResolver { - - private final DnsResolver delegate; - - private final SecurityDnsHandler securityDnsHandler; - - - public HttpComponentsDnsResolver(DnsResolver delegate, SecurityDnsHandler securityDnsHandler) { - this.delegate = delegate; - this.securityDnsHandler = securityDnsHandler; - } - - public HttpComponentsDnsResolver(SecurityDnsHandler securityDnsHandler) { - this(SystemDefaultDnsResolver.INSTANCE, securityDnsHandler); - } - - - @Override - public InetAddress[] resolve(String host) throws UnknownHostException { - InetAddress[] addresses = this.delegate.resolve(host); - List inetAddresses = this.securityDnsHandler.handleAddresses(Arrays.asList(addresses)); - return inetAddresses.toArray(new InetAddress[0]); - } - - @Override - public String resolveCanonicalHostname(String host) throws UnknownHostException { - return this.delegate.resolveCanonicalHostname(host); - } -} diff --git a/ssrf/src/main/java/client/JettyHttpClientDnsResolver.java b/ssrf/src/main/java/client/JettyHttpClientDnsResolver.java deleted file mode 100644 index cf403506daa..00000000000 --- a/ssrf/src/main/java/client/JettyHttpClientDnsResolver.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2002-2025 the original author or authors. - * - * Licensed 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 - * - * https://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 client; - - -import java.net.InetSocketAddress; -import java.util.List; - -import client.dns.SecurityDnsHandler; -import org.eclipse.jetty.util.Promise; -import org.eclipse.jetty.util.SocketAddressResolver; - -public class JettyHttpClientDnsResolver implements SocketAddressResolver { - - private final SocketAddressResolver delegate; - - private final SecurityDnsHandler securityDnsHandler; - - - public JettyHttpClientDnsResolver(SocketAddressResolver delegate, SecurityDnsHandler securityDnsHandler) { - this.delegate = delegate; - this.securityDnsHandler = securityDnsHandler; - } - - /** - * Creates a new instance using Jetty's default - * as the delegate resolver. - * @param securityDnsHandler The handler to apply security rules to the resolved addresses. - */ - public JettyHttpClientDnsResolver(SecurityDnsHandler securityDnsHandler) { - // Call the primary constructor, using the fully qualified name for the nested static class - SocketAddressResolver resolver = new SocketAddressResolver.Sync(); - this.delegate = resolver; - this.securityDnsHandler = securityDnsHandler; - } - - - @Override - public void resolve(String host, int port, Promise> outerPromise) { - this.delegate.resolve(host, port, new Promise<>() { - - @Override - public void succeeded(List candidates) { - outerPromise.succeeded(securityDnsHandler.handleInetSocketAddresses(candidates, port)); - } - - @Override - public void failed(Throwable ex) { - outerPromise.failed(ex); - } - }); - } - -} diff --git a/ssrf/src/main/java/client/NettyHttpClientAddressSelector.java b/ssrf/src/main/java/client/NettyHttpClientAddressSelector.java deleted file mode 100644 index df7d94cafaa..00000000000 --- a/ssrf/src/main/java/client/NettyHttpClientAddressSelector.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2002-2025 the original author or authors. - * - * Licensed 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 - * - * https://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 client; - -import client.dns.SecurityDnsHandler; -// import io.projectreactor.netty.transport.ClientTransportConfig; -// import io.projectreactor.netty.transport.ResolvedAddressSelector; -// import reactor.netty.transport.ResolvedAddressSelector; - -import reactor.netty.transport.ClientTransport.ResolvedAddressSelector; -import reactor.netty.transport.ClientTransportConfig; - -import java.net.SocketAddress; -import java.util.List; -import java.util.Objects; -import java.util.function.Supplier; - -import org.jspecify.annotations.Nullable; - -/** - * A {@link ResolvedAddressSelector} that uses a {@link SecurityDnsHandler} to filter a list of - * resolved {@link SocketAddress}es for a Reactor Netty client. - * - * @see reactor.netty.http.client.HttpClient#resolvedAddressesSelector(ResolvedAddressSelector) - */ -public class NettyHttpClientAddressSelector implements ResolvedAddressSelector> { - - private final SecurityDnsHandler securityDnsHandler; - - /** - * Creates a new instance. - * @param securityDnsHandler The handler to apply security rules to the resolved addresses. - */ - public NettyHttpClientAddressSelector(SecurityDnsHandler securityDnsHandler) { - Objects.requireNonNull(securityDnsHandler, "securityDnsHandler must not be null"); - this.securityDnsHandler = securityDnsHandler; - } - - public List select(ClientTransportConfig clientTransportConfig, - Supplier> addresses) { - - List socketAddresses = addresses.get(); - if (socketAddresses == null || socketAddresses.isEmpty()) { - return socketAddresses; - } - - return this.securityDnsHandler.handleSocketAddresses(socketAddresses); - } - - @Override - public @Nullable List apply(ClientTransportConfig config, - List resolvedAddresses) { - if (resolvedAddresses == null || resolvedAddresses.isEmpty()) { - return resolvedAddresses; - } - return this.securityDnsHandler.handleSocketAddresses(resolvedAddresses); - } -} diff --git a/ssrf/src/main/java/client/dns/DefaultInetAddressFilter.java b/ssrf/src/main/java/client/dns/DefaultInetAddressFilter.java deleted file mode 100644 index 345e8659137..00000000000 --- a/ssrf/src/main/java/client/dns/DefaultInetAddressFilter.java +++ /dev/null @@ -1,252 +0,0 @@ -/* - * Copyright 2002-2025 the original author or authors. - * - * Licensed 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 - * - * https://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 client.dns; - -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.jspecify.annotations.Nullable; - - -public final class DefaultInetAddressFilter implements InetAddressFilter { - - private final List allowList; - - private final List denyList; - - private final @Nullable BlockMode blockMode; - - private final List customFilters; - - - public DefaultInetAddressFilter( - List allowList, List denyList, boolean blockExternal, boolean blockInternal, - List customFilters) { - - if (!allowList.isEmpty() && !denyList.isEmpty()) { - throw new IllegalArgumentException( - "Logic inconsistency: allowList and denyList cannot be used at the same time"); - } - - this.allowList = initIpList(allowList); - this.denyList = initIpList(denyList); - this.blockMode = BlockMode.from(blockExternal, blockInternal); - this.customFilters = new ArrayList<>(customFilters); - } - - private static List initIpList(List ipList) { - return ipList.stream().map(IpAddressMatcher::new).collect(Collectors.toList()); - } - - - @Override - public boolean filterAddress(InetAddress address) { - // A block is final. Check all block conditions first. - - // 1. Block mode - if (!passBlockMode(address)) { - return true; // Block - } - - // 2. Deny list - if (!passDenyList(address)) { - return true; // Block - } - - // 3. Custom filters - for (InetAddressFilter filter : this.customFilters) { - if (filter.filterAddress(address)) { // true from custom filter means block - return true; // Block - } - } - - // If an allow list is configured, the address MUST be on it to be allowed. - // This check is done after block rules, so block rules take precedence. - if (!this.allowList.isEmpty()) { - return !passAllowList(address); // Block if it doesn't pass the allow list - } - - // If we reach here, no rules have blocked the address. - return false; // Allow - } - - private boolean passBlockMode(InetAddress address) { - if (this.blockMode != null) { - if (this.blockMode == BlockMode.EXTERNAL) { - return isInternalIp(address); - } - if (this.blockMode == BlockMode.INTERNAL) { - return !isInternalIp(address); - } - } - return true; - } - - private boolean passAllowList(InetAddress address) { - if (this.allowList.isEmpty()) { - return true; - } - for (IpAddressMatcher ipAddressMatcher : this.allowList) { - if (ipAddressMatcher.matches(address)) { - return true; - } - } - return false; - } - - private boolean passDenyList(InetAddress address) { - for (IpAddressMatcher ipAddressMatcher : this.denyList) { - if (ipAddressMatcher.matches(address)) { - return false; - } - } - return true; - } - - private static boolean isInternalIp(InetAddress addr) { - - if (addr.isLoopbackAddress()) { - return true; - } - - byte[] rawAddress = addr.getAddress(); - - // there is sadly no Stream support for byte arrays - int[] iAddr = new int[rawAddress.length]; - for (int i = 0; i < rawAddress.length; i++) { - iAddr[i] = Byte.toUnsignedInt(rawAddress[i]); - } - - // Ignoring Multicast addresses - if (addr.getAddress().length == 4) { - // IPv4 filtering - // 10.x.x.x , 192.168.x.x , 172.16.x.x - if (iAddr[0] == 10 || - (iAddr[0] == 192 && iAddr[1] == 168) || - (iAddr[0] == 172 && iAddr[1] == 16)) { - return true; - } - - } else if (addr.getAddress().length == 16) { - // IPv6, check for Unique Local Addresses - if (iAddr[0] == 0xfc || iAddr[0] == 0xfd) { - return true; - } - - // IPv4/IPv6 translation, 64:ff9b - if (iAddr[0] == 0x00 && iAddr[1] == 0x64 && iAddr[2] == 0xff && iAddr[3] == 0x9b) { - int[] ipv4Part = new int[]{iAddr[12], iAddr[13], iAddr[14], iAddr[15]}; - // same check as above plus a check for loopback - if (ipv4Part[0] == 10 || ipv4Part[0] == 127 || - (ipv4Part[0] == 192 && ipv4Part[1] == 168) || - (ipv4Part[0] == 172 && ipv4Part[1] == 16)) { - return true; - } - } - } - return false; - } - - - private enum BlockMode { - - /** - * Allow request to the local network only. - */ - EXTERNAL, - - /** - * Allow requests towards the internet only, e.g. prevent access to cloud VM metadata. - */ - INTERNAL; - - - public static @Nullable BlockMode from(boolean blockExternal, boolean blockInternal) { - return (blockExternal ? EXTERNAL : (blockInternal ? INTERNAL : null)); - } - } - - - /** - * Class to represent and IPv4 or IPv6 range to be used in filtering. Inspired by: - * org.springframework.security.web.util.matcher.IpAddressMatcher.java - */ - private static final class IpAddressMatcher { - - private static final Log logger = LogFactory.getLog(IpAddressMatcher.class); - private final InetAddress address; - private final int nMaskBits; - - public IpAddressMatcher(String addressOrRange) { - if (addressOrRange.indexOf('/') > 0) { - String[] addressAndMask = addressOrRange.split("/"); - address = parseAddress(addressAndMask[0]); - this.nMaskBits = Integer.parseInt(addressAndMask[1]); - } else { - this.nMaskBits = -1; - address = parseAddress(addressOrRange); - } - } - - public boolean matches(InetAddress toCheck) { - - if (this.nMaskBits < 0) { - return toCheck.equals(this.address); - } - byte[] remAddr = toCheck.getAddress(); - byte[] reqAddr = this.address.getAddress(); - int nMaskFullBytes = this.nMaskBits / 8; - byte finalByte = (byte) (0xFF00 >> (this.nMaskBits & 0x07)); - for (int i = 0; i < nMaskFullBytes; i++) { - if (remAddr[i] != reqAddr[i]) { - return false; - } - } - if (finalByte != 0) { - return (remAddr[nMaskFullBytes] & finalByte) == (reqAddr[nMaskFullBytes] & finalByte); - } - return true; - - } - - private InetAddress parseAddress(String address) { - try { - InetAddress result = InetAddress.getByName(address); - if (address.matches(".*[a-zA-Z\\-].*$") && !address.contains(":")) { - logger.warn("Hostname '" + address + "' resolved to " + result.toString() - + " will be used on IP address matching"); - } - return result; - } catch (UnknownHostException ex) { - throw new IllegalArgumentException(String.format("Failed to parse address '%s'", address), ex); - } - } - - @Override - public String toString() { - return "IpAddressMatcher{" + - "address=" + address + - ", nMaskBits=" + nMaskBits + - '}'; - } - } -} diff --git a/ssrf/src/main/java/client/dns/InetAddressFilter.java b/ssrf/src/main/java/client/dns/InetAddressFilter.java deleted file mode 100644 index 24a8dcc616e..00000000000 --- a/ssrf/src/main/java/client/dns/InetAddressFilter.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2002-2025 the original author or authors. - * - * Licensed 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 - * - * https://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 client.dns; - -import java.net.InetAddress; - -/** - * Filter to decide if an {@link InetAddress} can be used. - */ -public interface InetAddressFilter { - - /** - * Return {@code true} if the address should be filtered out, and {@code false} if - * it should be used. - */ - boolean filterAddress(InetAddress address); - -} diff --git a/ssrf/src/main/java/client/dns/SecurityDnsHandler.java b/ssrf/src/main/java/client/dns/SecurityDnsHandler.java deleted file mode 100644 index d68bc4c578d..00000000000 --- a/ssrf/src/main/java/client/dns/SecurityDnsHandler.java +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright 2002-2025 the original author or authors. - * - * Licensed 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 - * - * https://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 client.dns; - -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.SocketAddress; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - -/** - * Component to filter addresses according configured security rules. - * For use from an HTTP client's DNS resolver. - * - *

Use {@link #builder()} to create an instance. - */ -public final class SecurityDnsHandler { - - private static final Log logger = LogFactory.getLog(SecurityDnsHandler.class); - - - private final InetAddressFilter inetAddressFilter; - - private final boolean reportOnly; - - - public SecurityDnsHandler(InetAddressFilter filter, boolean reportOnly) { - this.inetAddressFilter = filter; - this.reportOnly = reportOnly; - } - - public boolean getReportMode() { - return this.reportOnly; - } - - - public List handleAddresses(List candidateAddresses) { - List blocked = null; - for (InetAddress address : candidateAddresses) { - if (this.inetAddressFilter.filterAddress(address)) { - blocked = (blocked != null) ? blocked : new ArrayList<>(); - blocked.add(address); - } - } - - if (blocked == null) { - return candidateAddresses; - } - - if (logger.isErrorEnabled()) { - logger.error("Blocked IP addresses: " + candidateAddresses); - } - - - if (this.reportOnly) { - return candidateAddresses; - } - - ArrayList result = new ArrayList<>(candidateAddresses); - result.removeAll(blocked); - return result; - } - - public List handleInetSocketAddresses(List candidates) { - if (candidates == null || candidates.isEmpty()) { - return candidates; - } - List input = candidates.stream().map(InetSocketAddress::getAddress).distinct().toList(); - List output = handleAddresses(input); - // Use the original port for each address - return candidates.stream() - .filter(isa -> output.contains(isa.getAddress())) - .toList(); - } - - public List handleInetSocketAddresses(List candidates, int port) { - List input = candidates.stream().map(InetSocketAddress::getAddress).distinct().toList(); - List output = handleAddresses(input); - return output.stream().map(address -> new InetSocketAddress(address, port)).toList(); - } - - public List handleSocketAddresses(List candidates) { - if (candidates == null || candidates.isEmpty()) { - return new ArrayList<>(candidates); - } - // Extract InetSocketAddress instances - List inetCandidates = candidates.stream() - .filter(InetSocketAddress.class::isInstance) - .map(InetSocketAddress.class::cast) - .toList(); - - List filteredInet = handleInetSocketAddresses(inetCandidates); - - // Only keep those InetSocketAddress that passed the filter, and preserve order - return new ArrayList( - candidates.stream() - .filter(sa -> !(sa instanceof InetSocketAddress) || filteredInet.contains(sa)) - .toList() - ); - } - - - public static Builder builder() { - return new Builder(); - } - - - public static class Builder { - - private final List allowList = new ArrayList<>(); - - private final List denyList = new ArrayList<>(); - - private boolean blockAllExternal; - - private boolean blockAllInternal; - - private final List customFilters = new ArrayList<>(); - - private boolean reportOnly; - - - public Builder blockAllExternal(boolean block) { - this.blockAllExternal = block; - return this; - } - - public Builder blockAllInternal(boolean block) { - this.blockAllInternal = block; - return this; - } - - public Builder allowList(String... ipAddresses) { - this.allowList.addAll(Arrays.asList(ipAddresses)); - return this; - } - - public Builder denyList(String... ipAddresses) { - this.denyList.addAll(Arrays.asList(ipAddresses)); - return this; - } - - public Builder customFilter(InetAddressFilter filter) { - this.customFilters.add(filter); - return this; - } - - public Builder reportOnly(boolean report) { - this.reportOnly = report; - return this; - } - - public SecurityDnsHandler build() { - - DefaultInetAddressFilter filter = new DefaultInetAddressFilter( - this.allowList, this.denyList, this.blockAllExternal, this.blockAllInternal, this.customFilters); - - return new SecurityDnsHandler(filter, this.reportOnly); - } - - } - -} \ No newline at end of file diff --git a/ssrf/src/main/java/client/dns/package-info.java b/ssrf/src/main/java/client/dns/package-info.java deleted file mode 100644 index 684dc08b573..00000000000 --- a/ssrf/src/main/java/client/dns/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -@NullMarked -package client.dns; - -import org.jspecify.annotations.NullMarked; diff --git a/ssrf/src/main/java/client/package-info.java b/ssrf/src/main/java/client/package-info.java deleted file mode 100644 index 630f91689a3..00000000000 --- a/ssrf/src/main/java/client/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -@NullMarked -package client; - -import org.jspecify.annotations.NullMarked; diff --git a/ssrf/src/test/java/client/NettyHttpClientAddressSelectorTests.java b/ssrf/src/test/java/client/NettyHttpClientAddressSelectorTests.java deleted file mode 100644 index fab0b2f8927..00000000000 --- a/ssrf/src/test/java/client/NettyHttpClientAddressSelectorTests.java +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright 2002-2025 the original author or authors. - * - * Licensed 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 - * - * https://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 client; - -import client.dns.SecurityDnsHandler; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import reactor.netty.transport.ClientTransportConfig; - -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.SocketAddress; -import java.net.UnknownHostException; -import java.util.Collections; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; - -class NettyHttpClientAddressSelectorTests { - - private ClientTransportConfig mockConfig; - - @BeforeEach - void setUp() { - mockConfig = mock(ClientTransportConfig.class); - } - - private InetSocketAddress createInetSocketAddress(String hostname, int port) { - try { - return new InetSocketAddress(InetAddress.getByName(hostname), port); - } catch (UnknownHostException e) { - throw new RuntimeException(e); - } - } - - @Test - void selectWhenEmptyListShouldReturnEmptyList() { - SecurityDnsHandler handler = SecurityDnsHandler.builder().build(); - NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); - List result = selector.select(mockConfig, Collections::emptyList); - assertTrue(result.isEmpty()); - } - - @Test - void selectWhenNullListShouldReturnNull() { - SecurityDnsHandler handler = SecurityDnsHandler.builder().build(); - NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); - List result = selector.select(mockConfig, () -> null); - assertNull(result); - } - - @Test - void selectWithNoFilterShouldReturnOriginalList() { - SecurityDnsHandler handler = SecurityDnsHandler.builder().build(); - NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); - List addresses = List.of( - createInetSocketAddress("1.1.1.1", 80), - createInetSocketAddress("8.8.8.8", 80) - ); - List result = selector.select(mockConfig, () -> addresses); - assertEquals(addresses, result); - } - - @Test - void selectWithAllowListShouldFilter() { - SecurityDnsHandler handler = SecurityDnsHandler.builder().allowList("1.1.1.1").build(); - NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); - List addresses = List.of( - createInetSocketAddress("1.1.1.1", 80), - createInetSocketAddress("8.8.8.8", 80), - createInetSocketAddress("1.0.0.1", 80) - ); - List result = selector.select(mockConfig, () -> addresses); - assertEquals(List.of(createInetSocketAddress("1.1.1.1", 80)), result); - } - - @Test - void selectWithDenyListShouldFilter() { - SecurityDnsHandler handler = SecurityDnsHandler.builder().denyList("8.8.8.8").build(); - NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); - List addresses = List.of( - createInetSocketAddress("1.1.1.1", 80), - createInetSocketAddress("8.8.8.8", 80), - createInetSocketAddress("1.0.0.1", 80) - ); - List result = selector.select(mockConfig, () -> addresses); - assertEquals(List.of( - createInetSocketAddress("1.1.1.1", 80), - createInetSocketAddress("1.0.0.1", 80) - ), result); - } - - @Test - void selectWithBlockExternalShouldFilter() throws UnknownHostException { - // 10.0.0.1 is internal, 8.8.8.8 is external - SecurityDnsHandler handler = SecurityDnsHandler.builder().blockAllExternal(true).build(); - NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); - List addresses = List.of( - createInetSocketAddress("10.0.0.1", 80), - createInetSocketAddress("8.8.8.8", 80) - ); - List result = selector.select(mockConfig, () -> addresses); - assertEquals(List.of(createInetSocketAddress("10.0.0.1", 80)), result); - } - - @Test - void selectWithBlockInternalShouldFilter() throws UnknownHostException { - // 10.0.0.1 is internal, 8.8.8.8 is external - SecurityDnsHandler handler = SecurityDnsHandler.builder().blockAllInternal(true).build(); - NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); - List addresses = List.of( - createInetSocketAddress("10.0.0.1", 80), - createInetSocketAddress("8.8.8.8", 80) - ); - List result = selector.select(mockConfig, () -> addresses); - assertEquals(List.of(createInetSocketAddress("8.8.8.8", 80)), result); - } - - @Test - void selectWithReportOnlyModeShouldNotFilterButLog() { - // For logging, we can't directly assert logs here without more complex setup. - // We'll trust SecurityDnsHandler's tests for logging and just check that reportOnly doesn't filter. - SecurityDnsHandler handler = SecurityDnsHandler.builder().denyList("8.8.8.8").reportOnly(true).build(); - NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); - List addresses = List.of( - createInetSocketAddress("1.1.1.1", 80), - createInetSocketAddress("8.8.8.8", 80) - ); - List result = selector.select(mockConfig, () -> addresses); - assertEquals(addresses, result); - } - - @Test - void selectWithDifferentPortsShouldUseFirstPort() { - // This test highlights the behavior of using the port from the first address. - SecurityDnsHandler handler = SecurityDnsHandler.builder().allowList("1.1.1.1").build(); - NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); - List addresses = List.of( - createInetSocketAddress("1.1.1.1", 80), - createInetSocketAddress("1.1.1.1", 443), // Same IP, different port - createInetSocketAddress("8.8.8.8", 80) - ); - // SecurityDnsHandler.handleInetSocketAddresses uses the port passed to it, - // which our selector derives from the first element. - List result = selector.select(mockConfig, () -> addresses); - assertEquals(List.of( - createInetSocketAddress("1.1.1.1", 80), - createInetSocketAddress("1.1.1.1", 443) - ), result); - } - - @Test - void selectWithNonInetSocketAddressShouldReturnOriginalList() { - SecurityDnsHandler handler = SecurityDnsHandler.builder().build(); - NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); - - // Create a mock SocketAddress that is not an InetSocketAddress - SocketAddress nonInetAddress = mock(SocketAddress.class); - - List addresses = List.of(nonInetAddress); - - List result = selector.select(mockConfig, () -> addresses); - - // Expect the original list to be returned as per the implementation logic - assertEquals(addresses, result); - } - - @Test - void selectWithMixedAddressTypesShouldFilterInetSocketAddressesOnly() { - SecurityDnsHandler handler = SecurityDnsHandler.builder().denyList("8.8.8.8").build(); - NettyHttpClientAddressSelector selector = new NettyHttpClientAddressSelector(handler); - - SocketAddress nonInetAddress = mock(SocketAddress.class); - InetSocketAddress allowedAddress = createInetSocketAddress("1.1.1.1", 80); - InetSocketAddress deniedAddress = createInetSocketAddress("8.8.8.8", 80); - - List addresses = List.of( - allowedAddress, - deniedAddress, - nonInetAddress - ); - - List result = selector.select(mockConfig, () -> addresses); - - // The current implementation keeps non-InetSocketAddress types in the result. - assertEquals(List.of(allowedAddress, nonInetAddress), result); - } -} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/HttpComponentsDnsResolverTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/HttpComponentsDnsResolverTest.java deleted file mode 100644 index 8f594690bfa..00000000000 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/HttpComponentsDnsResolverTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.google.springframework.security.web.client; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import client.dns.SecurityDnsHandler; -import client.HttpComponentsDnsResolver; -import org.apache.hc.client5.http.DnsResolver; -import org.junit.jupiter.api.Test; - -class HttpComponentsDnsResolverTests { - - @Test - void resolveDelegatesAndHandlesAddresses() throws UnknownHostException { - DnsResolver delegate = mock(DnsResolver.class); - SecurityDnsHandler securityDnsHandler = mock(SecurityDnsHandler.class); - HttpComponentsDnsResolver resolver = new HttpComponentsDnsResolver(delegate, securityDnsHandler); - - InetAddress address1 = InetAddress.getByName("192.168.1.1"); - InetAddress address2 = InetAddress.getByName("10.0.0.1"); - InetAddress[] resolvedAddresses = new InetAddress[]{address1, address2}; - when(delegate.resolve("example.com")).thenReturn(resolvedAddresses); - - InetAddress filteredAddress = InetAddress.getByName("127.0.0.1"); - List handledAddresses = Collections.singletonList(filteredAddress); - when(securityDnsHandler.handleAddresses(Arrays.asList(resolvedAddresses))).thenReturn(handledAddresses); - - InetAddress[] result = resolver.resolve("example.com"); - - assertEquals(1, result.length); - assertEquals(filteredAddress, result[0]); - verify(delegate, times(1)).resolve("example.com"); - verify(securityDnsHandler, times(1)).handleAddresses(Arrays.asList(resolvedAddresses)); - } - - @Test - void resolveUsesSystemDefaultDnsResolverAndHandlesAddresses() throws UnknownHostException { - SecurityDnsHandler securityDnsHandler = mock(SecurityDnsHandler.class); - HttpComponentsDnsResolver resolver = new HttpComponentsDnsResolver(securityDnsHandler); - - InetAddress resolvedAddress = InetAddress.getByName("8.8.8.8"); - // We can't directly mock SystemDefaultDnsResolver's resolve method, - // so we'll rely on its actual behavior for this test. - // For a more isolated test, we'd mock the delegate. - - InetAddress filteredAddress = InetAddress.getByName("1.1.1.1"); - List handledAddresses = Collections.singletonList(filteredAddress); - when(securityDnsHandler.handleAddresses(anyList())).thenReturn(handledAddresses); - - InetAddress[] result = resolver.resolve("google.com"); // Using a real domain for SystemDefaultDnsResolver - - assertEquals(1, result.length); - assertEquals(filteredAddress, result[0]); - // We can't easily verify the delegate call here without more complex mocking. - verify(securityDnsHandler, times(1)).handleAddresses(anyList()); - } - - @Test - void resolveCanonicalHostnameDelegates() throws UnknownHostException { - DnsResolver delegate = mock(DnsResolver.class); - SecurityDnsHandler securityDnsHandler = mock(SecurityDnsHandler.class); - HttpComponentsDnsResolver resolver = new HttpComponentsDnsResolver(delegate, securityDnsHandler); - - when(delegate.resolveCanonicalHostname("example.com")).thenReturn("canonical.example.com"); - - String result = resolver.resolveCanonicalHostname("example.com"); - - assertEquals("canonical.example.com", result); - verify(delegate, times(1)).resolveCanonicalHostname("example.com"); - verify(securityDnsHandler, times(0)).handleAddresses(anyList()); - } - - @Test - void resolveCanonicalHostnameUsesSystemDefaultDnsResolver() throws UnknownHostException { - SecurityDnsHandler securityDnsHandler = mock(SecurityDnsHandler.class); - HttpComponentsDnsResolver resolver = new HttpComponentsDnsResolver(securityDnsHandler); - - // We can't directly mock SystemDefaultDnsResolver's resolveCanonicalHostname, - // so we'll rely on its actual behavior. - - String result = resolver.resolveCanonicalHostname("localhost"); // Should resolve to "localhost" or similar - - assertEquals("localhost", result); // Assuming default behavior for localhost - verify(securityDnsHandler, times(0)).handleAddresses(anyList()); - } - - @Test - void resolveDelegatesUnknownHostException() throws UnknownHostException { - DnsResolver delegate = mock(DnsResolver.class); - SecurityDnsHandler securityDnsHandler = mock(SecurityDnsHandler.class); - HttpComponentsDnsResolver resolver = new HttpComponentsDnsResolver(delegate, securityDnsHandler); - - when(delegate.resolve("unknown.host")).thenThrow(new UnknownHostException("Host not found")); - - assertThrows(UnknownHostException.class, () -> resolver.resolve("unknown.host")); - verify(securityDnsHandler, times(0)).handleAddresses(anyList()); - } - - @Test - void resolveCanonicalHostnameDelegatesUnknownHostException() throws UnknownHostException { - DnsResolver delegate = mock(DnsResolver.class); - SecurityDnsHandler securityDnsHandler = mock(SecurityDnsHandler.class); - HttpComponentsDnsResolver resolver = new HttpComponentsDnsResolver(delegate, securityDnsHandler); - - when(delegate.resolveCanonicalHostname("unknown.host")).thenThrow(new UnknownHostException("Host not found")); - - assertThrows(UnknownHostException.class, () -> resolver.resolveCanonicalHostname("unknown.host")); - verify(securityDnsHandler, times(0)).handleAddresses(anyList()); - } -} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/JettyHttpClientDnsResolverTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/JettyHttpClientDnsResolverTest.java deleted file mode 100644 index cb61fb771d4..00000000000 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/JettyHttpClientDnsResolverTest.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.google.springframework.security.web.client; - -import client.dns.SecurityDnsHandler; -import client.JettyHttpClientDnsResolver; -import org.eclipse.jetty.util.Promise; -import org.eclipse.jetty.util.SocketAddressResolver; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.UnknownHostException; -import java.util.List; -import java.util.ArrayList; -import java.util.Arrays; - -import static org.mockito.Mockito.*; -import static org.junit.jupiter.api.Assertions.*; - -@ExtendWith(MockitoExtension.class) -class JettyHttpClientDnsResolverTest { - - @Mock - private SocketAddressResolver mockDelegateResolver; - - @Mock - private SecurityDnsHandler mockSecurityDnsHandler; - - @Mock - private Promise> mockResultPromise; - - @Captor - private ArgumentCaptor>> delegatePromiseCaptor; - - @Captor - private ArgumentCaptor> resultListCaptor; - - private JettyHttpClientDnsResolver jettyHttpClientDnsResolver; - - private final String HOST = "example.com"; - private final int PORT = 80; - - private List initialAddresses; - private List filteredAddresses; - - @BeforeEach - void setUp() throws UnknownHostException { - // Initialize the class under test with mocked dependencies - jettyHttpClientDnsResolver = new JettyHttpClientDnsResolver(mockDelegateResolver, mockSecurityDnsHandler); - - // Prepare sample data - InetSocketAddress address1 = new InetSocketAddress(InetAddress.getByAddress(HOST, new byte[]{1, 2, 3, 4}), PORT); - InetSocketAddress address2 = new InetSocketAddress(InetAddress.getByAddress(HOST, new byte[]{1, 2, 3, 5}), PORT); - InetSocketAddress address3 = new InetSocketAddress(InetAddress.getByAddress(HOST, new byte[]{1, 2, 3, 6}), PORT); - - initialAddresses = new ArrayList<>(Arrays.asList(address1, address2, address3)); - // SecurityDnsHandler will remove address2 - filteredAddresses = new ArrayList<>(Arrays.asList(address1, address3)); - } - - @Test - void resolve_whenDelegateSucceeds_shouldApplySecurityHandlerAndReturnFilteredAddresses() { - // 1. Configure mock SecurityDnsHandler - // When handleInetSocketAddresses is called with the initial list, return the filtered list - when(mockSecurityDnsHandler.handleInetSocketAddresses(initialAddresses, PORT)) - .thenReturn(filteredAddresses); - - // 2. Call the method under test - jettyHttpClientDnsResolver.resolve(HOST, PORT, mockResultPromise); - - // 3. Verify that the delegate resolver's resolve method was called - // and capture the Promise passed to it. - verify(mockDelegateResolver).resolve(eq(HOST), eq(PORT), delegatePromiseCaptor.capture()); - // 4. Simulate the delegate resolver succeeding - // Get the captured promise and call its 'succeeded' method with the initial addresses. - Promise> capturedPromiseForDelegate = delegatePromiseCaptor.getValue(); - assertNotNull(capturedPromiseForDelegate, "Promise passed to delegate resolver should not be null"); - capturedPromiseForDelegate.succeeded(initialAddresses); - // 5. Verify that SecurityDnsHandler.handleInetSocketAddresses was called - verify(mockSecurityDnsHandler).handleInetSocketAddresses(initialAddresses, PORT); - // 6. Verify that the original mockResultPromise.succeeded was called with the filtered list - verify(mockResultPromise).succeeded(resultListCaptor.capture()); - assertEquals(filteredAddresses, resultListCaptor.getValue(), "The final list of addresses should be the one filtered by the security handler."); - assertTrue(resultListCaptor.getValue().containsAll(filteredAddresses) && filteredAddresses.containsAll(resultListCaptor.getValue()), - "Resulting list should exactly match the filtered list."); - assertFalse(resultListCaptor.getValue().stream().anyMatch(addr -> addr.getAddress().getHostAddress().equals("1.2.3.5")), - "The IP address '1.2.3.5' should have been filtered out."); - // Ensure no failures were propagated - verify(mockResultPromise, never()).failed(any(Throwable.class)); - } - - @Test - void resolve_whenDelegateFails_shouldPropagateFailureAndNotCallSecurityHandler() { - // 1. Prepare a sample exception - Throwable expectedException = new RuntimeException("DNS resolution failed by delegate"); - - // 2. Call the method under test - jettyHttpClientDnsResolver.resolve(HOST, PORT, mockResultPromise); - - // 3. Verify that the delegate resolver's resolve method was called - // and capture the Promise passed to it. - verify(mockDelegateResolver).resolve(eq(HOST), eq(PORT), delegatePromiseCaptor.capture()); - // 4. Simulate the delegate resolver failing - // Get the captured promise and call its 'failed' method with the exception. - Promise> capturedPromiseForDelegate = delegatePromiseCaptor.getValue(); - assertNotNull(capturedPromiseForDelegate, "Promise passed to delegate resolver should not be null"); - capturedPromiseForDelegate.failed(expectedException); - // 5. Verify that SecurityDnsHandler.handleInetSocketAddresses was NOT called - verify(mockSecurityDnsHandler, never()).handleInetSocketAddresses(anyList(), anyInt()); - // 6. Verify that the original mockResultPromise.failed was called with the same exception - verify(mockResultPromise).failed(expectedException); - // Ensure succeeded was not called - verify(mockResultPromise, never()).succeeded(anyList()); - } -} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/SecurityDnsHandlerTest.java b/ssrf/src/test/java/com/google/springframework/security/web/client/SecurityDnsHandlerTest.java deleted file mode 100644 index 40623abff22..00000000000 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/SecurityDnsHandlerTest.java +++ /dev/null @@ -1,229 +0,0 @@ -package com.google.springframework.security.web.client; - -import client.dns.InetAddressFilter; -import client.dns.SecurityDnsHandler; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.UnknownHostException; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -public class SecurityDnsHandlerTest { - - private InetAddress INTERNAL_IP_1; - private InetAddress INTERNAL_IP_2; - private InetAddress EXTERNAL_IP_1; - private InetAddress EXTERNAL_IP_2; - private InetAddress LOCALHOST_IP; - - @BeforeEach - void setUp() throws UnknownHostException { - INTERNAL_IP_1 = InetAddress.getByName("192.168.1.1"); // site-local - INTERNAL_IP_2 = InetAddress.getByName("10.0.0.1"); // site-local - EXTERNAL_IP_1 = InetAddress.getByName("8.8.8.8"); // public - EXTERNAL_IP_2 = InetAddress.getByName("1.1.1.1"); // public - LOCALHOST_IP = InetAddress.getByName("127.0.0.1"); // loopback - } - - // --- Builder Tests --- - - @Test - void testBuilder_defaults() { - SecurityDnsHandler handler = SecurityDnsHandler.builder().build(); - assertFalse(handler.getReportMode(), "Default reportOnly mode should be false"); - - // Assuming default DefaultInetAddressFilter allows all if no rules are set - List addresses = Arrays.asList(INTERNAL_IP_1, EXTERNAL_IP_1); - List result = handler.handleAddresses(addresses); - assertEquals(addresses, result, "With default builder, all addresses should be allowed"); - } - - @Test - void testBuilder_reportOnlyTrue() { - SecurityDnsHandler handler = SecurityDnsHandler.builder().reportOnly(true).build(); - assertTrue(handler.getReportMode()); - } - - @Test - void testBuilder_reportOnlyFalse() { - SecurityDnsHandler handler = SecurityDnsHandler.builder().reportOnly(false).build(); - assertFalse(handler.getReportMode()); - } - - @Test - void testBuilder_blockAllExternal_blocksExternalOnly() { - SecurityDnsHandler handler = SecurityDnsHandler.builder().blockAllExternal(true).build(); - List candidates = Arrays.asList(INTERNAL_IP_1, EXTERNAL_IP_1, LOCALHOST_IP, EXTERNAL_IP_2); - - List result = handler.handleAddresses(candidates); - - assertTrue(result.contains(INTERNAL_IP_1)); - assertTrue(result.contains(LOCALHOST_IP)); // Loopback is not external - assertFalse(result.contains(EXTERNAL_IP_1)); - assertFalse(result.contains(EXTERNAL_IP_2)); - assertEquals(2, result.size(), "Only internal and loopback IPs should pass. Result: " + resultToString(result)); - } - - @Test - void testBuilder_blockAllInternal_blocksInternalAndLoopback() { - // Assuming DefaultInetAddressFilter treats loopback as internal for blocking purposes - SecurityDnsHandler handler = SecurityDnsHandler.builder().blockAllInternal(true).build(); - List candidates = Arrays.asList(INTERNAL_IP_1, EXTERNAL_IP_1, LOCALHOST_IP, INTERNAL_IP_2); - - List result = handler.handleAddresses(candidates); - - assertTrue(result.contains(EXTERNAL_IP_1)); - assertFalse(result.contains(INTERNAL_IP_1)); - assertFalse(result.contains(INTERNAL_IP_2)); - assertFalse(result.contains(LOCALHOST_IP)); // Loopback should be blocked - assertEquals(1, result.size(), "Only external IPs should pass. Result: " + resultToString(result)); - } - - @Test - void testBuilder_customFilter_blocksSpecificAddress() { - InetAddressFilter customBlocker = address -> address.equals(EXTERNAL_IP_1); - SecurityDnsHandler handler = SecurityDnsHandler.builder().customFilter(customBlocker).build(); - List candidates = Arrays.asList(INTERNAL_IP_1, EXTERNAL_IP_1, EXTERNAL_IP_2); - - List result = handler.handleAddresses(candidates); - - assertTrue(result.contains(INTERNAL_IP_1)); - assertTrue(result.contains(EXTERNAL_IP_2)); - assertFalse(result.contains(EXTERNAL_IP_1)); - assertEquals(2, result.size()); - } - - @Test - void handleAddresses_someAddressesBlocked_byFilter() { - InetAddressFilter blockExternal1Filter = address -> address.equals(EXTERNAL_IP_1); - SecurityDnsHandler handler = new SecurityDnsHandler(blockExternal1Filter, false); - List candidates = Arrays.asList(INTERNAL_IP_1, EXTERNAL_IP_1, EXTERNAL_IP_2); - - List result = handler.handleAddresses(candidates); - - assertTrue(result.contains(INTERNAL_IP_1)); - assertTrue(result.contains(EXTERNAL_IP_2)); - assertFalse(result.contains(EXTERNAL_IP_1)); - assertEquals(2, result.size()); - } - - @Test - void handleAddresses_allAddressesBlocked_reportOnlyFalse_returnsEmpty() { - SecurityDnsHandler handler = SecurityDnsHandler.builder().blockAllInternal(true).reportOnly(false).build(); - List candidates = Arrays.asList(INTERNAL_IP_1, INTERNAL_IP_2); - - List result = handler.handleAddresses(candidates); - - assertTrue(result.isEmpty(), "Should return empty list when all are blocked and not in reportOnly mode"); - } - - @Test - void handleAddresses_allAddressesBlocked_reportOnlyTrue_returnsAllCandidates() { - SecurityDnsHandler handler = SecurityDnsHandler.builder().blockAllInternal(true).reportOnly(true).build(); - List candidates = Arrays.asList(INTERNAL_IP_1, EXTERNAL_IP_1); - - List result = handler.handleAddresses(candidates); - - assertEquals(candidates, result, "Should return all candidates when all are blocked and in reportOnly mode"); - } - - @Test - void handleAddresses_emptyCandidateList_returnsEmpty() { - SecurityDnsHandler handler = SecurityDnsHandler.builder().blockAllInternal(true).reportOnly(true).build(); - List candidates = Collections.emptyList(); - - List result = handler.handleAddresses(candidates); - - assertTrue(result.isEmpty()); - } - - @Test - void handleInetSocketAddresses_filtersBasedOnAddress_rewritesPort() { - InetAddressFilter blockExternal1Filter = address -> address.equals(EXTERNAL_IP_1); // This should only filter EXTERNAL_IP_1 - SecurityDnsHandler handler = new SecurityDnsHandler(blockExternal1Filter, false); - - int targetPort = 8080; - List candidates = Arrays.asList( - new InetSocketAddress(INTERNAL_IP_1, 9000), // original port 9000 - new InetSocketAddress(EXTERNAL_IP_1, targetPort), // original port 8080 (blocked) - new InetSocketAddress(EXTERNAL_IP_2, 9001) // original port 9001 - ); - - List result = handler.handleInetSocketAddresses(candidates, targetPort); - - assertEquals(2, result.size()); - assertTrue(result.stream().anyMatch(sa -> sa.getAddress().equals(INTERNAL_IP_1) && sa.getPort() == targetPort), - "INTERNAL_IP_1 should be present with targetPort"); - assertTrue(result.stream().anyMatch(sa -> sa.getAddress().equals(EXTERNAL_IP_2) && sa.getPort() == targetPort), - "EXTERNAL_IP_2 should be present with targetPort"); - assertFalse(result.stream().anyMatch(sa -> sa.getAddress().equals(EXTERNAL_IP_1)), - "EXTERNAL_IP_1 should be filtered out"); - } - - @Test - void handleInetSocketAddresses_allAddressesBlocked_reportOnlyFalse_returnsEmpty() { - InetAddressFilter blockAllFilter = address -> true; - SecurityDnsHandler handler = new SecurityDnsHandler(blockAllFilter, false); - int port = 443; - List candidates = Arrays.asList( - new InetSocketAddress(INTERNAL_IP_1, port), - new InetSocketAddress(EXTERNAL_IP_1, port) - ); - List result = handler.handleInetSocketAddresses(candidates, port); - - assertTrue(result.isEmpty()); - } - - @Test - void handleInetSocketAddresses_allAddressesBlocked_reportOnlyTrue_returnsAllCandidatesWithNewPort() { - InetAddressFilter blockAllFilter = address -> true; - SecurityDnsHandler handler = new SecurityDnsHandler(blockAllFilter, true); - int targetPort = 443; - List candidates = Arrays.asList( - new InetSocketAddress(INTERNAL_IP_1, 8000), - new InetSocketAddress(EXTERNAL_IP_1, 8001) - ); - - List result = handler.handleInetSocketAddresses(candidates, targetPort); - - assertEquals(candidates.size(), result.size()); - for (int i = 0; i < candidates.size(); i++) { - assertEquals(candidates.get(i).getAddress(), result.get(i).getAddress(), "Address should match original candidate"); - assertEquals(targetPort, result.get(i).getPort(), "Port should be rewritten to targetPort"); - } - } - - // --- Mockito based test for direct InetAddressFilter interaction --- - @Test - void handleAddresses_withMockFilter_verifiesFilteringLogic() { - InetAddressFilter mockFilter = mock(InetAddressFilter.class); - // Define behavior for mock filter - when(mockFilter.filterAddress(INTERNAL_IP_1)).thenReturn(false); // Not blocked - when(mockFilter.filterAddress(EXTERNAL_IP_1)).thenReturn(true); // Blocked - when(mockFilter.filterAddress(EXTERNAL_IP_2)).thenReturn(false); // Not blocked - - SecurityDnsHandler handler = new SecurityDnsHandler(mockFilter, false); - List candidates = Arrays.asList(INTERNAL_IP_1, EXTERNAL_IP_1, EXTERNAL_IP_2); - - List result = handler.handleAddresses(candidates); - - assertTrue(result.contains(INTERNAL_IP_1)); - assertFalse(result.contains(EXTERNAL_IP_1)); - assertTrue(result.contains(EXTERNAL_IP_2)); - assertEquals(2, result.size()); - } - - // Helper for better assertion messages - private String resultToString(List addresses) { - return addresses.stream().map(InetAddress::getHostAddress).collect(Collectors.joining(", ")); - } -} diff --git a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java b/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java deleted file mode 100644 index 8df13b94288..00000000000 --- a/ssrf/src/test/java/com/google/springframework/security/web/client/UsageExample.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.google.springframework.security.web.client; - - -import client.dns.SecurityDnsHandler; -import client.HttpComponentsDnsResolver; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; -import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; -import org.apache.hc.client5.http.socket.ConnectionSocketFactory; -import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; -import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; -import org.apache.hc.core5.http.config.Registry; -import org.apache.hc.core5.http.config.RegistryBuilder; -import org.eclipse.jetty.client.HttpClient; -import org.springframework.http.ResponseEntity; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.reactive.function.client.WebClient; - - -public class UsageExample { - - public static void exampleApache() { - System.out.println("Example Apache - Apache HttpClient with HttpComponentsDnsResolver"); - // 1. Create SecurityDnsHandler with custom logic - SecurityDnsHandler securityDnsHandler = new SecurityDnsHandler.Builder().blockAllExternal( true).build(); - - // 2. Create HttpComponentsDnsResolver with the custom SecurityDnsHandler - HttpComponentsDnsResolver httpComponentsDnsResolver = new HttpComponentsDnsResolver(securityDnsHandler); - - // 3. Create a RestTemplate with the custom DnsResolver - Registry socketFactoryRegistry = RegistryBuilder.create() - .register("http", PlainConnectionSocketFactory.getSocketFactory()) - .register("https", SSLConnectionSocketFactory.getSocketFactory()) - .build(); - - BasicHttpClientConnectionManager connectionManager = new BasicHttpClientConnectionManager( - socketFactoryRegistry, null, null, httpComponentsDnsResolver); - - CloseableHttpClient httpClient = HttpClientBuilder.create() - .setConnectionManager(connectionManager) - .build(); - - RestTemplate restTemplate = new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); - - // 4. Use the RestTemplate to make a request - System.out.println("Attempting to access http://google.com\n"); - - try { - ResponseEntity response = restTemplate.getForEntity("https://google.com", String.class); - System.out.println("FAILURE - Response: " + response.getBody()); - } catch (Exception e) { - System.err.println("SUCCESS - Access blocked: " + e.getMessage()); - } - } - - - public static void main(String[] args) { - exampleApache(); - exampleNetty(); - exampleJetty(); - } - - public static void exampleJetty() { - System.out.println("\nExample Jetty - Jetty HttpClient with JettyHttpClientDnsResolver"); - - // 1. Create SecurityDnsHandler with custom logic - SecurityDnsHandler securityDnsHandler = SecurityDnsHandler.builder() - .denyList("192.168.1.100") // Example: deny a specific private IP - .blockAllInternal(true) // Example: block all internal IPs (like 127.0.0.1) - .reportOnly(false) - .build(); - - // 2. Create JettyHttpClientDnsResolver with the custom SecurityDnsHandler - // Jetty's default SocketAddressResolver will be used as the delegate. - client.JettyHttpClientDnsResolver jettyDnsResolver = new client.JettyHttpClientDnsResolver(securityDnsHandler); - - // 3. Create and configure Jetty's HttpClient - HttpClient jettyHttpClient = new HttpClient(); - jettyHttpClient.setSocketAddressResolver(jettyDnsResolver); - - try { - jettyHttpClient.start(); // Jetty HttpClient must be started - - // 4. Use the HttpClient to make a request - // Example: trying to access a blocked internal address - System.out.println("\nAttempting to access http://localhost (should be blocked by blockAllInternal=true)\n"); - try { - org.eclipse.jetty.client.api.ContentResponse response = jettyHttpClient.newRequest("http://localhost:8080").send(); // Jetty 11.x API - System.out.println("FAILURE: Response from http://localhost: " + response.getContentAsString()); - } catch (Exception e) { - // We expect an exception here, often a form of ConnectTimeoutException or similar - // because the resolution will yield no valid addresses if localhost is blocked. - // Jetty's behavior on no resolvable address might vary (e.g. timeout or specific exception). - System.err.println("SUCCESS: Access to http://localhost blocked or failed as expected: " + e.getClass().getName() + " - " + e.getMessage()); - } - - // Example: trying to access an external address (should be allowed if not in deny list and DNS resolves) - System.out.println("\nAttempting to access http://example.com (should be allowed)\n"); - try { - org.eclipse.jetty.client.api.ContentResponse response = jettyHttpClient.newRequest("http://example.com").send(); // Jetty 11.x API - System.out.println("SUCCESS: Response from http://example.com: " + response.getContentAsString().substring(0, Math.min(response.getContentAsString().length(), 100)) + "..."); - } catch (Exception e) { - System.err.println("FAILURE: Access to http://example.com failed: " + e.getMessage()); - } - - jettyHttpClient.stop(); // Stop the client - - } catch (Exception e) { - System.err.println("Error setting up or using Jetty HttpClient: " + e.getMessage()); - e.printStackTrace(); - } - } - - public static void exampleNetty() { - System.out.println("\nExample Netty - Reactor Netty HttpClient with NettyHttpClientAddressSelector\n"); - - // 1. Create SecurityDnsHandler with custom logic - SecurityDnsHandler securityDnsHandler = SecurityDnsHandler.builder() - .blockAllExternal(false) - .allowList("1.1.1.1", "8.8.4.4","google.com") // Example: only allow specific public IPs - .reportOnly(false) // Block if rules are not met - .build(); - - // 2. Create NettyHttpClientAddressSelector with the custom SecurityDnsHandler - client.NettyHttpClientAddressSelector addressSelector = new client.NettyHttpClientAddressSelector(securityDnsHandler); - - // 3. Create a Reactor Netty HttpClient with the custom addressSelector - reactor.netty.http.client.HttpClient nettyHttpClient = reactor.netty.http.client.HttpClient.create() - .resolvedAddressesSelector(addressSelector); - - // 4. Use the WebClient with the configured HttpClient to make a request - // The following WebClient setup requires spring-webflux dependency. - // Commenting out to allow compilation without it, to focus on the selector's role. - WebClient webClient = WebClient.builder() - .clientConnector(new ReactorClientHttpConnector(nettyHttpClient)) - .build(); - - System.out.println("Attempting to access http://example.com (should be blocked as not in allowList)\n"); - try { - String response = webClient.get() - .uri("http://example.com") // example.com IP is likely not 1.1.1.1 or 8.8.8.8 - .retrieve() - .bodyToMono(String.class) - .block(); - System.out.println("FAILURE: Response from http://example.com: " + response.substring(0, Math.min(response.length(), 100)) + "..."); - } catch (Exception e) { - System.err.println("SUCCESS: Access to http://example.com blocked or failed as expected: " + e.getMessage()); - } - - System.out.println("\nAttempting to access http://1.1.1.1 (should be allowed if 1.1.1.1 is reachable)\n"); - try { - // Note: 1.1.1.1 might not be serving HTTP or could be firewalled. - // This is to demonstrate the selector allowing the attempt. - String response = webClient.get() - .uri("http://1.1.1.1") - .retrieve() - .bodyToMono(String.class) - .block(java.time.Duration.ofSeconds(5)); // Timeout for potentially unresponsive IP - System.out.println("SUCCESS: Response from http://1.1.1.1: " + response.substring(0, Math.min(response.length(), 100)) + "..."); - } catch (Exception e) { - System.err.println("FAILURE: Access to http://1.1.1.1 blocked or failed as expected: " + e.getMessage()); - } - } -} From abc075cde91b8a703ebee4dfd48e6755df860c12 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Thu, 22 Jan 2026 16:45:33 +0000 Subject: [PATCH 33/34] Add more tests and Javadoc for `InetAddressFilter.Builder` (#18) * Add InetAddressFilterBuilderTests * Add Javadoc to InetAddressFilter.Builder * Drop allow-deny list mutually exclusive assertion * Remove remaining uses of "verifier" term * Rename addCustomFilter to customFilter * Polishing --- .gitignore | 1 + .../matcher/AllowedInetAddressFilter.java | 2 +- ...er.java => DefaultInetAddressBuilder.java} | 28 ++--- .../matcher/DisallowedInetAddressFilter.java | 2 +- .../web/util/matcher/InetAddressFilter.java | 41 ++++++- .../InetAddressFilterBuilderTests.java | 108 ++++++++++++++++++ 6 files changed, 156 insertions(+), 26 deletions(-) rename ssrf/src/main/java/org/springframework/security/web/util/matcher/{DefaultInetAddressVerifierBuilder.java => DefaultInetAddressBuilder.java} (64%) create mode 100644 ssrf/src/test/java/org/springframework/security/web/util/matcher/InetAddressFilterBuilderTests.java diff --git a/.gitignore b/.gitignore index 34b38a08f71..dfe4d035850 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ ssrf/bin out test-output atlassian-ide-plugin.xml +build/ # VS Code .vscode/ diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/AllowedInetAddressFilter.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/AllowedInetAddressFilter.java index 525c0a2f530..c1d9e7e2d7f 100644 --- a/ssrf/src/main/java/org/springframework/security/web/util/matcher/AllowedInetAddressFilter.java +++ b/ssrf/src/main/java/org/springframework/security/web/util/matcher/AllowedInetAddressFilter.java @@ -26,7 +26,7 @@ public boolean filter(InetAddress address) { @Override public String toString() { - return "AllowedInetAddressVerifier[\"" + this.allowList + "\"]"; + return "AllowedInetAddressFilter[\"" + this.allowList + "\"]"; } } diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/DefaultInetAddressVerifierBuilder.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/DefaultInetAddressBuilder.java similarity index 64% rename from ssrf/src/main/java/org/springframework/security/web/util/matcher/DefaultInetAddressVerifierBuilder.java rename to ssrf/src/main/java/org/springframework/security/web/util/matcher/DefaultInetAddressBuilder.java index 0f0db250e63..0bbb4b63123 100644 --- a/ssrf/src/main/java/org/springframework/security/web/util/matcher/DefaultInetAddressVerifierBuilder.java +++ b/ssrf/src/main/java/org/springframework/security/web/util/matcher/DefaultInetAddressBuilder.java @@ -4,7 +4,6 @@ import java.net.InetAddress; import java.util.ArrayList; import java.util.List; -import java.util.function.Predicate; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -12,11 +11,7 @@ import org.springframework.util.Assert; -final class DefaultInetAddressVerifierBuilder implements InetAddressFilter.Builder { - - private static final String ALLOWED_DISALLOWED_MESSAGE = "allowed and disallowed are mutually exclusive"; - - private static final String INTERNAL_EXTERNAL_ONLY_MESSAGE = "internalOnly and externalOnly are mutually exclusive"; +final class DefaultInetAddressBuilder implements InetAddressFilter.Builder { private final List filters = new ArrayList<>(); @@ -24,40 +19,37 @@ final class DefaultInetAddressVerifierBuilder implements InetAddressFilter.Build @Override public InetAddressFilter.Builder allowList(List addresses) { - assertNoneMatch(filter -> filter instanceof DisallowedInetAddressFilter, ALLOWED_DISALLOWED_MESSAGE); this.filters.add(new AllowedInetAddressFilter(addresses)); return this; } @Override public InetAddressFilter.Builder denyList(List addresses) { - assertNoneMatch(filter -> filter instanceof AllowedInetAddressFilter, ALLOWED_DISALLOWED_MESSAGE); this.filters.add(new DisallowedInetAddressFilter(addresses)); return this; } @Override public InetAddressFilter.Builder blockExternal() { - return addInternalOrExternalFilter(true); + return addInternalExternalFilter(true); } @Override public InetAddressFilter.Builder blockInternal() { - return addInternalOrExternalFilter(false); + return addInternalExternalFilter(false); } - private InetAddressFilter.Builder addInternalOrExternalFilter(boolean blockExternal) { - - assertNoneMatch( - f -> f instanceof InternalExternalInetAddressFilter ief && blockExternal != ief.shouldBlockExternal(), - INTERNAL_EXTERNAL_ONLY_MESSAGE); + private InetAddressFilter.Builder addInternalExternalFilter(boolean blockExternal) { + Assert.isTrue(this.filters.stream().noneMatch(f -> + f instanceof InternalExternalInetAddressFilter ief && blockExternal != ief.shouldBlockExternal()), + "blockExternal and blockInternal are mutually exclusive options"); this.filters.add(new InternalExternalInetAddressFilter(blockExternal)); return this; } @Override - public InetAddressFilter.Builder addCustomFilter(InetAddressFilter filter) { + public InetAddressFilter.Builder customFilter(InetAddressFilter filter) { this.filters.add(filter); return this; } @@ -73,10 +65,6 @@ public InetAddressFilter build() { return new CompositeInetAddressFilter(this.filters, this.reportOnly); } - private void assertNoneMatch(Predicate predicate, String message) { - Assert.state(this.filters.stream().noneMatch(predicate), message); - } - private record CompositeInetAddressFilter( List filters, boolean reportOnly) implements InetAddressFilter { diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/DisallowedInetAddressFilter.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/DisallowedInetAddressFilter.java index 5b58206af7f..7ab2a94e3a3 100644 --- a/ssrf/src/main/java/org/springframework/security/web/util/matcher/DisallowedInetAddressFilter.java +++ b/ssrf/src/main/java/org/springframework/security/web/util/matcher/DisallowedInetAddressFilter.java @@ -23,7 +23,7 @@ public boolean filter(InetAddress address) { @Override public String toString() { - return "DisallowedInetAddressVerifier[\"" + this.disallowList + "\"]"; + return "DisallowedInetAddressFilter[\"" + this.disallowList + "\"]"; } } diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/InetAddressFilter.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/InetAddressFilter.java index b914d5cf409..1563c7adaa2 100644 --- a/ssrf/src/main/java/org/springframework/security/web/util/matcher/InetAddressFilter.java +++ b/ssrf/src/main/java/org/springframework/security/web/util/matcher/InetAddressFilter.java @@ -6,6 +6,7 @@ /** * Component that helps to filter an {@link InetAddress} in or out. */ +@FunctionalInterface public interface InetAddressFilter { /** @@ -21,26 +22,58 @@ public interface InetAddressFilter { * delegates to any number of other filters. */ static Builder builder() { - return new DefaultInetAddressVerifierBuilder(); + return new DefaultInetAddressBuilder(); } + /** + * Builder to create a composite {@link InetAddressFilter}. + */ interface Builder { + /** + * Add filter that matches addresses if found in an "allow" list. + * @param addresses the allow list of addresses + * @return the same builder instance + */ Builder allowList(List addresses); + /** + * Add filter that matches addresses if not found in a "deny" list. + * @param addresses the deny list of addresses + * @return the same builder instance + */ Builder denyList(List addresses); + /** + * Add filter that blocks all external addresses. + * @return the same builder instance + */ Builder blockExternal(); + /** + * Add filter that blocks all internal addresses. + * @return the same builder instance + */ Builder blockInternal(); - Builder addCustomFilter(InetAddressFilter verifier); - + /** + * Add filter with custom logic to match addresses. + * @param filter the filter to add + * @return the same builder instance + */ + Builder customFilter(InetAddressFilter filter); + + /** + * Enable a "report-only" mode that only logs debug messages, and always matches. + * @return the same builder instance + */ Builder reportOnly(); + /** + * Return the created composite {@link InetAddressFilter} instance. + */ InetAddressFilter build(); - } } diff --git a/ssrf/src/test/java/org/springframework/security/web/util/matcher/InetAddressFilterBuilderTests.java b/ssrf/src/test/java/org/springframework/security/web/util/matcher/InetAddressFilterBuilderTests.java new file mode 100644 index 00000000000..3a7effbbdc0 --- /dev/null +++ b/ssrf/src/test/java/org/springframework/security/web/util/matcher/InetAddressFilterBuilderTests.java @@ -0,0 +1,108 @@ +package org.springframework.security.web.util.matcher; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link InetAddressFilter.Builder}. + */ +public class InetAddressFilterBuilderTests { + + private static final InetAddress INTERNAL_IP_1 = initAddress("192.168.1.1"); + private static final InetAddress INTERNAL_IP_2 = initAddress("10.0.0.1"); + private static final InetAddress EXTERNAL_IP_1 = initAddress("8.8.8.8"); + private static final InetAddress EXTERNAL_IP_2 = initAddress("1.1.1.1"); + private static final InetAddress LOCALHOST_IP = initAddress("127.0.0.1"); + + private static InetAddress initAddress(String address) { + try { + return InetAddress.getByName(address); + } + catch (UnknownHostException ex) { + throw new IllegalArgumentException(ex); + } + } + + + @Test + void defaultAllowsAll() { + InetAddressFilter filter = InetAddressFilter.builder().build(); + + assertTrue(filter.filter(INTERNAL_IP_1)); + assertTrue(filter.filter(EXTERNAL_IP_1)); + } + + @Test + void allowList() { + InetAddressFilter filter = InetAddressFilter.builder().allowList(List.of(EXTERNAL_IP_1.getHostAddress())).build(); + + assertTrue(filter.filter(EXTERNAL_IP_1)); + assertFalse(filter.filter(EXTERNAL_IP_2)); + } + + + @Test + void denyList() { + InetAddressFilter filter = InetAddressFilter.builder().denyList(List.of(EXTERNAL_IP_1.getHostAddress())).build(); + + assertFalse(filter.filter(EXTERNAL_IP_1)); + assertTrue(filter.filter(EXTERNAL_IP_2)); + } + + @Test + void blockExternal() { + InetAddressFilter filter = InetAddressFilter.builder().blockExternal().build(); + + assertTrue(filter.filter(INTERNAL_IP_1)); + assertTrue(filter.filter(LOCALHOST_IP), "Loopback is not external"); + + assertFalse(filter.filter(EXTERNAL_IP_1)); + assertFalse(filter.filter(EXTERNAL_IP_2)); + } + + @Test + void blockInternal() { + InetAddressFilter filter = InetAddressFilter.builder().blockInternal().build(); + + assertTrue(filter.filter(EXTERNAL_IP_1)); + assertFalse(filter.filter(INTERNAL_IP_1)); + assertFalse(filter.filter(INTERNAL_IP_2)); + assertFalse(filter.filter(LOCALHOST_IP), "Loopback should be blocked"); + } + + @Test + void blockInternalExternalAreMutuallyExclusive() { + assertThrows(IllegalArgumentException.class, + () -> InetAddressFilter.builder().blockExternal().blockInternal(), + "blockExternal and blockInternal are mutually exclusive options"); + } + + @Test + void customFilter() { + InetAddressFilter filter = InetAddressFilter.builder().customFilter(EXTERNAL_IP_1::equals).build(); + + assertTrue(filter.filter(EXTERNAL_IP_1)); + assertFalse(filter.filter(EXTERNAL_IP_2)); + } + + @Test + void reportOnly() { + InetAddressFilter.Builder builder = InetAddressFilter.builder().customFilter(EXTERNAL_IP_1::equals); + + InetAddressFilter filter = builder.build(); + assertTrue(filter.filter(EXTERNAL_IP_1)); + assertFalse(filter.filter(EXTERNAL_IP_2)); + + filter = builder.reportOnly().build(); + assertTrue(filter.filter(EXTERNAL_IP_1)); + assertTrue(filter.filter(EXTERNAL_IP_2)); + } + +} From 13703d89ff85915057b072c51ddf57a686064737 Mon Sep 17 00:00:00 2001 From: Robert Winch <362503+rwinch@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:24:19 -0600 Subject: [PATCH 34/34] use spring security --- gradle.properties | 2 +- ssrf/build.gradle | 1 + .../HttpComponentsFilteringDnsResolver.java | 10 +- .../JettyHttpClientFilteringDnsResolver.java | 10 +- ...ttyHttpClientFilteringAddressSelector.java | 13 ++- .../matcher/AllowedInetAddressFilter.java | 32 ------ .../matcher/DefaultInetAddressBuilder.java | 98 ---------------- .../matcher/DisallowedInetAddressFilter.java | 29 ----- .../web/util/matcher/InetAddressFilter.java | 79 ------------- .../InternalExternalInetAddressFilter.java | 72 ------------ .../web/util/matcher/IpAddressMatcher.java | 70 ------------ .../web/util/matcher/package-info.java | 4 - ...ttpComponentsFilteringDnsResolverTest.java | 32 +++--- ...ttyHttpClientFilteringDnsResolverTest.java | 8 +- ...ttpClientFilteringAddressSelectorTest.java | 48 ++++---- .../InetAddressFilterBuilderTests.java | 108 ------------------ .../InetAddressMatchersBuilderTests.java | 101 ++++++++++++++++ .../web/util/matcher/UsageExample.java | 12 +- 18 files changed, 169 insertions(+), 560 deletions(-) delete mode 100644 ssrf/src/main/java/org/springframework/security/web/util/matcher/AllowedInetAddressFilter.java delete mode 100644 ssrf/src/main/java/org/springframework/security/web/util/matcher/DefaultInetAddressBuilder.java delete mode 100644 ssrf/src/main/java/org/springframework/security/web/util/matcher/DisallowedInetAddressFilter.java delete mode 100644 ssrf/src/main/java/org/springframework/security/web/util/matcher/InetAddressFilter.java delete mode 100644 ssrf/src/main/java/org/springframework/security/web/util/matcher/InternalExternalInetAddressFilter.java delete mode 100644 ssrf/src/main/java/org/springframework/security/web/util/matcher/IpAddressMatcher.java delete mode 100644 ssrf/src/main/java/org/springframework/security/web/util/matcher/package-info.java delete mode 100644 ssrf/src/test/java/org/springframework/security/web/util/matcher/InetAddressFilterBuilderTests.java create mode 100644 ssrf/src/test/java/org/springframework/security/web/util/matcher/InetAddressMatchersBuilderTests.java diff --git a/gradle.properties b/gradle.properties index 49abc104716..30242efdc13 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,2 @@ springVersion=6.1.4 -springSecurityVersion=6.5.7 +springSecurityVersion=7.1.0-SNAPSHOT diff --git a/ssrf/build.gradle b/ssrf/build.gradle index 873926b7091..6e3b30104e9 100644 --- a/ssrf/build.gradle +++ b/ssrf/build.gradle @@ -12,6 +12,7 @@ plugins { repositories { // Use Maven Central for resolving dependencies. mavenCentral() + mavenLocal() } dependencies { diff --git a/ssrf/src/main/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolver.java b/ssrf/src/main/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolver.java index a5f9fdc5fb1..f5028ce5a7d 100644 --- a/ssrf/src/main/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolver.java +++ b/ssrf/src/main/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolver.java @@ -23,20 +23,20 @@ import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.SystemDefaultDnsResolver; -import org.springframework.security.web.util.matcher.InetAddressFilter; +import org.springframework.security.web.util.matcher.InetAddressMatcher; public class HttpComponentsFilteringDnsResolver implements DnsResolver { private final DnsResolver delegate; - private final InetAddressFilter filter; + private final InetAddressMatcher filter; - public HttpComponentsFilteringDnsResolver(InetAddressFilter filter) { + public HttpComponentsFilteringDnsResolver(InetAddressMatcher filter) { this(SystemDefaultDnsResolver.INSTANCE, filter); } - public HttpComponentsFilteringDnsResolver(DnsResolver delegate, InetAddressFilter filter) { + public HttpComponentsFilteringDnsResolver(DnsResolver delegate, InetAddressMatcher filter) { this.delegate = delegate; this.filter = filter; } @@ -45,7 +45,7 @@ public HttpComponentsFilteringDnsResolver(DnsResolver delegate, InetAddressFilte @Override public InetAddress[] resolve(String host) throws UnknownHostException { return Arrays.stream(this.delegate.resolve(host)) - .filter(this.filter::filter).toArray(InetAddress[]::new); + .filter(this.filter::matches).toArray(InetAddress[]::new); } @Override diff --git a/ssrf/src/main/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolver.java b/ssrf/src/main/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolver.java index 2f443cc6127..d64e4d9464d 100644 --- a/ssrf/src/main/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolver.java +++ b/ssrf/src/main/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolver.java @@ -23,16 +23,16 @@ import org.eclipse.jetty.util.Promise; import org.eclipse.jetty.util.SocketAddressResolver; -import org.springframework.security.web.util.matcher.InetAddressFilter; +import org.springframework.security.web.util.matcher.InetAddressMatcher; public class JettyHttpClientFilteringDnsResolver implements SocketAddressResolver { private final SocketAddressResolver delegate; - private final InetAddressFilter filter; + private final InetAddressMatcher filter; - public JettyHttpClientFilteringDnsResolver(SocketAddressResolver delegate, InetAddressFilter filter) { + public JettyHttpClientFilteringDnsResolver(SocketAddressResolver delegate, InetAddressMatcher filter) { this.delegate = delegate; this.filter = filter; } @@ -42,7 +42,7 @@ public JettyHttpClientFilteringDnsResolver(SocketAddressResolver delegate, InetA * as the delegate resolver. * @param filter the filter to apply security rules to the resolved addresses. */ - public JettyHttpClientFilteringDnsResolver(InetAddressFilter filter) { + public JettyHttpClientFilteringDnsResolver(InetAddressMatcher filter) { // Call the primary constructor, using the fully qualified name for the nested static class SocketAddressResolver resolver = new Sync(); this.delegate = resolver; @@ -58,7 +58,7 @@ public void resolve(String host, int port, Promise> oute public void succeeded(List candidates) { outerPromise.succeeded(candidates.stream() .map(InetSocketAddress::getAddress) - .filter(filter::filter) + .filter(filter::matches) .map(address -> new InetSocketAddress(address, port)) .toList()); } diff --git a/ssrf/src/main/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelector.java b/ssrf/src/main/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelector.java index 8483ea0abe3..dc1ab88eef4 100644 --- a/ssrf/src/main/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelector.java +++ b/ssrf/src/main/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelector.java @@ -28,24 +28,25 @@ import reactor.netty.transport.ClientTransport.ResolvedAddressSelector; import reactor.netty.transport.ClientTransportConfig; -import org.springframework.security.web.util.matcher.InetAddressFilter; +import org.springframework.security.web.util.matcher.InetAddressMatcher; +import org.springframework.security.web.util.matcher.InetAddressMatcher; /** - * A {@link ResolvedAddressSelector} that uses a {@link InetAddressFilter} to filter a list of + * A {@link ResolvedAddressSelector} that uses a {@link InetAddressMatcher} to filter a list of * resolved {@link SocketAddress}es for a Reactor Netty client. * * @see reactor.netty.http.client.HttpClient#resolvedAddressesSelector(ResolvedAddressSelector) */ public class NettyHttpClientFilteringAddressSelector implements ResolvedAddressSelector> { - private final InetAddressFilter filter; + private final InetAddressMatcher filter; /** * Creates a new instance. * @param filter The filter to apply security rules to the resolved addresses. */ - public NettyHttpClientFilteringAddressSelector(InetAddressFilter filter) { - Objects.requireNonNull(filter, "InetAddressFilter must not be null"); + public NettyHttpClientFilteringAddressSelector(InetAddressMatcher filter) { + Objects.requireNonNull(filter, "InetAddressMatcher must not be null"); this.filter = filter; } @@ -72,7 +73,7 @@ private List filterInternal(List socketA .filter(InetSocketAddress.class::isInstance) .map(InetSocketAddress.class::cast) .map(InetSocketAddress::getAddress) - .filter(this.filter::filter) + .filter(this.filter::matches) .toList(); return socketAddresses.stream() diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/AllowedInetAddressFilter.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/AllowedInetAddressFilter.java deleted file mode 100644 index c1d9e7e2d7f..00000000000 --- a/ssrf/src/main/java/org/springframework/security/web/util/matcher/AllowedInetAddressFilter.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.springframework.security.web.util.matcher; - -import java.net.InetAddress; -import java.util.List; - -final class AllowedInetAddressFilter implements InetAddressFilter { - - private final List allowList; - - public AllowedInetAddressFilter(List allowList) { - this.allowList = allowList.stream().map(IpAddressMatcher::new).toList(); - } - - @Override - public boolean filter(InetAddress address) { - if (this.allowList.isEmpty()) { - return true; - } - for (IpAddressMatcher matcher : this.allowList) { - if (matcher.matches(address)) { - return true; - } - } - return false; - } - - @Override - public String toString() { - return "AllowedInetAddressFilter[\"" + this.allowList + "\"]"; - } - -} diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/DefaultInetAddressBuilder.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/DefaultInetAddressBuilder.java deleted file mode 100644 index 0bbb4b63123..00000000000 --- a/ssrf/src/main/java/org/springframework/security/web/util/matcher/DefaultInetAddressBuilder.java +++ /dev/null @@ -1,98 +0,0 @@ -package org.springframework.security.web.util.matcher; - - -import java.net.InetAddress; -import java.util.ArrayList; -import java.util.List; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - -import org.springframework.util.Assert; - - -final class DefaultInetAddressBuilder implements InetAddressFilter.Builder { - - private final List filters = new ArrayList<>(); - - private boolean reportOnly; - - @Override - public InetAddressFilter.Builder allowList(List addresses) { - this.filters.add(new AllowedInetAddressFilter(addresses)); - return this; - } - - @Override - public InetAddressFilter.Builder denyList(List addresses) { - this.filters.add(new DisallowedInetAddressFilter(addresses)); - return this; - } - - @Override - public InetAddressFilter.Builder blockExternal() { - return addInternalExternalFilter(true); - } - - @Override - public InetAddressFilter.Builder blockInternal() { - return addInternalExternalFilter(false); - } - - private InetAddressFilter.Builder addInternalExternalFilter(boolean blockExternal) { - Assert.isTrue(this.filters.stream().noneMatch(f -> - f instanceof InternalExternalInetAddressFilter ief && blockExternal != ief.shouldBlockExternal()), - "blockExternal and blockInternal are mutually exclusive options"); - - this.filters.add(new InternalExternalInetAddressFilter(blockExternal)); - return this; - } - - @Override - public InetAddressFilter.Builder customFilter(InetAddressFilter filter) { - this.filters.add(filter); - return this; - } - - @Override - public InetAddressFilter.Builder reportOnly() { - this.reportOnly = true; - return this; - } - - @Override - public InetAddressFilter build() { - return new CompositeInetAddressFilter(this.filters, this.reportOnly); - } - - - private record CompositeInetAddressFilter( - List filters, boolean reportOnly) implements InetAddressFilter { - - private static final Log logger = LogFactory.getLog(InetAddressFilter.class); - - private CompositeInetAddressFilter(List filters, boolean reportOnly) { - this.filters = new ArrayList<>(filters); - this.reportOnly = reportOnly; - } - - @Override - public boolean filter(InetAddress address) { - boolean result = doFilter(address); - return (this.reportOnly || result); - } - - private boolean doFilter(InetAddress address) { - for (InetAddressFilter filter : this.filters) { - if (!filter.filter(address)) { - if (logger.isDebugEnabled()) { - logger.debug("InetAddress " + address + " blocked by " + filter); - } - return false; - } - } - return true; - } - } - -} diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/DisallowedInetAddressFilter.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/DisallowedInetAddressFilter.java deleted file mode 100644 index 7ab2a94e3a3..00000000000 --- a/ssrf/src/main/java/org/springframework/security/web/util/matcher/DisallowedInetAddressFilter.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.springframework.security.web.util.matcher; - -import java.net.InetAddress; -import java.util.List; - -final class DisallowedInetAddressFilter implements InetAddressFilter { - - private final List disallowList; - - public DisallowedInetAddressFilter(List disallowList) { - this.disallowList = disallowList.stream().map(IpAddressMatcher::new).toList(); - } - - @Override - public boolean filter(InetAddress address) { - for (IpAddressMatcher matcher : this.disallowList) { - if (matcher.matches(address)) { - return false; - } - } - return true; - } - - @Override - public String toString() { - return "DisallowedInetAddressFilter[\"" + this.disallowList + "\"]"; - } - -} diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/InetAddressFilter.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/InetAddressFilter.java deleted file mode 100644 index 1563c7adaa2..00000000000 --- a/ssrf/src/main/java/org/springframework/security/web/util/matcher/InetAddressFilter.java +++ /dev/null @@ -1,79 +0,0 @@ -package org.springframework.security.web.util.matcher; - -import java.net.InetAddress; -import java.util.List; - -/** - * Component that helps to filter an {@link InetAddress} in or out. - */ -@FunctionalInterface -public interface InetAddressFilter { - - /** - * Whether the given address should be filtered in or out. - * @return {@code true} if the address is allowed for use, and {@code false} - * if it is restricted and should not be used. - */ - boolean filter(InetAddress address); - - - /** - * Return a builder to for a composite {@link InetAddressFilter} that - * delegates to any number of other filters. - */ - static Builder builder() { - return new DefaultInetAddressBuilder(); - } - - - /** - * Builder to create a composite {@link InetAddressFilter}. - */ - interface Builder { - - /** - * Add filter that matches addresses if found in an "allow" list. - * @param addresses the allow list of addresses - * @return the same builder instance - */ - Builder allowList(List addresses); - - /** - * Add filter that matches addresses if not found in a "deny" list. - * @param addresses the deny list of addresses - * @return the same builder instance - */ - Builder denyList(List addresses); - - /** - * Add filter that blocks all external addresses. - * @return the same builder instance - */ - Builder blockExternal(); - - /** - * Add filter that blocks all internal addresses. - * @return the same builder instance - */ - Builder blockInternal(); - - /** - * Add filter with custom logic to match addresses. - * @param filter the filter to add - * @return the same builder instance - */ - Builder customFilter(InetAddressFilter filter); - - /** - * Enable a "report-only" mode that only logs debug messages, and always matches. - * @return the same builder instance - */ - Builder reportOnly(); - - /** - * Return the created composite {@link InetAddressFilter} instance. - */ - InetAddressFilter build(); - } - -} diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/InternalExternalInetAddressFilter.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/InternalExternalInetAddressFilter.java deleted file mode 100644 index 17b6f2e1ea6..00000000000 --- a/ssrf/src/main/java/org/springframework/security/web/util/matcher/InternalExternalInetAddressFilter.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.springframework.security.web.util.matcher; - -import java.net.InetAddress; - -final class InternalExternalInetAddressFilter implements InetAddressFilter { - - private final boolean blockExternal; - - InternalExternalInetAddressFilter(boolean blockExternal) { - this.blockExternal = blockExternal; - } - - public boolean shouldBlockExternal() { - return this.blockExternal; - } - - @Override - public boolean filter(InetAddress address) { - return (this.blockExternal == isLocal(address)); - } - - private static boolean isLocal(InetAddress address) { - if (address.isLoopbackAddress()) { - return true; - } - - byte[] rawAddress = address.getAddress(); - - // there is sadly no Stream support for byte arrays - int[] iAddr = new int[rawAddress.length]; - for (int i = 0; i < rawAddress.length; i++) { - iAddr[i] = Byte.toUnsignedInt(rawAddress[i]); - } - - // Ignoring Multicast addresses - if (address.getAddress().length == 4) { - // IPv4 filtering - // 10.x.x.x , 192.168.x.x , 172.16.x.x - if (iAddr[0] == 10 || - (iAddr[0] == 192 && iAddr[1] == 168) || - (iAddr[0] == 172 && iAddr[1] == 16)) { - return true; - } - - } - else if (address.getAddress().length == 16) { - // IPv6, check for Unique Local Addresses - if (iAddr[0] == 0xfc || iAddr[0] == 0xfd) { - return true; - } - - // IPv4/IPv6 translation, 64:ff9b - if (iAddr[0] == 0x00 && iAddr[1] == 0x64 && iAddr[2] == 0xff && iAddr[3] == 0x9b) { - int[] ipv4Part = new int[] {iAddr[12], iAddr[13], iAddr[14], iAddr[15]}; - // same check as above plus a check for loopback - if (ipv4Part[0] == 10 || ipv4Part[0] == 127 || - (ipv4Part[0] == 192 && ipv4Part[1] == 168) || - (ipv4Part[0] == 172 && ipv4Part[1] == 16)) { - return true; - } - } - } - - return false; - } - - @Override - public String toString() { - return getClass().getSimpleName() + " (" + (this.blockExternal ? "blockExternal" : "blockInternal") + ")"; - } - -} diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/IpAddressMatcher.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/IpAddressMatcher.java deleted file mode 100644 index e4e3197e02b..00000000000 --- a/ssrf/src/main/java/org/springframework/security/web/util/matcher/IpAddressMatcher.java +++ /dev/null @@ -1,70 +0,0 @@ -package org.springframework.security.web.util.matcher; - -import java.net.InetAddress; -import java.net.UnknownHostException; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - -// Inspired by and to be merged back into -// org.springframework.security.web.util.matcher.IpAddressMatcher.java - -class IpAddressMatcher { - - private static final Log logger = LogFactory.getLog(IpAddressMatcher.class); - - private final InetAddress address; - - private final int nMaskBits; - - - public IpAddressMatcher(String addressOrRange) { - if (addressOrRange.indexOf('/') > 0) { - String[] addressAndMask = addressOrRange.split("/"); - address = parse(addressAndMask[0]); - this.nMaskBits = Integer.parseInt(addressAndMask[1]); - } else { - this.nMaskBits = -1; - address = parse(addressOrRange); - } - } - - private static InetAddress parse(String address) { - try { - InetAddress result = InetAddress.getByName(address); - if (address.matches(".*[a-zA-Z\\-].*$") && !address.contains(":")) { - logger.warn("Hostname '" + address + "' resolved to " + result.toString() - + " will be used on IP address matching"); - } - return result; - } catch (UnknownHostException ex) { - throw new IllegalArgumentException(String.format("Failed to parse address '%s'", address), ex); - } - } - - - public boolean matches(InetAddress toCheck) { - if (this.nMaskBits < 0) { - return toCheck.equals(this.address); - } - byte[] remAddr = toCheck.getAddress(); - byte[] reqAddr = this.address.getAddress(); - int nMaskFullBytes = this.nMaskBits / 8; - byte finalByte = (byte) (0xFF00 >> (this.nMaskBits & 0x07)); - for (int i = 0; i < nMaskFullBytes; i++) { - if (remAddr[i] != reqAddr[i]) { - return false; - } - } - if (finalByte != 0) { - return (remAddr[nMaskFullBytes] & finalByte) == (reqAddr[nMaskFullBytes] & finalByte); - } - return true; - } - - @Override - public String toString() { - return "IpAddressMatcher{address=" + this.address + ", nMaskBits=" + this.nMaskBits + '}'; - } - -} diff --git a/ssrf/src/main/java/org/springframework/security/web/util/matcher/package-info.java b/ssrf/src/main/java/org/springframework/security/web/util/matcher/package-info.java deleted file mode 100644 index 55b34c26a2d..00000000000 --- a/ssrf/src/main/java/org/springframework/security/web/util/matcher/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -@NullMarked -package org.springframework.security.web.util.matcher; - -import org.jspecify.annotations.NullMarked; diff --git a/ssrf/src/test/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolverTest.java b/ssrf/src/test/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolverTest.java index cb088257477..5d32937a172 100644 --- a/ssrf/src/test/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolverTest.java +++ b/ssrf/src/test/java/org/springframework/boot/http/client/HttpComponentsFilteringDnsResolverTest.java @@ -6,7 +6,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.web.util.matcher.InetAddressFilter; +import org.springframework.security.web.util.matcher.InetAddressMatcher; import java.net.InetAddress; import java.net.UnknownHostException; @@ -27,13 +27,13 @@ class HttpComponentsFilteringDnsResolverTest { private DnsResolver delegateDnsResolver; @Mock - private InetAddressFilter inetAddressFilter; + private InetAddressMatcher InetAddressMatcher; private HttpComponentsFilteringDnsResolver dnsResolver; @BeforeEach void setUp() { - dnsResolver = new HttpComponentsFilteringDnsResolver(this.delegateDnsResolver, this.inetAddressFilter); + dnsResolver = new HttpComponentsFilteringDnsResolver(this.delegateDnsResolver, this.InetAddressMatcher); } @Test @@ -47,17 +47,17 @@ void resolve_whenDelegateSucceeds_shouldApplyFilterAndReturnFilteredAddresses() List expectedFilteredAddresses = Arrays.asList(address1, address3); when(this.delegateDnsResolver.resolve(host)).thenReturn(resolvedAddresses); - when(this.inetAddressFilter.filter(address1)).thenReturn(true); - when(this.inetAddressFilter.filter(address2)).thenReturn(false); - when(this.inetAddressFilter.filter(address3)).thenReturn(true); + when(this.InetAddressMatcher.matches(address1)).thenReturn(true); + when(this.InetAddressMatcher.matches(address2)).thenReturn(false); + when(this.InetAddressMatcher.matches(address3)).thenReturn(true); InetAddress[] result = this.dnsResolver.resolve(host); assertArrayEquals(expectedFilteredAddresses.toArray(), result); verify(this.delegateDnsResolver, times(1)).resolve(host); - verify(this.inetAddressFilter, times(1)).filter(address1); - verify(this.inetAddressFilter, times(1)).filter(address2); - verify(this.inetAddressFilter, times(1)).filter(address3); + verify(this.InetAddressMatcher, times(1)).matches(address1); + verify(this.InetAddressMatcher, times(1)).matches(address2); + verify(this.InetAddressMatcher, times(1)).matches(address3); } @Test @@ -66,7 +66,7 @@ void resolve_whenDelegateFails_shouldPropagateFailure() throws UnknownHostExcept when(this.delegateDnsResolver.resolve(host)).thenThrow(new UnknownHostException("Host not found")); assertThrows(UnknownHostException.class, () -> this.dnsResolver.resolve(host)); - verify(this.inetAddressFilter, times(0)).filter(any()); // Filter should not be called + verify(this.InetAddressMatcher, times(0)).matches(any(InetAddress.class)); // Filter should not be called } @Test @@ -78,15 +78,15 @@ void resolve_whenNoAddressesAreAllowed_shouldReturnEmptyArray() throws UnknownHo InetAddress[] resolvedAddresses = new InetAddress[]{address1, address2}; when(this.delegateDnsResolver.resolve(host)).thenReturn(resolvedAddresses); - when(this.inetAddressFilter.filter(address1)).thenReturn(false); - when(this.inetAddressFilter.filter(address2)).thenReturn(false); + when(this.InetAddressMatcher.matches(address1)).thenReturn(false); + when(this.InetAddressMatcher.matches(address2)).thenReturn(false); InetAddress[] result = this.dnsResolver.resolve(host); assertEquals(0, result.length); verify(this.delegateDnsResolver, times(1)).resolve(host); - verify(this.inetAddressFilter, times(1)).filter(address1); - verify(this.inetAddressFilter, times(1)).filter(address2); + verify(this.InetAddressMatcher, times(1)).matches(address1); + verify(this.InetAddressMatcher, times(1)).matches(address2); } @Test @@ -99,7 +99,7 @@ void resolveCanonicalHostname_shouldDelegateWithoutFiltering() throws UnknownHos assertEquals(canonicalHost, result); verify(this.delegateDnsResolver, times(1)).resolveCanonicalHostname(host); - verify(this.inetAddressFilter, times(0)).filter(any()); // Filter should not be called + verify(this.InetAddressMatcher, times(0)).matches(any(InetAddress.class)); // Filter should not be called } @Test @@ -108,7 +108,7 @@ void resolveCanonicalHostname_whenDelegateFails_shouldPropagateFailure() throws when(this.delegateDnsResolver.resolveCanonicalHostname(host)).thenThrow(new UnknownHostException("Host not found")); assertThrows(UnknownHostException.class, () -> this.dnsResolver.resolveCanonicalHostname(host)); - verify(this.inetAddressFilter, times(0)).filter(any()); // Filter should not be called + verify(this.InetAddressMatcher, times(0)).matches(any(InetAddress.class)); // Filter should not be called } } diff --git a/ssrf/src/test/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolverTest.java b/ssrf/src/test/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolverTest.java index 577a83e33ce..72617306f6a 100644 --- a/ssrf/src/test/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolverTest.java +++ b/ssrf/src/test/java/org/springframework/boot/http/client/JettyHttpClientFilteringDnsResolverTest.java @@ -17,7 +17,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.web.util.matcher.InetAddressFilter; +import org.springframework.security.web.util.matcher.InetAddressMatcher; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -37,7 +37,7 @@ class JettyHttpClientFilteringDnsResolverTest { private SocketAddressResolver mockDelegateResolver; @Mock - private InetAddressFilter mockFilter; + private InetAddressMatcher mockFilter; @Mock private Promise> mockResultPromise; @@ -76,7 +76,7 @@ void resolve_whenDelegateSucceeds_shouldApplySecurityHandlerAndReturnFilteredAdd // 1. Configure mock SecurityDnsHandler // When handleInetSocketAddresses is called with the initial list, return the filtered list for (InetSocketAddress address : initialAddresses) { - when(mockFilter.filter(address.getAddress())) + when(mockFilter.matches(address.getAddress())) .thenReturn(filteredAddresses.contains(address)); } @@ -93,7 +93,7 @@ void resolve_whenDelegateSucceeds_shouldApplySecurityHandlerAndReturnFilteredAdd capturedPromiseForDelegate.succeeded(initialAddresses); // 5. Verify that SecurityDnsHandler.handleInetSocketAddresses was called for (InetSocketAddress address : initialAddresses) { - verify(mockFilter).filter(address.getAddress()); + verify(mockFilter).matches(address.getAddress()); } // 6. Verify that the original mockResultPromise.succeeded was called with the filtered list verify(mockResultPromise).succeeded(resultListCaptor.capture()); diff --git a/ssrf/src/test/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelectorTest.java b/ssrf/src/test/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelectorTest.java index 3e388f65a38..ee9ddb7a877 100644 --- a/ssrf/src/test/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelectorTest.java +++ b/ssrf/src/test/java/org/springframework/boot/http/client/NettyHttpClientFilteringAddressSelectorTest.java @@ -21,7 +21,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.web.util.matcher.InetAddressFilter; +import org.springframework.security.web.util.matcher.InetAddressMatcher; import reactor.netty.transport.ClientTransportConfig; import java.net.InetAddress; @@ -47,7 +47,7 @@ class NettyHttpClientFilteringAddressSelectorTest { private ClientTransportConfig mockConfig; @Mock - private InetAddressFilter inetAddressFilter; + private InetAddressMatcher inetAddressFilter; private NettyHttpClientFilteringAddressSelector selector; @@ -69,14 +69,14 @@ private InetSocketAddress createInetSocketAddress(String hostname, int port) { void selectWhenEmptyListShouldReturnEmptyList() { List result = selector.select(mockConfig, Collections::emptyList); assertTrue(result.isEmpty()); - verify(this.inetAddressFilter, never()).filter(any()); + verify(this.inetAddressFilter, never()).matches(any(InetAddress.class)); } @Test void selectWhenNullListShouldReturnNull() { List result = selector.select(mockConfig, () -> null); assertNull(result); - verify(this.inetAddressFilter, never()).filter(any()); + verify(this.inetAddressFilter, never()).matches(any(InetAddress.class)); } @Test @@ -85,8 +85,8 @@ void selectWhenAllAddressesAreAllowed() { InetSocketAddress address2 = createInetSocketAddress("8.8.8.8", 80); List addresses = List.of(address1, address2); - when(this.inetAddressFilter.filter(address1.getAddress())).thenReturn(true); - when(this.inetAddressFilter.filter(address2.getAddress())).thenReturn(true); + when(this.inetAddressFilter.matches(address1.getAddress())).thenReturn(true); + when(this.inetAddressFilter.matches(address2.getAddress())).thenReturn(true); List result = selector.select(mockConfig, () -> addresses); List resultStrings = result.stream() @@ -96,8 +96,8 @@ void selectWhenAllAddressesAreAllowed() { .map(sa -> ((InetSocketAddress) sa).getAddress().getHostAddress() + ":" + ((InetSocketAddress) sa).getPort()) .collect(java.util.stream.Collectors.toList()); assertEquals(expectedStrings, resultStrings); - verify(this.inetAddressFilter, times(1)).filter(address1.getAddress()); - verify(this.inetAddressFilter, times(1)).filter(address2.getAddress()); + verify(this.inetAddressFilter, times(1)).matches(address1.getAddress()); + verify(this.inetAddressFilter, times(1)).matches(address2.getAddress()); } @Test @@ -107,9 +107,9 @@ void selectWhenSomeAddressesAreDenied() { InetSocketAddress anotherAllowedAddress = createInetSocketAddress("1.0.0.1", 80); List addresses = List.of(allowedAddress, deniedAddress, anotherAllowedAddress); - when(this.inetAddressFilter.filter(allowedAddress.getAddress())).thenReturn(true); - when(this.inetAddressFilter.filter(deniedAddress.getAddress())).thenReturn(false); - when(this.inetAddressFilter.filter(anotherAllowedAddress.getAddress())).thenReturn(true); + when(this.inetAddressFilter.matches(allowedAddress.getAddress())).thenReturn(true); + when(this.inetAddressFilter.matches(deniedAddress.getAddress())).thenReturn(false); + when(this.inetAddressFilter.matches(anotherAllowedAddress.getAddress())).thenReturn(true); List result = selector.select(mockConfig, () -> addresses); List resultStrings = result.stream() @@ -119,9 +119,9 @@ void selectWhenSomeAddressesAreDenied() { .map(sa -> ((InetSocketAddress) sa).getAddress().getHostAddress() + ":" + ((InetSocketAddress) sa).getPort()) .collect(java.util.stream.Collectors.toList()); assertEquals(expectedStrings, resultStrings); - verify(this.inetAddressFilter, times(1)).filter(allowedAddress.getAddress()); - verify(this.inetAddressFilter, times(1)).filter(deniedAddress.getAddress()); - verify(this.inetAddressFilter, times(1)).filter(anotherAllowedAddress.getAddress()); + verify(this.inetAddressFilter, times(1)).matches(allowedAddress.getAddress()); + verify(this.inetAddressFilter, times(1)).matches(deniedAddress.getAddress()); + verify(this.inetAddressFilter, times(1)).matches(anotherAllowedAddress.getAddress()); } @Test @@ -130,8 +130,8 @@ void selectWhenAllAddressesAreDenied() { InetSocketAddress deniedAddress2 = createInetSocketAddress("8.8.8.8", 80); List addresses = List.of(deniedAddress1, deniedAddress2); - when(this.inetAddressFilter.filter(deniedAddress1.getAddress())).thenReturn(false); - when(this.inetAddressFilter.filter(deniedAddress2.getAddress())).thenReturn(false); + when(this.inetAddressFilter.matches(deniedAddress1.getAddress())).thenReturn(false); + when(this.inetAddressFilter.matches(deniedAddress2.getAddress())).thenReturn(false); List result = selector.select(mockConfig, () -> addresses); List resultStrings = result.stream() @@ -139,8 +139,8 @@ void selectWhenAllAddressesAreDenied() { .collect(java.util.stream.Collectors.toList()); List expectedStrings = Collections.emptyList(); // All denied, so expected is empty assertEquals(expectedStrings, resultStrings); - verify(this.inetAddressFilter, times(1)).filter(deniedAddress1.getAddress()); - verify(this.inetAddressFilter, times(1)).filter(deniedAddress2.getAddress()); + verify(this.inetAddressFilter, times(1)).matches(deniedAddress1.getAddress()); + verify(this.inetAddressFilter, times(1)).matches(deniedAddress2.getAddress()); } @@ -150,14 +150,14 @@ void selectWithNonInetSocketAddressShouldReturnOriginalIfFilterPasses() { InetSocketAddress allowedAddress = createInetSocketAddress("1.1.1.1", 80); List addresses = List.of(nonInetAddress, allowedAddress); - when(this.inetAddressFilter.filter(allowedAddress.getAddress())).thenReturn(true); + when(this.inetAddressFilter.matches(allowedAddress.getAddress())).thenReturn(true); List result = selector.select(mockConfig, () -> addresses); assertEquals(2, result.size()); assertTrue(result.contains(nonInetAddress)); assertTrue(result.contains(allowedAddress)); - verify(this.inetAddressFilter, times(1)).filter(allowedAddress.getAddress()); + verify(this.inetAddressFilter, times(1)).matches(allowedAddress.getAddress()); } @Test @@ -168,16 +168,16 @@ void selectWithMixedAddressTypesAndFiltering() { List addresses = List.of(allowedAddress, deniedAddress, nonInetAddress); - when(this.inetAddressFilter.filter(allowedAddress.getAddress())).thenReturn(true); - when(this.inetAddressFilter.filter(deniedAddress.getAddress())).thenReturn(false); + when(this.inetAddressFilter.matches(allowedAddress.getAddress())).thenReturn(true); + when(this.inetAddressFilter.matches(deniedAddress.getAddress())).thenReturn(false); List result = selector.select(mockConfig, () -> addresses); assertEquals(2, result.size()); assertTrue(result.contains(nonInetAddress)); assertTrue(result.contains(allowedAddress)); - verify(this.inetAddressFilter, times(1)).filter(allowedAddress.getAddress()); - verify(this.inetAddressFilter, times(1)).filter(deniedAddress.getAddress()); + verify(this.inetAddressFilter, times(1)).matches(allowedAddress.getAddress()); + verify(this.inetAddressFilter, times(1)).matches(deniedAddress.getAddress()); } } diff --git a/ssrf/src/test/java/org/springframework/security/web/util/matcher/InetAddressFilterBuilderTests.java b/ssrf/src/test/java/org/springframework/security/web/util/matcher/InetAddressFilterBuilderTests.java deleted file mode 100644 index 3a7effbbdc0..00000000000 --- a/ssrf/src/test/java/org/springframework/security/web/util/matcher/InetAddressFilterBuilderTests.java +++ /dev/null @@ -1,108 +0,0 @@ -package org.springframework.security.web.util.matcher; - -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.util.List; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Unit tests for {@link InetAddressFilter.Builder}. - */ -public class InetAddressFilterBuilderTests { - - private static final InetAddress INTERNAL_IP_1 = initAddress("192.168.1.1"); - private static final InetAddress INTERNAL_IP_2 = initAddress("10.0.0.1"); - private static final InetAddress EXTERNAL_IP_1 = initAddress("8.8.8.8"); - private static final InetAddress EXTERNAL_IP_2 = initAddress("1.1.1.1"); - private static final InetAddress LOCALHOST_IP = initAddress("127.0.0.1"); - - private static InetAddress initAddress(String address) { - try { - return InetAddress.getByName(address); - } - catch (UnknownHostException ex) { - throw new IllegalArgumentException(ex); - } - } - - - @Test - void defaultAllowsAll() { - InetAddressFilter filter = InetAddressFilter.builder().build(); - - assertTrue(filter.filter(INTERNAL_IP_1)); - assertTrue(filter.filter(EXTERNAL_IP_1)); - } - - @Test - void allowList() { - InetAddressFilter filter = InetAddressFilter.builder().allowList(List.of(EXTERNAL_IP_1.getHostAddress())).build(); - - assertTrue(filter.filter(EXTERNAL_IP_1)); - assertFalse(filter.filter(EXTERNAL_IP_2)); - } - - - @Test - void denyList() { - InetAddressFilter filter = InetAddressFilter.builder().denyList(List.of(EXTERNAL_IP_1.getHostAddress())).build(); - - assertFalse(filter.filter(EXTERNAL_IP_1)); - assertTrue(filter.filter(EXTERNAL_IP_2)); - } - - @Test - void blockExternal() { - InetAddressFilter filter = InetAddressFilter.builder().blockExternal().build(); - - assertTrue(filter.filter(INTERNAL_IP_1)); - assertTrue(filter.filter(LOCALHOST_IP), "Loopback is not external"); - - assertFalse(filter.filter(EXTERNAL_IP_1)); - assertFalse(filter.filter(EXTERNAL_IP_2)); - } - - @Test - void blockInternal() { - InetAddressFilter filter = InetAddressFilter.builder().blockInternal().build(); - - assertTrue(filter.filter(EXTERNAL_IP_1)); - assertFalse(filter.filter(INTERNAL_IP_1)); - assertFalse(filter.filter(INTERNAL_IP_2)); - assertFalse(filter.filter(LOCALHOST_IP), "Loopback should be blocked"); - } - - @Test - void blockInternalExternalAreMutuallyExclusive() { - assertThrows(IllegalArgumentException.class, - () -> InetAddressFilter.builder().blockExternal().blockInternal(), - "blockExternal and blockInternal are mutually exclusive options"); - } - - @Test - void customFilter() { - InetAddressFilter filter = InetAddressFilter.builder().customFilter(EXTERNAL_IP_1::equals).build(); - - assertTrue(filter.filter(EXTERNAL_IP_1)); - assertFalse(filter.filter(EXTERNAL_IP_2)); - } - - @Test - void reportOnly() { - InetAddressFilter.Builder builder = InetAddressFilter.builder().customFilter(EXTERNAL_IP_1::equals); - - InetAddressFilter filter = builder.build(); - assertTrue(filter.filter(EXTERNAL_IP_1)); - assertFalse(filter.filter(EXTERNAL_IP_2)); - - filter = builder.reportOnly().build(); - assertTrue(filter.filter(EXTERNAL_IP_1)); - assertTrue(filter.filter(EXTERNAL_IP_2)); - } - -} diff --git a/ssrf/src/test/java/org/springframework/security/web/util/matcher/InetAddressMatchersBuilderTests.java b/ssrf/src/test/java/org/springframework/security/web/util/matcher/InetAddressMatchersBuilderTests.java new file mode 100644 index 00000000000..cc1db167c3a --- /dev/null +++ b/ssrf/src/test/java/org/springframework/security/web/util/matcher/InetAddressMatchersBuilderTests.java @@ -0,0 +1,101 @@ +package org.springframework.security.web.util.matcher; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link InetAddressMatchers.Builder}. + */ +class InetAddressMatchersBuilderTests { + + private static final InetAddress INTERNAL_IP_1 = initAddress("192.168.1.1"); + private static final InetAddress INTERNAL_IP_2 = initAddress("10.0.0.1"); + private static final InetAddress EXTERNAL_IP_1 = initAddress("8.8.8.8"); + private static final InetAddress EXTERNAL_IP_2 = initAddress("1.1.1.1"); + private static final InetAddress LOCALHOST_IP = initAddress("127.0.0.1"); + + private static InetAddress initAddress(String address) { + try { + return InetAddress.getByName(address); + } + catch (UnknownHostException ex) { + throw new IllegalArgumentException(ex); + } + } + + + @Test + void defaultAllowsAll() { + InetAddressMatcher filter = InetAddressMatchers.builder().build(); + + assertTrue(filter.matches(INTERNAL_IP_1)); + assertTrue(filter.matches(EXTERNAL_IP_1)); + } + + @Test + void allowList() { + InetAddressMatcher filter = InetAddressMatchers.builder().allowAddresses(List.of(EXTERNAL_IP_1.getHostAddress())).build(); + + assertTrue(filter.matches(EXTERNAL_IP_1)); + assertFalse(filter.matches(EXTERNAL_IP_2)); + } + + + @Test + void denyList() { + InetAddressMatcher filter = InetAddressMatchers.builder().denyAddresses(List.of(EXTERNAL_IP_1.getHostAddress())).build(); + + assertFalse(filter.matches(EXTERNAL_IP_1)); + assertTrue(filter.matches(EXTERNAL_IP_2)); + } + + @Test + void blockExternal() { + InetAddressMatcher filter = InetAddressMatchers.matchInternal().build(); + + assertTrue(filter.matches(INTERNAL_IP_1)); + assertTrue(filter.matches(LOCALHOST_IP), "Loopback is not external"); + + assertFalse(filter.matches(EXTERNAL_IP_1)); + assertFalse(filter.matches(EXTERNAL_IP_2)); + } + + @Test + void blockInternal() { + InetAddressMatcher filter = InetAddressMatchers.matchExternal().build(); + + assertTrue(filter.matches(EXTERNAL_IP_1)); + assertFalse(filter.matches(INTERNAL_IP_1)); + assertFalse(filter.matches(INTERNAL_IP_2)); + assertFalse(filter.matches(LOCALHOST_IP), "Loopback should be blocked"); + } + + @Test + void customFilter() { + InetAddressMatcher filter = InetAddressMatchers.builder().allowList(EXTERNAL_IP_1::equals).build(); + + assertTrue(filter.matches(EXTERNAL_IP_1)); + assertFalse(filter.matches(EXTERNAL_IP_2)); + } + + @Test + void reportOnly() { + InetAddressMatchers.Builder builder = InetAddressMatchers.builder().allowList(EXTERNAL_IP_1::equals); + + InetAddressMatcher filter = builder.build(); + assertTrue(filter.matches(EXTERNAL_IP_1)); + assertFalse(filter.matches(EXTERNAL_IP_2)); + + filter = builder.reportOnly().build(); + assertTrue(filter.matches(EXTERNAL_IP_1)); + assertTrue(filter.matches(EXTERNAL_IP_2)); + } + +} diff --git a/ssrf/src/test/java/org/springframework/security/web/util/matcher/UsageExample.java b/ssrf/src/test/java/org/springframework/security/web/util/matcher/UsageExample.java index bce2876d062..7a9f7ea5ad2 100644 --- a/ssrf/src/test/java/org/springframework/security/web/util/matcher/UsageExample.java +++ b/ssrf/src/test/java/org/springframework/security/web/util/matcher/UsageExample.java @@ -28,7 +28,7 @@ public class UsageExample { public static void exampleApache() { System.out.println("Example Apache - Apache HttpClient with HttpComponentsDnsResolver"); - InetAddressFilter filter = InetAddressFilter.builder().blockExternal().build(); + InetAddressMatcher filter = InetAddressMatchers.matchExternal().build(); HttpComponentsFilteringDnsResolver dnsResolver = new HttpComponentsFilteringDnsResolver(filter); Registry factoryRegistry = RegistryBuilder.create() @@ -63,9 +63,8 @@ static void main(String[] args) { public static void exampleJetty() { System.out.println("\nExample Jetty - Jetty HttpClient with JettyHttpClientDnsResolver"); - InetAddressFilter filter = InetAddressFilter.builder() - .denyList(List.of("192.168.1.100")) // Example: deny a specific private IP - .blockInternal() // Example: block all internal IPs (like 127.0.0.1) + InetAddressMatcher filter = InetAddressMatchers.matchInternal() // Example: block all internal IPs (like 127.0.0.1) + .denyAddresses(List.of("192.168.1.100")) // Example: deny a specific private IP .build(); JettyHttpClientFilteringDnsResolver dnsResolver = new JettyHttpClientFilteringDnsResolver(filter); @@ -111,9 +110,8 @@ public static void exampleNetty() { System.out.println("\nExample Netty - Reactor Netty HttpClient with NettyHttpClientAddressSelector\n"); // 1. Create SecurityDnsHandler with custom logic - InetAddressFilter filter = InetAddressFilter.builder() - .blockExternal() - .allowList(List.of("1.1.1.1", "8.8.4.4","google.com")) // Example: only allow specific public IPs + InetAddressMatcher filter = InetAddressMatchers.matchExternal() + .allowAddresses(List.of("1.1.1.1", "8.8.4.4","google.com")) // Example: only allow specific public IPs .build(); // 2. Create NettyHttpClientAddressSelector with the custom SecurityDnsHandler