Skip to content

Commit

Permalink
Merge pull request #1910 from swagger-api/ft/create_safe_url_resolver
Browse files Browse the repository at this point in the history
Initial commit of safe url resolver
  • Loading branch information
CalemRoelofsSB authored Apr 13, 2023
2 parents 9b875f9 + a2e6237 commit 7ff9e99
Show file tree
Hide file tree
Showing 10 changed files with 891 additions and 0 deletions.
46 changes: 46 additions & 0 deletions modules/swagger-parser-safe-url-resolver/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?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.parser.v3</groupId>
<artifactId>swagger-parser-project</artifactId>
<version>2.1.14-SNAPSHOT</version>
<relativePath>../../pom.xml</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,97 @@
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) {
this.allowlistMatcher = new UrlPatternMatcher(allowlist);
this.denylistMatcher = new UrlPatternMatcher(denylist);
}

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 + '\'' +
'}';
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit 7ff9e99

Please sign in to comment.