Skip to content

Commit

Permalink
Merge pull request #2031 from swagger-api/SWG-9288-utilizing-safe-url…
Browse files Browse the repository at this point in the history
…-resolver-for-oas-20

SWG-9288 utilizing safe url resolver for OAS 2.0
  • Loading branch information
MiloszTarka authored Jan 11, 2024
2 parents 86f480f + 1d5dab3 commit 5804358
Show file tree
Hide file tree
Showing 19 changed files with 1,177 additions and 8 deletions.
71 changes: 71 additions & 0 deletions modules/swagger-parser-safe-url-resolver/README.md
Original file line number Diff line number Diff line change
@@ -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
<dependency>
<groupId>io.swagger.parser.v3</groupId>
<artifactId>swagger-parser-safe-url-resolver</artifactId>
// version of swagger-parser being used
<version>2.1.14</version>
</dependency>
```

## 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<String> allowlist = List.of("mysite.local");
List<String> 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();
}
}
}
```
47 changes: 47 additions & 0 deletions modules/swagger-parser-safe-url-resolver/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.swagger</groupId>
<artifactId>swagger-parser-project</artifactId>
<version>1.0.69-SNAPSHOT</version>
<relativePath>../..</relativePath>

</parent>

<artifactId>swagger-parser-safe-url-resolver</artifactId>

<dependencies>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>${commons-io-version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>${slf4j-version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>${testng-version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit-version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jmockit</groupId>
<artifactId>jmockit</artifactId>
<version>${jmockit-version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -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<String> allowlist, List<String> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String> patterns;

public UrlPatternMatcher(List<String> 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));
}
}
Original file line number Diff line number Diff line change
@@ -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 + '\'' +
'}';
}
}
Loading

0 comments on commit 5804358

Please sign in to comment.