From 40cf337b23444a3204b6707a3c423accf1397ffa Mon Sep 17 00:00:00 2001 From: Ludy Date: Sun, 24 Aug 2025 23:08:29 +0200 Subject: [PATCH] feat(ssrf): enhance private IP detection and IPv6 handling (#4191) # Description of Changes - Refactored `isPrivateAddress` to improve detection of private and local addresses for both IPv4 and IPv6. - Added explicit handling for: - IPv4-mapped IPv6 addresses - IPv6 link-local, site-local, and unique local (fc00::/7) addresses - Additional IPv4 private ranges such as link-local (169.254.0.0/16) - Introduced `normalizeIpv4MappedAddress` to standardize IP checks in cloud metadata detection. - Replaced `switch` statement with modern `switch` expression for cleaner control flow. These changes were made to strengthen SSRF protection by covering more address edge cases, especially in mixed IPv4/IPv6 environments. This also improves detection of cloud metadata endpoints when accessed via IPv4-mapped IPv6 addresses. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../common/service/SsrfProtectionService.java | 94 +++++++++++++++---- 1 file changed, 76 insertions(+), 18 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java b/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java index 97c2da12e..b58e0d516 100644 --- a/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java +++ b/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java @@ -1,5 +1,7 @@ package stirling.software.common.service; +import java.net.Inet4Address; +import java.net.Inet6Address; import java.net.InetAddress; import java.net.URI; import java.net.UnknownHostException; @@ -51,16 +53,12 @@ public class SsrfProtectionService { SsrfProtectionLevel level = parseProtectionLevel(config.getLevel()); - switch (level) { - case OFF: - return true; - case MAX: - return isMaxSecurityAllowed(trimmedUrl, config); - case MEDIUM: - return isMediumSecurityAllowed(trimmedUrl, config); - default: - return false; - } + return switch (level) { + case OFF -> true; + case MAX -> isMaxSecurityAllowed(trimmedUrl, config); + case MEDIUM -> isMediumSecurityAllowed(trimmedUrl, config); + default -> false; + }; } private SsrfProtectionLevel parseProtectionLevel(String level) { @@ -172,15 +170,61 @@ public class SsrfProtectionService { } private boolean isPrivateAddress(InetAddress address) { - return address.isSiteLocalAddress() - || address.isAnyLocalAddress() - || isPrivateIPv4Range(address.getHostAddress()); + if (address.isAnyLocalAddress() || address.isLoopbackAddress()) { + return true; + } + + if (address instanceof Inet4Address) { + return isPrivateIPv4Range(address.getHostAddress()); + } + + if (address instanceof Inet6Address addr6) { + if (addr6.isLinkLocalAddress() || addr6.isSiteLocalAddress()) { + return true; + } + + byte[] bytes = addr6.getAddress(); + if (isIpv4MappedAddress(bytes)) { + String ipv4 = + (bytes[12] & 0xff) + + "." + + (bytes[13] & 0xff) + + "." + + (bytes[14] & 0xff) + + "." + + (bytes[15] & 0xff); + return isPrivateIPv4Range(ipv4); + } + + int firstByte = bytes[0] & 0xff; + // Check for IPv6 unique local addresses (fc00::/7) + if ((firstByte & 0xfe) == 0xfc) { + return true; + } + } + + return false; + } + + private boolean isIpv4MappedAddress(byte[] addr) { + if (addr.length != 16) { + return false; + } + for (int i = 0; i < 10; i++) { + if (addr[i] != 0) { + return false; + } + } + // For IPv4-mapped IPv6 addresses, bytes 10 and 11 must be 0xff (i.e., address is ::ffff:w.x.y.z) + return addr[10] == (byte) 0xff && addr[11] == (byte) 0xff; } private boolean isPrivateIPv4Range(String ip) { + // Includes RFC1918, loopback, link-local, and unspecified addresses return ip.startsWith("10.") || ip.startsWith("192.168.") || (ip.startsWith("172.") && isInRange172(ip)) + || ip.startsWith("169.254.") || ip.startsWith("127.") || "0.0.0.0".equals(ip); } @@ -192,17 +236,31 @@ public class SsrfProtectionService { int secondOctet = Integer.parseInt(parts[1]); return secondOctet >= 16 && secondOctet <= 31; } catch (NumberFormatException e) { - return false; } } return false; } private boolean isCloudMetadataAddress(String ip) { + String normalizedIp = normalizeIpv4MappedAddress(ip); // Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud - return ip.startsWith("169.254.169.254") // AWS/GCP/Azure - || ip.startsWith("fd00:ec2::254") // AWS IPv6 - || ip.startsWith("169.254.169.253") // Oracle Cloud - || ip.startsWith("169.254.169.250"); // IBM Cloud + return normalizedIp.startsWith("169.254.169.254") // AWS/GCP/Azure + || normalizedIp.startsWith("fd00:ec2::254") // AWS IPv6 + || normalizedIp.startsWith("169.254.169.253") // Oracle Cloud + || normalizedIp.startsWith("169.254.169.250"); // IBM Cloud + } + + private String normalizeIpv4MappedAddress(String ip) { + if (ip == null) { + return ""; + } + if (ip.startsWith("::ffff:")) { + return ip.substring(7); + } + int lastColon = ip.lastIndexOf(':'); + if (lastColon >= 0 && ip.indexOf('.') > lastColon) { + return ip.substring(lastColon + 1); + } + return ip; } }