Skip to content

Commit 5804358

Browse files
authored
Merge pull request #2031 from swagger-api/SWG-9288-utilizing-safe-url-resolver-for-oas-20
SWG-9288 utilizing safe url resolver for OAS 2.0
2 parents 86f480f + 1d5dab3 commit 5804358

File tree

19 files changed

+1177
-8
lines changed

19 files changed

+1177
-8
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Swagger Parser Safe URL Resolver
2+
3+
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.
4+
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.
5+
6+
## How does it work?
7+
The main class of the package is the `PermittedUrlsChecker` which has one method: `verify(String url)`.
8+
This method takes in a string URL and performs the following steps:
9+
10+
1. Gets the hostname portion from the URL
11+
2. Resolves the hostname to an IP address
12+
3. Checks if that IP address is in a private/restricted IP address range (and throws an exception if it is)
13+
4. Returns a `ResolvedUrl` object which contains
14+
4.1. `String url` where the original URL has the hostname replaced with the IP address
15+
4.2. A `String hostHeader` which contains the hostname from the original URL to be added as a host header
16+
17+
This behavior can also be customized with the allowlist and denylist in the constructor, whereby:
18+
19+
- An entry in the allowlist will allow the URL to pass even if it resolves to a private/restricted IP address
20+
- An entry in the denylist will throw an exception even when the URL resolves to a public IP address
21+
22+
## Installation
23+
Add the following to you `pom.xml` file under `dependencies`
24+
```xml
25+
<dependency>
26+
<groupId>io.swagger.parser.v3</groupId>
27+
<artifactId>swagger-parser-safe-url-resolver</artifactId>
28+
// version of swagger-parser being used
29+
<version>2.1.14</version>
30+
</dependency>
31+
```
32+
33+
## Example usage
34+
35+
```java
36+
import io.swagger.v3.parser.urlresolver.PermittedUrlsChecker;
37+
import io.swagger.v3.parser.urlresolver.exceptions.HostDeniedException;
38+
import io.swagger.v3.parser.urlresolver.models.ResolvedUrl;
39+
40+
import java.util.List;
41+
42+
public class Main {
43+
public static void main() {
44+
List<String> allowlist = List.of("mysite.local");
45+
List<String> denylist = List.of("*.example.com:443");
46+
var checker = new PermittedUrlsChecker(allowlist, denylist);
47+
48+
try {
49+
// Will throw a HostDeniedException as `localhost`
50+
// resolves to local IP and is not in allowlist
51+
checker.verify("http://localhost/example");
52+
53+
// Will return a ResolvedUrl if `github.com`
54+
// resolves to a public IP
55+
checker.verify("https://github.com/swagger-api/swagger-parser");
56+
57+
// Will throw a HostDeniedException as `*.example.com` is
58+
// explicitly deny listed, even if it resolves to public IP
59+
checker.verify("https://subdomain.example.com/somepage");
60+
61+
// Will return a `ResolvedUrl` as `mysite.local`
62+
// is explicitly allowlisted
63+
ResolvedUrl resolvedUrl = checker.verify("http://mysite.local/example");
64+
System.out.println(resolvedUrl.getUrl()); // "http://127.0.0.1/example"
65+
System.out.println(resolvedUrl.getHostHeader()); // "mysite.local"
66+
} catch (HostDeniedException e) {
67+
e.printStackTrace();
68+
}
69+
}
70+
}
71+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
<parent>
7+
<groupId>io.swagger</groupId>
8+
<artifactId>swagger-parser-project</artifactId>
9+
<version>1.0.69-SNAPSHOT</version>
10+
<relativePath>../..</relativePath>
11+
12+
</parent>
13+
14+
<artifactId>swagger-parser-safe-url-resolver</artifactId>
15+
16+
<dependencies>
17+
<dependency>
18+
<groupId>commons-io</groupId>
19+
<artifactId>commons-io</artifactId>
20+
<version>${commons-io-version}</version>
21+
</dependency>
22+
<dependency>
23+
<groupId>org.slf4j</groupId>
24+
<artifactId>slf4j-simple</artifactId>
25+
<version>${slf4j-version}</version>
26+
<scope>test</scope>
27+
</dependency>
28+
<dependency>
29+
<groupId>org.testng</groupId>
30+
<artifactId>testng</artifactId>
31+
<version>${testng-version}</version>
32+
<scope>test</scope>
33+
</dependency>
34+
<dependency>
35+
<groupId>junit</groupId>
36+
<artifactId>junit</artifactId>
37+
<version>${junit-version}</version>
38+
<scope>test</scope>
39+
</dependency>
40+
<dependency>
41+
<groupId>org.jmockit</groupId>
42+
<artifactId>jmockit</artifactId>
43+
<version>${jmockit-version}</version>
44+
<scope>test</scope>
45+
</dependency>
46+
</dependencies>
47+
</project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package io.swagger.v3.parser.urlresolver;
2+
3+
import io.swagger.v3.parser.urlresolver.exceptions.HostDeniedException;
4+
import io.swagger.v3.parser.urlresolver.matchers.UrlPatternMatcher;
5+
import io.swagger.v3.parser.urlresolver.models.ResolvedUrl;
6+
import io.swagger.v3.parser.urlresolver.utils.NetUtils;
7+
8+
import java.net.InetAddress;
9+
import java.net.MalformedURLException;
10+
import java.net.URL;
11+
import java.net.UnknownHostException;
12+
import java.util.Collections;
13+
import java.util.List;
14+
15+
public class PermittedUrlsChecker {
16+
17+
protected final UrlPatternMatcher allowlistMatcher;
18+
protected final UrlPatternMatcher denylistMatcher;
19+
20+
public PermittedUrlsChecker() {
21+
this.allowlistMatcher = new UrlPatternMatcher(Collections.emptyList());
22+
this.denylistMatcher = new UrlPatternMatcher(Collections.emptyList());
23+
}
24+
25+
public PermittedUrlsChecker(List<String> allowlist, List<String> denylist) {
26+
if(allowlist != null) {
27+
this.allowlistMatcher = new UrlPatternMatcher(allowlist);
28+
} else {
29+
this.allowlistMatcher = new UrlPatternMatcher(Collections.emptyList());
30+
}
31+
32+
if(denylist != null) {
33+
this.denylistMatcher = new UrlPatternMatcher(denylist);
34+
} else {
35+
this.denylistMatcher = new UrlPatternMatcher(Collections.emptyList());
36+
}
37+
}
38+
39+
public ResolvedUrl verify(String url) throws HostDeniedException {
40+
URL parsed;
41+
42+
try {
43+
parsed = new URL(url);
44+
} catch (MalformedURLException e) {
45+
throw new HostDeniedException(String.format("Failed to parse URL. URL [%s]", url), e);
46+
}
47+
48+
if (!parsed.getProtocol().equals("http") && !parsed.getProtocol().equals("https")) {
49+
throw new HostDeniedException(String.format("URL does not use a supported protocol. URL [%s]", url));
50+
}
51+
52+
String hostname;
53+
try {
54+
hostname = NetUtils.getHostFromUrl(url);
55+
} catch (MalformedURLException e) {
56+
throw new HostDeniedException(String.format("Failed to get hostname from URL. URL [%s]", url), e);
57+
}
58+
59+
if (this.allowlistMatcher.matches(url)) {
60+
return new ResolvedUrl(url, hostname);
61+
}
62+
63+
if (this.denylistMatcher.matches(url)) {
64+
throw new HostDeniedException(String.format("URL is part of the explicit denylist. URL [%s]", url));
65+
}
66+
67+
InetAddress ip;
68+
try {
69+
ip = NetUtils.getHostByName(hostname);
70+
} catch (UnknownHostException e) {
71+
throw new HostDeniedException(
72+
String.format("Failed to resolve IP from hostname. Hostname [%s]", hostname), e);
73+
}
74+
75+
String urlWithIp;
76+
try {
77+
urlWithIp = NetUtils.setHost(url, ip.getHostAddress());
78+
} catch (MalformedURLException e) {
79+
throw new HostDeniedException(
80+
String.format("Failed to create new URL with IP. IP [%s] URL [%s]", ip.getHostAddress(), url), e);
81+
}
82+
83+
if (this.allowlistMatcher.matches(urlWithIp)) {
84+
return new ResolvedUrl(urlWithIp, hostname);
85+
}
86+
87+
if (isRestrictedIpRange(ip)) {
88+
throw new HostDeniedException(String.format("IP is restricted. URL [%s]", urlWithIp));
89+
}
90+
91+
if (this.denylistMatcher.matches(urlWithIp)) {
92+
throw new HostDeniedException(String.format("IP is part of the explicit denylist. URL [%s]", urlWithIp));
93+
}
94+
95+
return new ResolvedUrl(urlWithIp, hostname);
96+
}
97+
98+
protected boolean isRestrictedIpRange(InetAddress ip) {
99+
return ip.isLinkLocalAddress()
100+
|| ip.isSiteLocalAddress()
101+
|| ip.isLoopbackAddress()
102+
|| ip.isAnyLocalAddress()
103+
|| NetUtils.isUniqueLocalAddress(ip)
104+
|| NetUtils.isNAT64Address(ip);
105+
}
106+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package io.swagger.v3.parser.urlresolver.exceptions;
2+
3+
public class HostDeniedException extends Exception {
4+
public HostDeniedException(String message) {
5+
super(message);
6+
}
7+
8+
public HostDeniedException(String message, Throwable e) {
9+
super(message, e);
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package io.swagger.v3.parser.urlresolver.matchers;
2+
3+
import io.swagger.v3.parser.urlresolver.utils.NetUtils;
4+
5+
import java.net.IDN;
6+
import java.net.MalformedURLException;
7+
import java.net.URL;
8+
import java.util.ArrayList;
9+
import java.util.List;
10+
11+
import static org.apache.commons.io.FilenameUtils.wildcardMatch;
12+
13+
public class UrlPatternMatcher {
14+
15+
private final List<String> patterns;
16+
17+
public UrlPatternMatcher(List<String> patterns) {
18+
this.patterns = new ArrayList<>();
19+
20+
patterns.forEach(pattern -> {
21+
String patternLower = pattern.toLowerCase();
22+
String hostAndPort = pattern.contains(":") ? patternLower : patternLower + ":*";
23+
String[] split = hostAndPort.split(":");
24+
String host = Character.isDigit(split[0].charAt(0)) ? split[0] : IDN.toASCII(split[0], IDN.ALLOW_UNASSIGNED);
25+
String port = split.length > 1 ? split[1] : "*";
26+
27+
// Ignore domains that end in a wildcard
28+
if (host.length() > 1 && !NetUtils.isIPv4(host.replace("*", "0")) && host.endsWith("*")) {
29+
return;
30+
}
31+
32+
this.patterns.add(String.format("%s:%s", host, port));
33+
});
34+
}
35+
36+
public boolean matches(String url) {
37+
URL parsed;
38+
try {
39+
parsed = new URL(url.toLowerCase());
40+
} catch (MalformedURLException e) {
41+
return false;
42+
}
43+
44+
String host = IDN.toASCII(parsed.getHost(), IDN.ALLOW_UNASSIGNED);
45+
String hostAndPort;
46+
if (parsed.getPort() == -1) {
47+
if (parsed.getProtocol().equals("http")) {
48+
hostAndPort = host + ":80";
49+
} else if (parsed.getProtocol().equals("https")) {
50+
hostAndPort = host + ":443";
51+
} else {
52+
return false;
53+
}
54+
} else {
55+
hostAndPort = host + ":" + parsed.getPort();
56+
}
57+
58+
return this.patterns.stream().anyMatch(pattern -> wildcardMatch(hostAndPort, pattern));
59+
}
60+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package io.swagger.v3.parser.urlresolver.models;
2+
3+
public class ResolvedUrl {
4+
5+
private String url;
6+
private String hostHeader;
7+
8+
public ResolvedUrl(String url, String hostHeader) {
9+
this.url = url;
10+
this.hostHeader = hostHeader;
11+
}
12+
13+
public String getUrl() {
14+
return url;
15+
}
16+
17+
public void setUrl(String url) {
18+
this.url = url;
19+
}
20+
21+
public String getHostHeader() {
22+
return hostHeader;
23+
}
24+
25+
public void setHostHeader(String hostHeader) {
26+
this.hostHeader = hostHeader;
27+
}
28+
29+
@Override
30+
public String toString() {
31+
return "ResolvedUrl{" +
32+
"url='" + url + '\'' +
33+
", hostHeader='" + hostHeader + '\'' +
34+
'}';
35+
}
36+
}

0 commit comments

Comments
 (0)