From e51ae2db067b69328d0f875d8af60f040c1417e8 Mon Sep 17 00:00:00 2001 From: Milosz Tarka Date: Thu, 4 Jan 2024 10:49:42 +0100 Subject: [PATCH 1/4] SWG-9288 utilizing safe url resolver for OAS 2.0 --- .../README.md | 71 +++++ .../swagger-parser-safe-url-resolver/pom.xml | 47 +++ .../urlresolver/PermittedUrlsChecker.java | 106 +++++++ .../exceptions/HostDeniedException.java | 11 + .../matchers/UrlPatternMatcher.java | 60 ++++ .../urlresolver/models/ResolvedUrl.java | 36 +++ .../v3/parser/urlresolver/utils/NetUtils.java | 90 ++++++ .../urlresolver/PermittedUrlsCheckerTest.java | 283 ++++++++++++++++++ .../matchers/UrlPatternMatcherTest.java | 129 ++++++++ .../urlresolver/utils/NetUtilsTest.java | 138 +++++++++ modules/swagger-parser/pom.xml | 5 + .../java/io/swagger/parser/ResolverCache.java | 44 ++- .../java/io/swagger/parser/SwaggerParser.java | 2 +- .../io/swagger/parser/SwaggerResolver.java | 11 + .../io/swagger/parser/util/ParseOptions.java | 29 ++ pom.xml | 3 +- 16 files changed, 1059 insertions(+), 6 deletions(-) create mode 100644 modules/swagger-parser-safe-url-resolver/README.md create mode 100644 modules/swagger-parser-safe-url-resolver/pom.xml create mode 100644 modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/PermittedUrlsChecker.java create mode 100644 modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/exceptions/HostDeniedException.java create mode 100644 modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/matchers/UrlPatternMatcher.java create mode 100644 modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/models/ResolvedUrl.java create mode 100644 modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/utils/NetUtils.java create mode 100644 modules/swagger-parser-safe-url-resolver/src/test/java/io/swagger/v3/parser/urlresolver/PermittedUrlsCheckerTest.java create mode 100644 modules/swagger-parser-safe-url-resolver/src/test/java/io/swagger/v3/parser/urlresolver/matchers/UrlPatternMatcherTest.java create mode 100644 modules/swagger-parser-safe-url-resolver/src/test/java/io/swagger/v3/parser/urlresolver/utils/NetUtilsTest.java diff --git a/modules/swagger-parser-safe-url-resolver/README.md b/modules/swagger-parser-safe-url-resolver/README.md new file mode 100644 index 0000000000..d6bbc29462 --- /dev/null +++ b/modules/swagger-parser-safe-url-resolver/README.md @@ -0,0 +1,71 @@ +# Swagger Parser Safe URL Resolver + +The `swagger-parser-safe-url-resolver` is a library used for verifying that the hostname of URLs does not resolve to a private/restricted IPv4/IPv6 address range. +This library can be used in services that deal with user-submitted URLs that get fetched (like in swagger-parser when resolving external URL $refs) to protect against Server-Side Request Forgery and DNS rebinding attacks. + +## How does it work? +The main class of the package is the `PermittedUrlsChecker` which has one method: `verify(String url)`. +This method takes in a string URL and performs the following steps: + +1. Gets the hostname portion from the URL +2. Resolves the hostname to an IP address +3. Checks if that IP address is in a private/restricted IP address range (and throws an exception if it is) +4. Returns a `ResolvedUrl` object which contains + 4.1. `String url` where the original URL has the hostname replaced with the IP address + 4.2. A `String hostHeader` which contains the hostname from the original URL to be added as a host header + +This behavior can also be customized with the allowlist and denylist in the constructor, whereby: + +- An entry in the allowlist will allow the URL to pass even if it resolves to a private/restricted IP address +- An entry in the denylist will throw an exception even when the URL resolves to a public IP address + +## Installation +Add the following to you `pom.xml` file under `dependencies` +```xml + + io.swagger.parser.v3 + swagger-parser-safe-url-resolver + // version of swagger-parser being used + 2.1.14 + +``` + +## Example usage + +```java +import io.swagger.v3.parser.urlresolver.PermittedUrlsChecker; +import io.swagger.v3.parser.urlresolver.exceptions.HostDeniedException; +import io.swagger.v3.parser.urlresolver.models.ResolvedUrl; + +import java.util.List; + +public class Main { + public static void main() { + List allowlist = List.of("mysite.local"); + List denylist = List.of("*.example.com:443"); + var checker = new PermittedUrlsChecker(allowlist, denylist); + + try { + // Will throw a HostDeniedException as `localhost` + // resolves to local IP and is not in allowlist + checker.verify("http://localhost/example"); + + // Will return a ResolvedUrl if `github.com` + // resolves to a public IP + checker.verify("https://github.com/swagger-api/swagger-parser"); + + // Will throw a HostDeniedException as `*.example.com` is + // explicitly deny listed, even if it resolves to public IP + checker.verify("https://subdomain.example.com/somepage"); + + // Will return a `ResolvedUrl` as `mysite.local` + // is explicitly allowlisted + ResolvedUrl resolvedUrl = checker.verify("http://mysite.local/example"); + System.out.println(resolvedUrl.getUrl()); // "http://127.0.0.1/example" + System.out.println(resolvedUrl.getHostHeader()); // "mysite.local" + } catch (HostDeniedException e) { + e.printStackTrace(); + } + } +} +``` \ No newline at end of file diff --git a/modules/swagger-parser-safe-url-resolver/pom.xml b/modules/swagger-parser-safe-url-resolver/pom.xml new file mode 100644 index 0000000000..838c1d7dd8 --- /dev/null +++ b/modules/swagger-parser-safe-url-resolver/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + io.swagger + swagger-parser-project + 1.0.69-SNAPSHOT + ../.. + + + + swagger-parser-safe-url-resolver + + + + commons-io + commons-io + ${commons-io-version} + + + org.slf4j + slf4j-simple + ${slf4j-version} + test + + + org.testng + testng + ${testng-version} + test + + + junit + junit + ${junit-version} + test + + + org.jmockit + jmockit + ${jmockit-version} + test + + + \ No newline at end of file diff --git a/modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/PermittedUrlsChecker.java b/modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/PermittedUrlsChecker.java new file mode 100644 index 0000000000..7fb5ca2b50 --- /dev/null +++ b/modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/PermittedUrlsChecker.java @@ -0,0 +1,106 @@ +package io.swagger.v3.parser.urlresolver; + +import io.swagger.v3.parser.urlresolver.exceptions.HostDeniedException; +import io.swagger.v3.parser.urlresolver.matchers.UrlPatternMatcher; +import io.swagger.v3.parser.urlresolver.models.ResolvedUrl; +import io.swagger.v3.parser.urlresolver.utils.NetUtils; + +import java.net.InetAddress; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.List; + +public class PermittedUrlsChecker { + + protected final UrlPatternMatcher allowlistMatcher; + protected final UrlPatternMatcher denylistMatcher; + + public PermittedUrlsChecker() { + this.allowlistMatcher = new UrlPatternMatcher(Collections.emptyList()); + this.denylistMatcher = new UrlPatternMatcher(Collections.emptyList()); + } + + public PermittedUrlsChecker(List allowlist, List denylist) { + if(allowlist != null) { + this.allowlistMatcher = new UrlPatternMatcher(allowlist); + } else { + this.allowlistMatcher = new UrlPatternMatcher(Collections.emptyList()); + } + + if(denylist != null) { + this.denylistMatcher = new UrlPatternMatcher(denylist); + } else { + this.denylistMatcher = new UrlPatternMatcher(Collections.emptyList()); + } + } + + public ResolvedUrl verify(String url) throws HostDeniedException { + URL parsed; + + try { + parsed = new URL(url); + } catch (MalformedURLException e) { + throw new HostDeniedException(String.format("Failed to parse URL. URL [%s]", url), e); + } + + if (!parsed.getProtocol().equals("http") && !parsed.getProtocol().equals("https")) { + throw new HostDeniedException(String.format("URL does not use a supported protocol. URL [%s]", url)); + } + + String hostname; + try { + hostname = NetUtils.getHostFromUrl(url); + } catch (MalformedURLException e) { + throw new HostDeniedException(String.format("Failed to get hostname from URL. URL [%s]", url), e); + } + + if (this.allowlistMatcher.matches(url)) { + return new ResolvedUrl(url, hostname); + } + + if (this.denylistMatcher.matches(url)) { + throw new HostDeniedException(String.format("URL is part of the explicit denylist. URL [%s]", url)); + } + + InetAddress ip; + try { + ip = NetUtils.getHostByName(hostname); + } catch (UnknownHostException e) { + throw new HostDeniedException( + String.format("Failed to resolve IP from hostname. Hostname [%s]", hostname), e); + } + + String urlWithIp; + try { + urlWithIp = NetUtils.setHost(url, ip.getHostAddress()); + } catch (MalformedURLException e) { + throw new HostDeniedException( + String.format("Failed to create new URL with IP. IP [%s] URL [%s]", ip.getHostAddress(), url), e); + } + + if (this.allowlistMatcher.matches(urlWithIp)) { + return new ResolvedUrl(urlWithIp, hostname); + } + + if (isRestrictedIpRange(ip)) { + throw new HostDeniedException(String.format("IP is restricted. URL [%s]", urlWithIp)); + } + + if (this.denylistMatcher.matches(urlWithIp)) { + throw new HostDeniedException(String.format("IP is part of the explicit denylist. URL [%s]", urlWithIp)); + } + + return new ResolvedUrl(urlWithIp, hostname); + } + + protected boolean isRestrictedIpRange(InetAddress ip) { + return ip.isLinkLocalAddress() + || ip.isSiteLocalAddress() + || ip.isLoopbackAddress() + || ip.isAnyLocalAddress() + || NetUtils.isUniqueLocalAddress(ip) + || NetUtils.isNAT64Address(ip); + } +} diff --git a/modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/exceptions/HostDeniedException.java b/modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/exceptions/HostDeniedException.java new file mode 100644 index 0000000000..ed402001f0 --- /dev/null +++ b/modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/exceptions/HostDeniedException.java @@ -0,0 +1,11 @@ +package io.swagger.v3.parser.urlresolver.exceptions; + +public class HostDeniedException extends Exception { + public HostDeniedException(String message) { + super(message); + } + + public HostDeniedException(String message, Throwable e) { + super(message, e); + } +} diff --git a/modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/matchers/UrlPatternMatcher.java b/modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/matchers/UrlPatternMatcher.java new file mode 100644 index 0000000000..a0c70fcc2e --- /dev/null +++ b/modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/matchers/UrlPatternMatcher.java @@ -0,0 +1,60 @@ +package io.swagger.v3.parser.urlresolver.matchers; + +import io.swagger.v3.parser.urlresolver.utils.NetUtils; + +import java.net.IDN; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +import static org.apache.commons.io.FilenameUtils.wildcardMatch; + +public class UrlPatternMatcher { + + private final List patterns; + + public UrlPatternMatcher(List patterns) { + this.patterns = new ArrayList<>(); + + patterns.forEach(pattern -> { + String patternLower = pattern.toLowerCase(); + String hostAndPort = pattern.contains(":") ? patternLower : patternLower + ":*"; + String[] split = hostAndPort.split(":"); + String host = Character.isDigit(split[0].charAt(0)) ? split[0] : IDN.toASCII(split[0], IDN.ALLOW_UNASSIGNED); + String port = split.length > 1 ? split[1] : "*"; + + // Ignore domains that end in a wildcard + if (host.length() > 1 && !NetUtils.isIPv4(host.replace("*", "0")) && host.endsWith("*")) { + return; + } + + this.patterns.add(String.format("%s:%s", host, port)); + }); + } + + public boolean matches(String url) { + URL parsed; + try { + parsed = new URL(url.toLowerCase()); + } catch (MalformedURLException e) { + return false; + } + + String host = IDN.toASCII(parsed.getHost(), IDN.ALLOW_UNASSIGNED); + String hostAndPort; + if (parsed.getPort() == -1) { + if (parsed.getProtocol().equals("http")) { + hostAndPort = host + ":80"; + } else if (parsed.getProtocol().equals("https")) { + hostAndPort = host + ":443"; + } else { + return false; + } + } else { + hostAndPort = host + ":" + parsed.getPort(); + } + + return this.patterns.stream().anyMatch(pattern -> wildcardMatch(hostAndPort, pattern)); + } +} diff --git a/modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/models/ResolvedUrl.java b/modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/models/ResolvedUrl.java new file mode 100644 index 0000000000..b5c9bf39c4 --- /dev/null +++ b/modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/models/ResolvedUrl.java @@ -0,0 +1,36 @@ +package io.swagger.v3.parser.urlresolver.models; + +public class ResolvedUrl { + + private String url; + private String hostHeader; + + public ResolvedUrl(String url, String hostHeader) { + this.url = url; + this.hostHeader = hostHeader; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getHostHeader() { + return hostHeader; + } + + public void setHostHeader(String hostHeader) { + this.hostHeader = hostHeader; + } + + @Override + public String toString() { + return "ResolvedUrl{" + + "url='" + url + '\'' + + ", hostHeader='" + hostHeader + '\'' + + '}'; + } +} diff --git a/modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/utils/NetUtils.java b/modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/utils/NetUtils.java new file mode 100644 index 0000000000..7aa7088c77 --- /dev/null +++ b/modules/swagger-parser-safe-url-resolver/src/main/java/io/swagger/v3/parser/urlresolver/utils/NetUtils.java @@ -0,0 +1,90 @@ +package io.swagger.v3.parser.urlresolver.utils; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.UnknownHostException; + +public class NetUtils { + + private NetUtils() {} + + public static InetAddress getHostByName(String hostname) throws UnknownHostException { + return InetAddress.getByName(hostname); + } + + public static String getHostFromUrl(String url) throws MalformedURLException { + String hostnameOrIP = new URL(url).getHost(); + //IPv6 addresses in URLs are surrounded by square brackets + if (hostnameOrIP.length() > 2 && hostnameOrIP.startsWith("[") && hostnameOrIP.endsWith("]")) { + return hostnameOrIP.substring(1, hostnameOrIP.length() - 1); + } + return hostnameOrIP; + } + + public static String setHost(String url, String host) throws MalformedURLException { + URL parsed = new URL(url); + if (isIPv6(host)) { + return url.replace(parsed.getHost(), "[" + host + "]"); + } else { + return url.replace(parsed.getHost(), host); + } + } + + public static boolean isIPv4(String ipAddress) { + boolean isIPv4 = false; + + if (ipAddress != null) { + try { + InetAddress inetAddress = InetAddress.getByName(ipAddress); + isIPv4 = (inetAddress instanceof Inet4Address); + } catch (UnknownHostException ignored) { + return false; + } + } + + return isIPv4; + } + + public static boolean isIPv6(String ipAddress) { + boolean isIPv6 = false; + + if (ipAddress != null) { + try { + InetAddress inetAddress = InetAddress.getByName(ipAddress); + isIPv6 = (inetAddress instanceof Inet6Address); + } catch (UnknownHostException ignored) { + return false; + } + } + + return isIPv6; + } + + // Not picked up by Inet6Address.is*Address() checks + public static boolean isUniqueLocalAddress(InetAddress ip) { + // Only applies to IPv6 + if (ip instanceof Inet4Address) { + return false; + } + + byte[] address = ip.getAddress(); + return (address[0] & 0xff) == 0xfc || (address[0] & 0xff) == 0xfd; + } + + // Not picked up by Inet6Address.is*Address() checks + public static boolean isNAT64Address(InetAddress ip) { + // Only applies to IPv6 + if (ip instanceof Inet4Address) { + return false; + } + + byte[] address = ip.getAddress(); + return (address[0] & 0xff) == 0x00 + && (address[1] & 0xff) == 0x64 + && (address[2] & 0xff) == 0xff + && (address[3] & 0xff) == 0x9b; + } +} diff --git a/modules/swagger-parser-safe-url-resolver/src/test/java/io/swagger/v3/parser/urlresolver/PermittedUrlsCheckerTest.java b/modules/swagger-parser-safe-url-resolver/src/test/java/io/swagger/v3/parser/urlresolver/PermittedUrlsCheckerTest.java new file mode 100644 index 0000000000..4625cd719b --- /dev/null +++ b/modules/swagger-parser-safe-url-resolver/src/test/java/io/swagger/v3/parser/urlresolver/PermittedUrlsCheckerTest.java @@ -0,0 +1,283 @@ +package io.swagger.v3.parser.urlresolver; + +import io.swagger.v3.parser.urlresolver.exceptions.HostDeniedException; +import io.swagger.v3.parser.urlresolver.models.ResolvedUrl; +import io.swagger.v3.parser.urlresolver.utils.NetUtils; +import mockit.*; +import org.testng.Assert; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + + +import java.net.InetAddress; +import java.util.Collections; +import java.util.List; + +public class PermittedUrlsCheckerTest { + + private final List emptyAllowlist = Collections.emptyList(); + private final List emptyDenylist = Collections.emptyList(); + @Mocked + private NetUtils netUtils; + + private PermittedUrlsChecker checker; + + @BeforeMethod + void beforeMethod() { + this.checker = new PermittedUrlsChecker(Collections.emptyList(), Collections.emptyList()); + } + + @Test(expectedExceptions = HostDeniedException.class, expectedExceptionsMessageRegExp = ".*IP is restricted.*") + public void shouldRejectPrivateSIITIPv4in6HostReferencesInABCDFormat() throws Exception { + String url = "https://[0:0:0:0:0:ffff:10.1.33.147]:8000/v1/operation?theThing=something"; + String expectedIp = "10.1.33.147"; + + new Expectations() {{ + NetUtils.getHostFromUrl(url); times = 1; result = expectedIp; + NetUtils.getHostByName(expectedIp); times = 1; result = InetAddress.getByName(expectedIp); + NetUtils.setHost(url, expectedIp); times = 1; result = url; + }}; + + checker.verify(url); + } + + @Test + public void shouldAllowPublicSIITIPv4in6HostReferencesInABCDFormat() throws Exception { + String url = "https://[0:0:0:0:0:ffff:1.2.3.4]:8000/v1/operation?theThing=something"; + String expectedIp = "1.2.3.4"; + String expectedUrl = "https://1.2.3.4:8000/v1/operation?theThing=something"; + String expectedHostHeader = "0:0:0:0:0:ffff:1.2.3.4"; + + new Expectations() {{ + NetUtils.getHostFromUrl(url); times = 1; result = expectedHostHeader; + NetUtils.getHostByName(expectedHostHeader); times = 1; result = InetAddress.getByName(expectedIp); + NetUtils.setHost(url, expectedIp); times = 1; result = expectedUrl; + }}; + + ResolvedUrl result = checker.verify(url); + + Assert.assertEquals(result.getUrl(), expectedUrl); + Assert.assertEquals(result.getHostHeader(), expectedHostHeader); + } + + @Test(expectedExceptions = HostDeniedException.class, expectedExceptionsMessageRegExp = ".*IP is restricted.*") + public void shouldRejectPrivateSIITIPv4in6HostReferencesInIPv6Format() throws Exception { + String url = "https://[0:0:0:0:0:ffff:a01:219]:8000/v1/operation?theThing=something"; + String expectedIp = "10.1.2.25"; + + new Expectations() {{ + NetUtils.getHostFromUrl(url); times = 1; result = expectedIp; + NetUtils.getHostByName(expectedIp); times = 1; result = InetAddress.getByName(expectedIp); + NetUtils.setHost(url, expectedIp); times = 1; result = url; + }}; + + checker.verify(url); + } + + @Test(expectedExceptions = HostDeniedException.class, expectedExceptionsMessageRegExp = ".*IP is restricted.*") + public void shouldRejectNAT64HostReferences() throws Exception { + String url = "https://[64:ff9b::]:8000/v1/operation?theThing=something"; + String expectedIp = "64:ff9b:0:0:0:0:0:0"; + + new Expectations() {{ + NetUtils.getHostFromUrl(url); times = 1; result = expectedIp; + NetUtils.getHostByName(expectedIp); times = 1; result = InetAddress.getByName(expectedIp); + NetUtils.setHost(url, expectedIp); times = 1; result = url; + NetUtils.isNAT64Address(withInstanceOf(InetAddress.class)); times = 1; result = true; + }}; + + checker.verify(url); + } + + @Test(expectedExceptions = HostDeniedException.class, expectedExceptionsMessageRegExp = ".*IP is restricted.*") + public void shouldRejectDecimalIPsThatResolveToLocalIPs() throws Exception { + String url = "https://3232235778:8000/api/v3/pet/findByStatus?status=available"; + String expectedIp = "192.168.1.2"; + + new Expectations() {{ + NetUtils.getHostFromUrl(url); times = 1; result = expectedIp; + NetUtils.getHostByName(expectedIp); times = 1; result = InetAddress.getByName(expectedIp); + NetUtils.setHost(url, expectedIp); times = 1; result = url; + }}; + + checker.verify(url); + } + + @Test + public void shouldPreferAllowlistOverEverythingElse() throws Exception { + String url = "https://localhost:3000/1"; + String expectedHostname = "localhost"; + List allowlist = Collections.singletonList("localhost"); + this.checker = new PermittedUrlsChecker(allowlist, emptyDenylist); + + new Expectations() {{ + NetUtils.getHostFromUrl(url); times = 1; result = expectedHostname; + }}; + + ResolvedUrl result = checker.verify(url); + + Assert.assertEquals(result.getUrl(), "https://localhost:3000/1"); + Assert.assertEquals(result.getHostHeader(), "localhost"); + } + + @Test + public void shouldAllowPublicDomainsByDefault() throws Exception { + String url = "https://smartbear.com:3000/1"; + String expectedUrl = "https://1.2.3.4:3000/1"; + String expectedHost = "smartbear.com"; + String expectedIp = "1.2.3.4"; + + new Expectations() {{ + NetUtils.getHostFromUrl(url); times = 1; result = expectedHost; + NetUtils.getHostByName(expectedHost); times = 1; result = InetAddress.getByName(expectedIp); + NetUtils.setHost(url, expectedIp); times = 1; result = expectedUrl; + }}; + + this.checker = new PermittedUrlsChecker(emptyAllowlist, emptyDenylist); + ResolvedUrl result = checker.verify(url); + + Assert.assertEquals(result.getUrl(), expectedUrl); + Assert.assertEquals(result.getHostHeader(), expectedHost); + } + + @Test + public void shouldAllowPublicIPsByDefault() throws Exception { + String url = "https://1.2.3.4:3000/1"; + String expectedHost = "1.2.3.4"; + + new Expectations() {{ + NetUtils.getHostFromUrl(url); times = 1; result = expectedHost; + NetUtils.getHostByName(expectedHost); times = 1; result = InetAddress.getByName(expectedHost); + NetUtils.setHost(url, expectedHost); times = 1; result = url; + }}; + + this.checker = new PermittedUrlsChecker(emptyAllowlist, emptyDenylist); + ResolvedUrl result = checker.verify(url); + + Assert.assertEquals(result.getUrl(), url); + Assert.assertEquals(result.getHostHeader(), expectedHost); + } + + @Test( + dataProvider = "shouldBlockRestrictedIPv4sByDefault", + expectedExceptions = HostDeniedException.class, + expectedExceptionsMessageRegExp = ".*IP is restricted.*" + ) + public void shouldBlockIPv4Localhost(String url, String expectedIp) throws Exception { + new Expectations() {{ + NetUtils.getHostFromUrl(url); times = 1; result = expectedIp; + NetUtils.getHostByName(expectedIp); times = 1; result = InetAddress.getByName(expectedIp); + NetUtils.setHost(url, expectedIp); times = 1; result = url; + }}; + + checker.verify(url); + } + + @DataProvider(name = "shouldBlockRestrictedIPv4sByDefault") + private Object[][] shouldBlockRestrictedIPv4sByDefault() { + return new Object[][]{ + {"https://localhost:3000/1", "127.0.0.1"}, + {"https://127.0.0.1/", "127.0.0.1"}, + {"https://192.168.1.2/", "192.168.1.2"}, + {"https://127.3", "127.0.0.3"} + }; + } + + @Test( + dataProvider = "shouldBlockRestrictedIPv6sByDefault", + expectedExceptions = HostDeniedException.class, + expectedExceptionsMessageRegExp = ".*IP is restricted.*" + ) + public void shouldBlockIPv6Localhost(String url, String expectedIp) throws Exception { + new Expectations() {{ + NetUtils.getHostFromUrl(url); times = 1; result = expectedIp; + NetUtils.getHostByName(expectedIp); times = 1; result = InetAddress.getByName(expectedIp); + NetUtils.setHost(url, expectedIp); times = 1; result = url; + NetUtils.isUniqueLocalAddress(withInstanceOf(InetAddress.class)); result = true; + }}; + + checker.verify(url); + } + + @DataProvider(name = "shouldBlockRestrictedIPv6sByDefault") + private Object[][] shouldBlockRestrictedIPv6sByDefault() { + return new Object[][]{ + {"https://[fc00::1]/", "fc00:0:0:0:0:0:0:1"}, + {"https://[fd00:ec2::254]/", "fd00:ec2:0:0:0:0:0:254"} + }; + } + + @Test(expectedExceptions = HostDeniedException.class, expectedExceptionsMessageRegExp = ".*IP is restricted.*") + public void shouldBlockDomainNamesThatResolveToPrivateIPs() throws Exception { + String url = "https://evil.com"; + String expectedUrl = "https://192.168.1.1:3000/1"; + String expectedHost = "evil.com"; + String expectedIp = "192.168.1.1"; + + new Expectations() {{ + NetUtils.getHostFromUrl(url); times = 1; result = expectedHost; + NetUtils.getHostByName(expectedHost); times = 1; result = InetAddress.getByName(expectedIp); + NetUtils.setHost(url, expectedIp); times = 1; result = expectedUrl; + }}; + + this.checker = new PermittedUrlsChecker(emptyAllowlist, emptyDenylist); + checker.verify(url); + } + + @Test(expectedExceptions = HostDeniedException.class, expectedExceptionsMessageRegExp = ".*URL is part of the explicit denylist.*") + public void shouldBlockSpecificallyDenylistedURLs() throws Exception { + String url = "https://smartbear.com"; + List denylist = Collections.singletonList("smartbear.com"); + + this.checker = new PermittedUrlsChecker(emptyAllowlist, denylist); + checker.verify(url); + } + + @Test(expectedExceptions = HostDeniedException.class, expectedExceptionsMessageRegExp = ".*IP is part of the explicit denylist.*") + public void shouldBlockBasedOnResolvedIP() throws Exception { + String url = "https://smartbear.com"; + String expectedUrl = "https://1.2.3.4:3000/1"; + String expectedHost = "smartbear.com"; + String expectedIp = "1.2.3.4"; + List denylist = Collections.singletonList("1.2.3.4"); + + new Expectations() {{ + NetUtils.getHostFromUrl(url); times = 1; result = expectedHost; + NetUtils.getHostByName(expectedHost); times = 1; result = InetAddress.getByName(expectedIp); + NetUtils.setHost(url, expectedIp); times = 1; result = expectedUrl; + }}; + + this.checker = new PermittedUrlsChecker(emptyAllowlist, denylist); + checker.verify(url); + } + + @Test(expectedExceptions = HostDeniedException.class, expectedExceptionsMessageRegExp = ".*URL is part of the explicit denylist.*") + public void shouldBlockURLMatchingWildcardPattern() throws Exception { + String url = "https://foo.example.com"; + String expectedHost = "foo.example.com"; + List denylist = Collections.singletonList("f*.example.com"); + + new Expectations() {{ + NetUtils.getHostFromUrl(url); times = 1; result = expectedHost; + }}; + + this.checker = new PermittedUrlsChecker(emptyAllowlist, denylist); + checker.verify(url); + } + + @Test + public void shouldAllowURLMatchingWildcardPattern() throws Exception { + String url = "https://foo.example.com"; + String expectedHost = "foo.example.com"; + List allowlist = Collections.singletonList("f*.example.com"); + + new Expectations() {{ + NetUtils.getHostFromUrl(url); times = 1; result = expectedHost; + }}; + + this.checker = new PermittedUrlsChecker(allowlist, emptyDenylist); + checker.verify(url); + } + +} diff --git a/modules/swagger-parser-safe-url-resolver/src/test/java/io/swagger/v3/parser/urlresolver/matchers/UrlPatternMatcherTest.java b/modules/swagger-parser-safe-url-resolver/src/test/java/io/swagger/v3/parser/urlresolver/matchers/UrlPatternMatcherTest.java new file mode 100644 index 0000000000..bce4a3e2cd --- /dev/null +++ b/modules/swagger-parser-safe-url-resolver/src/test/java/io/swagger/v3/parser/urlresolver/matchers/UrlPatternMatcherTest.java @@ -0,0 +1,129 @@ +package io.swagger.v3.parser.urlresolver.matchers; + +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.util.Collections; +import java.util.List; + +public class UrlPatternMatcherTest { + + @Test + public void returnsFalseWhenUrlCannotBeParsed() { + List patterns = Collections.emptyList(); + UrlPatternMatcher matcher = new UrlPatternMatcher(patterns); + + Assert.assertFalse(matcher.matches("not a url")); + } + + @Test + public void returnsFalseWhenUrlIsNotHttpOrHttps() { + List patterns = Collections.emptyList(); + UrlPatternMatcher matcher = new UrlPatternMatcher(patterns); + + Assert.assertFalse(matcher.matches("file://not a url")); + } + + @Test + public void domainWithoutPortMatchesAnyPort() { + List patterns = Collections.singletonList("example.com"); + UrlPatternMatcher matcher = new UrlPatternMatcher(patterns); + + Assert.assertTrue(matcher.matches("http://example.com")); + Assert.assertTrue(matcher.matches("https://example.com")); + Assert.assertTrue(matcher.matches("http://example.com:12345")); + Assert.assertTrue(matcher.matches("https://example.com:12345")); + Assert.assertFalse(matcher.matches("https://not.example.com:12345")); + } + + @Test + public void domainWithPortMatchesOnlyThatPort() { + List patterns = Collections.singletonList("example.com:443"); + UrlPatternMatcher matcher = new UrlPatternMatcher(patterns); + + Assert.assertFalse(matcher.matches("http://example.com")); + Assert.assertTrue(matcher.matches("https://example.com")); + Assert.assertTrue(matcher.matches("http://example.com:443")); + Assert.assertFalse(matcher.matches("http://example.com:12345")); + Assert.assertFalse(matcher.matches("https://example.com:1234")); + Assert.assertFalse(matcher.matches("https://not.example.com:12345")); + } + + @Test + public void domainSupportsWildcards() { + List patterns = Collections.singletonList("*.example.com"); + UrlPatternMatcher matcher = new UrlPatternMatcher(patterns); + + Assert.assertFalse(matcher.matches("http://example.com")); + Assert.assertFalse(matcher.matches("https://example.com")); + Assert.assertFalse(matcher.matches("https://fooexample.com")); + Assert.assertTrue(matcher.matches("https://foo.example.com")); + Assert.assertTrue(matcher.matches("https://foo.bar.example.com")); + } + + @Test + public void domainInUrlIsCaseInsensitive() { + List patterns = Collections.singletonList("*.example.com"); + UrlPatternMatcher matcher = new UrlPatternMatcher(patterns); + + Assert.assertFalse(matcher.matches("http://ExAmPlE.CoM")); + Assert.assertTrue(matcher.matches("https://FoO.ExAmPlE.CoM")); + } + + @Test + public void domainInPatternIsCaseInsensitive() { + List patterns = Collections.singletonList("*.EXamPLe.Com"); + UrlPatternMatcher matcher = new UrlPatternMatcher(patterns); + + Assert.assertFalse(matcher.matches("http://ExAmPlE.CoM")); + Assert.assertTrue(matcher.matches("https://FoO.ExAmPlE.CoM")); + } + + @Test + public void supportForMatchingInternationalizedDomainNames() { + List patterns = Collections.singletonList("*.😋.local"); + UrlPatternMatcher matcher = new UrlPatternMatcher(patterns); + + Assert.assertFalse(matcher.matches("http://example.com")); + Assert.assertTrue(matcher.matches("http://blah.😋.local")); + Assert.assertTrue(matcher.matches("http://blah.xn--p28h.local")); + } + + @Test + public void domainsDoNotSupportWildcardsAtTheEnd() { + List patterns = Collections.singletonList("example.co*"); + UrlPatternMatcher matcher = new UrlPatternMatcher(patterns); + + Assert.assertFalse(matcher.matches("https://example.net")); + Assert.assertFalse(matcher.matches("https://example.co.uk")); + Assert.assertFalse(matcher.matches("https://example.com")); + } + + @Test + public void ipAddressesSupportWildcardsAtTheEnd() { + List patterns = Collections.singletonList("10.100.*.*"); + UrlPatternMatcher matcher = new UrlPatternMatcher(patterns); + + Assert.assertTrue(matcher.matches("http://10.100.1.2")); + Assert.assertFalse(matcher.matches("http://10.101.1.2")); + } + + @Test + public void worksWithUrlsWithAuthPathAndQueryComponents() { + List patterns = Collections.singletonList("*.example.com"); + UrlPatternMatcher matcher = new UrlPatternMatcher(patterns); + + Assert.assertFalse(matcher.matches("https://foo:bar@example.com/path?q=1")); + Assert.assertTrue(matcher.matches("https://foo:bar@foo.example.com/path?q=1")); + } + + @Test + public void supportsIpAddressesInPatterns() { + List patterns = Collections.singletonList("1.*.3.4"); + UrlPatternMatcher matcher = new UrlPatternMatcher(patterns); + + Assert.assertTrue(matcher.matches("https://foo:bar@1.2.3.4/path?q=1")); + Assert.assertFalse(matcher.matches("https://foo:bar@1.2.3.5/path?q=1")); + } + +} diff --git a/modules/swagger-parser-safe-url-resolver/src/test/java/io/swagger/v3/parser/urlresolver/utils/NetUtilsTest.java b/modules/swagger-parser-safe-url-resolver/src/test/java/io/swagger/v3/parser/urlresolver/utils/NetUtilsTest.java new file mode 100644 index 0000000000..6f533e85f6 --- /dev/null +++ b/modules/swagger-parser-safe-url-resolver/src/test/java/io/swagger/v3/parser/urlresolver/utils/NetUtilsTest.java @@ -0,0 +1,138 @@ +package io.swagger.v3.parser.urlresolver.utils; + +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.net.InetAddress; +import java.net.MalformedURLException; +import java.net.UnknownHostException; + +public class NetUtilsTest { + + @Test + public void getHostFromUrlWithDomainNameShouldReturnHostname() throws MalformedURLException { + String url = "https://example.com/hello?query=world"; + + String hostname = NetUtils.getHostFromUrl(url); + + Assert.assertEquals(hostname, "example.com"); + } + + @Test + public void getHostFromUrlWithIPv4AddressShouldReturnIPAddress() throws MalformedURLException { + String url = "https://1.2.3.4/hello?query=world"; + + String hostname = NetUtils.getHostFromUrl(url); + + Assert.assertEquals(hostname, "1.2.3.4"); + } + + @Test + public void getHostFromUrlWithIPv6AddressShouldReturnIPAddress() throws MalformedURLException { + String url = "https://[::1]/hello?query=world"; + + String hostname = NetUtils.getHostFromUrl(url); + + Assert.assertEquals(hostname, "::1"); + } + + @Test + public void setHostShouldSetIPv4AddressInUrl() throws MalformedURLException { + String url = "https://example.com/hello?query=world"; + String ip = "1.2.3.4"; + + String result = NetUtils.setHost(url, ip); + + Assert.assertEquals(result, "https://1.2.3.4/hello?query=world"); + } + + @Test + public void setHostShouldSetIPv6AddressInUrlWithBrackets() throws MalformedURLException { + String url = "https://example.com/hello?query=world"; + String ip = "::1"; + + String result = NetUtils.setHost(url, ip); + + Assert.assertEquals(result, "https://[::1]/hello?query=world"); + } + + @Test + public void isIPv4WithIPv4AddressShouldReturnTrue() { + String ip = "1.2.3.4"; + + Assert.assertTrue(NetUtils.isIPv4(ip)); + } + + @Test + public void isIPv4WithIPv6AddressShouldReturnFalse() { + String ip = "::1"; + + Assert.assertFalse(NetUtils.isIPv4(ip)); + } + + @Test + public void isIPv6WithIPv6AddressShouldReturnTrue() { + String ip = "::1"; + + Assert.assertTrue(NetUtils.isIPv6(ip)); + } + + @Test + public void isIPv6WithIPv4AddressShouldReturnFalse() { + String ip = "1.2.3.4"; + + Assert.assertFalse(NetUtils.isIPv6(ip)); + } + + @Test + public void isIPv6WithImproperAddressShouldReturnFalse() { + String ip = "999.999.999.999"; + + Assert.assertFalse(NetUtils.isIPv6(ip)); + } + + @Test + public void isUniqueLocalAddressWithULAShouldReturnTrue() throws UnknownHostException { + InetAddress ulaIp = InetAddress.getByName("fc00::1"); + InetAddress ulaIpWithLBit = InetAddress.getByName("fd00:ec2::254"); + + Assert.assertTrue(NetUtils.isUniqueLocalAddress(ulaIp)); + Assert.assertTrue(NetUtils.isUniqueLocalAddress(ulaIpWithLBit)); + } + + @Test + public void isUniqueLocalAddressWithNonULAIPv6AddressShouldReturnFalse() throws UnknownHostException { + InetAddress ip = InetAddress.getByName("::1"); + + Assert.assertFalse(NetUtils.isUniqueLocalAddress(ip)); + } + + @Test + public void isUniqueLocalAddressWithIPv4AddressShouldReturnFalse() throws UnknownHostException { + InetAddress ip = InetAddress.getByName("1.2.3.4"); + + Assert.assertFalse(NetUtils.isUniqueLocalAddress(ip)); + } + + @Test + public void isNAT64WithNAT64AddressShouldReturnTrue() throws UnknownHostException { + InetAddress ip = InetAddress.getByName("64:ff9b::"); + + Assert.assertTrue(NetUtils.isNAT64Address(ip)); + } + + @Test + public void isNAT64WithRegularIPv6AddressShouldReturnFalse() throws UnknownHostException { + InetAddress ip = InetAddress.getByName("fc00::1"); + + Assert.assertFalse(NetUtils.isNAT64Address(ip)); + } + + @Test + public void isNAT64WithIPv4AddressShouldReturnFalse() throws UnknownHostException { + InetAddress ip = InetAddress.getByName("1.2.3.4"); + + Assert.assertFalse(NetUtils.isNAT64Address(ip)); + } + +} diff --git a/modules/swagger-parser/pom.xml b/modules/swagger-parser/pom.xml index e7e5239f11..fd3018db63 100644 --- a/modules/swagger-parser/pom.xml +++ b/modules/swagger-parser/pom.xml @@ -87,5 +87,10 @@ + + io.swagger + swagger-parser-safe-url-resolver + ${project.version} + diff --git a/modules/swagger-parser/src/main/java/io/swagger/parser/ResolverCache.java b/modules/swagger-parser/src/main/java/io/swagger/parser/ResolverCache.java index e41f200199..48f9c1857e 100644 --- a/modules/swagger-parser/src/main/java/io/swagger/parser/ResolverCache.java +++ b/modules/swagger-parser/src/main/java/io/swagger/parser/ResolverCache.java @@ -13,10 +13,9 @@ import io.swagger.models.properties.RefProperty; import io.swagger.models.refs.RefFormat; import io.swagger.models.refs.RefType; -import io.swagger.parser.util.DeserializationUtils; -import io.swagger.parser.util.PathUtils; -import io.swagger.parser.util.RefUtils; -import io.swagger.parser.util.SwaggerDeserializer; +import io.swagger.parser.util.*; +import io.swagger.v3.parser.urlresolver.PermittedUrlsChecker; +import io.swagger.v3.parser.urlresolver.exceptions.HostDeniedException; import org.apache.commons.lang3.StringUtils; import java.io.File; @@ -53,6 +52,7 @@ public class ResolverCache { private final Path parentDirectory; private final String parentUrl; private final String rootPath; + private final ParseOptions parseOptions; private Map resolutionCache = new HashMap<>(); private Map externalFileCache = new HashMap<>(); private Set referencedModelKeys = new HashSet<>(); @@ -66,6 +66,27 @@ public ResolverCache(Swagger swagger, List auths, String par this.swagger = swagger; this.auths = auths; this.rootPath = parentFileLocation; + this.parseOptions = new ParseOptions(); + + if(parentFileLocation != null) { + if(parentFileLocation.startsWith("http")) { + parentDirectory = null; + } else { + parentDirectory = PathUtils.getParentDirectoryOfFile(parentFileLocation); + } + } else { + File file = new File("."); + parentDirectory = file.toPath(); + } + parentUrl = parentFileLocation; + + } + + public ResolverCache(Swagger swagger, List auths, String parentFileLocation, ParseOptions parseOptions) { + this.swagger = swagger; + this.auths = auths; + this.rootPath = parentFileLocation; + this.parseOptions = parseOptions; if(parentFileLocation != null) { if(parentFileLocation.startsWith("http")) { @@ -115,6 +136,10 @@ public T loadRef(String ref, RefFormat refFormat, Class expectedType) { String contents = externalFileCache.get(file); if (contents == null) { + if(parseOptions.isSafelyResolveURL()){ + checkUrlIsPermitted(file); + } + if(parentDirectory != null) { contents = RefUtils.readExternalRef(file, refFormat, auths, parentDirectory); } @@ -291,6 +316,17 @@ private Object getFromMap(String ref, Map map, Pattern pattern) { return null; } + protected void checkUrlIsPermitted(String refSet) { + try { + PermittedUrlsChecker permittedUrlsChecker = new PermittedUrlsChecker(parseOptions.getRemoteRefAllowList(), + parseOptions.getRemoteRefBlockList()); + + permittedUrlsChecker.verify(refSet); + } catch (HostDeniedException exception) { + throw new RuntimeException(exception.getMessage()); + } + } + public boolean hasReferencedKey(String modelKey) { if(referencedModelKeys == null) { return false; diff --git a/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerParser.java b/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerParser.java index 3a8291f464..524924c9cf 100644 --- a/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerParser.java +++ b/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerParser.java @@ -194,7 +194,7 @@ public Swagger read(JsonNode node, List authorizationValues, if (output != null) { if (options != null) { if (options.isResolve()) { - output = new SwaggerResolver(output, authorizationValues).resolve(); + output = new SwaggerResolver(output, authorizationValues, null, null, options).resolve(); } if (options.isFlatten()) { InlineModelResolver inlineModelResolver = new InlineModelResolver(); diff --git a/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerResolver.java b/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerResolver.java index 316a87d3ff..85371fe734 100644 --- a/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerResolver.java +++ b/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerResolver.java @@ -11,6 +11,7 @@ import io.swagger.parser.processors.OperationProcessor; import io.swagger.parser.processors.ParameterProcessor; import io.swagger.parser.processors.PathsProcessor; +import io.swagger.parser.util.ParseOptions; import java.util.Arrays; import java.util.List; @@ -49,6 +50,16 @@ public SwaggerResolver(Swagger swagger, List auths, String p parametersProcessor = new ParameterProcessor(cache, swagger); } + public SwaggerResolver(Swagger swagger, List auths, String parentFileLocation, Settings settings, ParseOptions parseOptions) { + this.swagger = swagger; + this.settings = settings != null ? settings : new Settings(); + this.cache = new ResolverCache(swagger, auths, parentFileLocation, parseOptions); + definitionsProcessor = new DefinitionsProcessor(cache, swagger); + pathProcessor = new PathsProcessor(cache, swagger, this.settings); + operationsProcessor = new OperationProcessor(cache, swagger); + parametersProcessor = new ParameterProcessor(cache, swagger); + } + public Swagger resolve() { if (swagger == null) { return null; diff --git a/modules/swagger-parser/src/main/java/io/swagger/parser/util/ParseOptions.java b/modules/swagger-parser/src/main/java/io/swagger/parser/util/ParseOptions.java index 64129f58cf..64e9946a9d 100644 --- a/modules/swagger-parser/src/main/java/io/swagger/parser/util/ParseOptions.java +++ b/modules/swagger-parser/src/main/java/io/swagger/parser/util/ParseOptions.java @@ -1,8 +1,13 @@ package io.swagger.parser.util; +import java.util.List; + public class ParseOptions { private boolean resolve; private boolean flatten; + private boolean safelyResolveURL; + private List remoteRefAllowList; + private List remoteRefBlockList; public boolean isResolve() { return resolve; @@ -15,4 +20,28 @@ public void setResolve(boolean resolve) { public boolean isFlatten() { return flatten; } public void setFlatten(boolean flatten) { this.flatten = flatten; } + + public boolean isSafelyResolveURL() { + return safelyResolveURL; + } + + public void setSafelyResolveURL(boolean safelyResolveURL) { + this.safelyResolveURL = safelyResolveURL; + } + + public List getRemoteRefAllowList() { + return remoteRefAllowList; + } + + public void setRemoteRefAllowList(List remoteRefAllowList) { + this.remoteRefAllowList = remoteRefAllowList; + } + + public List getRemoteRefBlockList() { + return remoteRefBlockList; + } + + public void setRemoteRefBlockList(List remoteRefBlockList) { + this.remoteRefBlockList = remoteRefBlockList; + } } diff --git a/pom.xml b/pom.xml index 70d580b7c6..f440ff69bc 100644 --- a/pom.xml +++ b/pom.xml @@ -139,7 +139,7 @@ 3.6.2 true - 1.7 + 1.8 UTF-8 1g @@ -367,6 +367,7 @@ modules/swagger-parser modules/swagger-compat-spec-parser + modules/swagger-parser-safe-url-resolver From 1f0a43580c27cb2ea898776fdbb3cba2afa7a428 Mon Sep 17 00:00:00 2001 From: Milosz Tarka Date: Thu, 4 Jan 2024 11:02:09 +0100 Subject: [PATCH 2/4] SWG-9288 adding parseoptions in second case --- .../src/main/java/io/swagger/parser/SwaggerParser.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerParser.java b/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerParser.java index 524924c9cf..dd7f1afe9b 100644 --- a/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerParser.java +++ b/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerParser.java @@ -87,7 +87,7 @@ public Swagger read(String location, List auths, ParseOption if (output != null) { if (options != null) { if (options.isResolve()) { - output = new SwaggerResolver(output, auths, location).resolve(); + output = new SwaggerResolver(output, auths, location, null, options).resolve(); } if (options.isFlatten()) { InlineModelResolver inlineModelResolver = new InlineModelResolver(); From 645414015d91c5b5c3b9a693d08b9f16b8a03819 Mon Sep 17 00:00:00 2001 From: Milosz Tarka Date: Thu, 4 Jan 2024 15:28:51 +0100 Subject: [PATCH 3/4] SWG-9288 adding tests --- .../io/swagger/parser/SwaggerParserTest.java | 86 +++++++++++++++++++ .../oas20SafeUrlResolvingWithLocalhost.yaml | 26 ++++++ .../oas20SafeUrlResolvingWithPetstore.yaml | 26 ++++++ 3 files changed, 138 insertions(+) create mode 100644 modules/swagger-parser/src/test/resources/safelyResolve/oas20SafeUrlResolvingWithLocalhost.yaml create mode 100644 modules/swagger-parser/src/test/resources/safelyResolve/oas20SafeUrlResolvingWithPetstore.yaml diff --git a/modules/swagger-parser/src/test/java/io/swagger/parser/SwaggerParserTest.java b/modules/swagger-parser/src/test/java/io/swagger/parser/SwaggerParserTest.java index 17b7767095..610bce9666 100644 --- a/modules/swagger-parser/src/test/java/io/swagger/parser/SwaggerParserTest.java +++ b/modules/swagger-parser/src/test/java/io/swagger/parser/SwaggerParserTest.java @@ -3,6 +3,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; import static org.testng.Assert.assertNull; import static org.testng.Assert.assertTrue; import static org.testng.Assert.fail; @@ -1790,4 +1791,89 @@ public void testInlineModelResolverByLocation() { assertTrue(userAddress.getProperties().containsKey("city")); assertTrue(userAddress.getProperties().containsKey("street")); } + + @Test(description = "Test safe resolving") + public void test20SafeURLResolving() throws IOException { + String yaml = new String(Files.readAllBytes(new File("src/test/resources/safelyResolve/oas20SafeUrlResolvingWithPetstore.yaml").toPath()), "UTF-8"); + JsonNode jsonNodeSwagger = Yaml.mapper().readValue(yaml, JsonNode.class); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + parseOptions.setSafelyResolveURL(true); + List allowList = Collections.emptyList(); + List blockList = Collections.emptyList(); + parseOptions.setRemoteRefAllowList(allowList); + parseOptions.setRemoteRefBlockList(blockList); + + new SwaggerParser().read(jsonNodeSwagger, null, parseOptions); + } + + @Test(description = "Test safe resolving with blocked URL") + public void test20SafeURLResolvingWithBlockedURL() throws IOException { + String yaml = new String(Files.readAllBytes(new File("src/test/resources/safelyResolve/oas20SafeUrlResolvingWithPetstore.yaml").toPath()), "UTF-8"); + JsonNode jsonNodeSwagger = Yaml.mapper().readValue(yaml, JsonNode.class); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + parseOptions.setSafelyResolveURL(true); + List allowList = Collections.emptyList(); + List blockList = Arrays.asList("petstore3.swagger.io"); + parseOptions.setRemoteRefAllowList(allowList); + parseOptions.setRemoteRefBlockList(blockList); + + assertThrows(RuntimeException.class, () -> { + new SwaggerParser().read(jsonNodeSwagger, null, parseOptions); + }); + } + + @Test(description = "Test safe resolving with turned off safelyResolveURL option") + public void test20SafeURLResolvingWithTurnedOffSafeResolving() throws IOException { + String yaml = new String(Files.readAllBytes(new File("src/test/resources/safelyResolve/oas20SafeUrlResolvingWithPetstore.yaml").toPath()), "UTF-8"); + JsonNode jsonNodeSwagger = Yaml.mapper().readValue(yaml, JsonNode.class); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(false); + parseOptions.setSafelyResolveURL(true); + List allowList = Collections.emptyList(); + List blockList = Arrays.asList("petstore3.swagger.io"); + parseOptions.setRemoteRefAllowList(allowList); + parseOptions.setRemoteRefBlockList(blockList); + + new SwaggerParser().read(jsonNodeSwagger, null, parseOptions); + } + + @Test(description = "Test safe resolving with localhost and blocked url") + public void test20SafeURLResolvingWithLocalhostAndBlockedURL() throws IOException { + String yaml = new String(Files.readAllBytes(new File("src/test/resources/safelyResolve/oas20SafeUrlResolvingWithLocalhost.yaml").toPath()), "UTF-8"); + JsonNode jsonNodeSwagger = Yaml.mapper().readValue(yaml, JsonNode.class); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + parseOptions.setSafelyResolveURL(true); + List allowList = Collections.emptyList(); + List blockList = Arrays.asList("petstore.swagger.io"); + parseOptions.setRemoteRefAllowList(allowList); + parseOptions.setRemoteRefBlockList(blockList); + + assertThrows(RuntimeException.class, () -> { + new SwaggerParser().read(jsonNodeSwagger, null, parseOptions); + }); } + + @Test(description = "Test safe resolving with localhost url") + public void test20SafeURLResolvingWithLocalhost() throws IOException { + String yaml = new String(Files.readAllBytes(new File("src/test/resources/safelyResolve/oas20SafeUrlResolvingWithLocalhost.yaml").toPath()), "UTF-8"); + JsonNode jsonNodeSwagger = Yaml.mapper().readValue(yaml, JsonNode.class); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + parseOptions.setSafelyResolveURL(true); + List allowList = Collections.emptyList(); + List blockList = Collections.emptyList(); + parseOptions.setRemoteRefAllowList(allowList); + parseOptions.setRemoteRefBlockList(blockList); + + assertThrows(RuntimeException.class, () -> { + new SwaggerParser().read(jsonNodeSwagger, null, parseOptions); + }); + } } diff --git a/modules/swagger-parser/src/test/resources/safelyResolve/oas20SafeUrlResolvingWithLocalhost.yaml b/modules/swagger-parser/src/test/resources/safelyResolve/oas20SafeUrlResolvingWithLocalhost.yaml new file mode 100644 index 0000000000..60ffcef8ce --- /dev/null +++ b/modules/swagger-parser/src/test/resources/safelyResolve/oas20SafeUrlResolvingWithLocalhost.yaml @@ -0,0 +1,26 @@ +swagger: '2.0' +info: + version: "1.0.0" + title: ssrf-test + +consumes: + - application/json +produces: + - application/json +paths: + /devices: + get: + operationId: getDevices + responses: + '200': + description: All the devices + schema: + $ref: 'http://localhost/example' + /pets: + get: + operationId: getPets + responses: + '200': + description: All the pets + schema: + $ref: 'https://petstore.swagger.io/v2/swagger.json' \ No newline at end of file diff --git a/modules/swagger-parser/src/test/resources/safelyResolve/oas20SafeUrlResolvingWithPetstore.yaml b/modules/swagger-parser/src/test/resources/safelyResolve/oas20SafeUrlResolvingWithPetstore.yaml new file mode 100644 index 0000000000..2982fd96a6 --- /dev/null +++ b/modules/swagger-parser/src/test/resources/safelyResolve/oas20SafeUrlResolvingWithPetstore.yaml @@ -0,0 +1,26 @@ +swagger: '2.0' +info: + version: "1.0.0" + title: ssrf-test + +consumes: + - application/json +produces: + - application/json +paths: + /devices: + get: + operationId: getDevices + responses: + '200': + description: All the devices + schema: + $ref: 'https://petstore3.swagger.io/api/v3/openapi.json' + /pets: + get: + operationId: getPets + responses: + '200': + description: All the pets + schema: + $ref: 'https://petstore.swagger.io/v2/swagger.json' \ No newline at end of file From 1d5dab311bf6ac61287c235cc853d12ad3340471 Mon Sep 17 00:00:00 2001 From: Milosz Tarka Date: Wed, 10 Jan 2024 13:31:50 +0100 Subject: [PATCH 4/4] SWG-9288 refactoring constructors for SwaggerResolver and ResolverCache --- .../java/io/swagger/parser/ResolverCache.java | 18 +----------------- .../io/swagger/parser/SwaggerResolver.java | 8 +------- 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/modules/swagger-parser/src/main/java/io/swagger/parser/ResolverCache.java b/modules/swagger-parser/src/main/java/io/swagger/parser/ResolverCache.java index 48f9c1857e..2593a88454 100644 --- a/modules/swagger-parser/src/main/java/io/swagger/parser/ResolverCache.java +++ b/modules/swagger-parser/src/main/java/io/swagger/parser/ResolverCache.java @@ -63,23 +63,7 @@ public class ResolverCache { private Map renameCache = new ConcurrentHashMap<>(); public ResolverCache(Swagger swagger, List auths, String parentFileLocation) { - this.swagger = swagger; - this.auths = auths; - this.rootPath = parentFileLocation; - this.parseOptions = new ParseOptions(); - - if(parentFileLocation != null) { - if(parentFileLocation.startsWith("http")) { - parentDirectory = null; - } else { - parentDirectory = PathUtils.getParentDirectoryOfFile(parentFileLocation); - } - } else { - File file = new File("."); - parentDirectory = file.toPath(); - } - parentUrl = parentFileLocation; - + this(swagger, auths, parentFileLocation, new ParseOptions()); } public ResolverCache(Swagger swagger, List auths, String parentFileLocation, ParseOptions parseOptions) { diff --git a/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerResolver.java b/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerResolver.java index 85371fe734..536339ff98 100644 --- a/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerResolver.java +++ b/modules/swagger-parser/src/main/java/io/swagger/parser/SwaggerResolver.java @@ -41,13 +41,7 @@ public SwaggerResolver(Swagger swagger, List auths, String p } public SwaggerResolver(Swagger swagger, List auths, String parentFileLocation, Settings settings) { - this.swagger = swagger; - this.settings = settings != null ? settings : new Settings(); - this.cache = new ResolverCache(swagger, auths, parentFileLocation); - definitionsProcessor = new DefinitionsProcessor(cache, swagger); - pathProcessor = new PathsProcessor(cache, swagger, this.settings); - operationsProcessor = new OperationProcessor(cache, swagger); - parametersProcessor = new ParameterProcessor(cache, swagger); + this(swagger, auths, parentFileLocation, settings, new ParseOptions()); } public SwaggerResolver(Swagger swagger, List auths, String parentFileLocation, Settings settings, ParseOptions parseOptions) {