Skip to content

Commit 9548ead

Browse files
committed
fix(webhook): Safer defaults and more config for webhook URLs
Exclude by default: single-word hostnames (`https://orca`, `https://spin-orca`), the `.spinnaker` domain (a common k8s deployment namespace), common internal-name suffixes (`.local`, `.internal`, `.localdomain`), and all verbatim IP addresses. Add new configuration to specify a list of exclusion patterns. This greatly simplifies configuration, as it is not easy to do complex filtering in a single allow expression. Add new configuration to dynamically exclude domains based on the values of specified environment variables. For example, this can always exclude the k8s namespace Spinnaker is currently running in, long as there is some variable set that specifies what that is. `POD_NAMESPACE` is commonly set by providers, and is included by default along with `ISTIO_META_MESH_ID`, as names in that domain are also resolvable. Also allows `localhost` in all cases if the `rejectLocalhost` flag is `false`, disregarding the name filter. This avoids the need to change the name filter to include all forms of local names while developing.
1 parent 6fddc77 commit 9548ead

File tree

2 files changed

+349
-40
lines changed

2 files changed

+349
-40
lines changed

orca-core/src/main/java/com/netflix/spinnaker/orca/config/UserConfiguredUrlRestrictions.java

Lines changed: 135 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,23 @@
1616

1717
package com.netflix.spinnaker.orca.config;
1818

19+
import com.google.common.net.InetAddresses;
1920
import java.net.InetAddress;
2021
import java.net.NetworkInterface;
22+
import java.net.SocketException;
2123
import java.net.URI;
24+
import java.net.UnknownHostException;
2225
import java.util.ArrayList;
2326
import java.util.Arrays;
2427
import java.util.Collection;
2528
import java.util.Collections;
2629
import java.util.HashSet;
2730
import java.util.List;
31+
import java.util.Objects;
2832
import java.util.Optional;
2933
import java.util.Set;
3034
import java.util.regex.Pattern;
35+
import java.util.stream.Collectors;
3136
import lombok.AllArgsConstructor;
3237
import lombok.Data;
3338
import lombok.NoArgsConstructor;
@@ -37,14 +42,25 @@
3742
public class UserConfiguredUrlRestrictions {
3843
@Data
3944
public static class Builder {
40-
private String allowedHostnamesRegex = ".*";
45+
private String allowedHostnamesRegex =
46+
".*\\..+"; // Exclude anything without a dot, since k8s resolves single-word names
4147
private List<String> allowedSchemes = new ArrayList<>(Arrays.asList("http", "https"));
4248
private boolean rejectLocalhost = true;
4349
private boolean rejectLinkLocal = true;
50+
private boolean rejectVerbatimIps = true;
4451
private HttpClientProperties httpClientProperties = new HttpClientProperties();
4552
private List<String> rejectedIps =
4653
new ArrayList<>(); // can contain IP addresses and/or IP ranges (CIDR block)
4754

55+
// Blanket exclusion on certain domains
56+
// This default pattern will exclude anything that is suffixed with the excluded domain
57+
private String excludedDomainTemplate = "(?=.+\\.%s$).*\\..+";
58+
private List<String> excludedDomains = List.of("spinnaker", "local", "localdomain", "internal");
59+
// Generate exclusion patterns based on the values of environment variables
60+
// Useful for dynamically excluding all requests to the current k8s namespace, for example
61+
private List<String> excludedDomainsFromEnvironment = List.of();
62+
private List<String> extraExcludedPatterns = List.of();
63+
4864
public Builder withAllowedHostnamesRegex(String allowedHostnamesRegex) {
4965
setAllowedHostnamesRegex(allowedHostnamesRegex);
5066
return this;
@@ -65,6 +81,11 @@ public Builder withRejectLinkLocal(boolean rejectLinkLocal) {
6581
return this;
6682
}
6783

84+
public Builder withRejectVerbatimIps(boolean rejectVerbatimIps) {
85+
setRejectVerbatimIps(rejectVerbatimIps);
86+
return this;
87+
}
88+
6889
public Builder withRejectedIps(List<String> rejectedIpRanges) {
6990
setRejectedIps(rejectedIpRanges);
7091
return this;
@@ -75,43 +96,122 @@ public Builder withHttpClientProperties(HttpClientProperties httpClientPropertie
7596
return this;
7697
}
7798

99+
public Builder withExcludedDomainsFromEnvironment(List<String> envVars) {
100+
setExcludedDomainsFromEnvironment(envVars);
101+
return this;
102+
}
103+
104+
public Builder withExtraExcludedPatterns(List<String> patterns) {
105+
setExtraExcludedPatterns(patterns);
106+
return this;
107+
}
108+
109+
String getEnvValue(String envVarName) {
110+
return System.getenv(envVarName);
111+
}
112+
113+
List<String> getEnvValues(List<String> envVars) {
114+
if (envVars == null) return List.of();
115+
116+
return envVars.stream()
117+
.map(this::getEnvValue)
118+
.filter(Objects::nonNull)
119+
.collect(Collectors.toList());
120+
}
121+
122+
List<Pattern> compilePatterns(List<String> values, String patternStr, boolean quote) {
123+
if (values == null || patternStr == null) {
124+
return List.of();
125+
}
126+
127+
return values.stream()
128+
.map(value -> quote ? Pattern.quote(value) : value)
129+
.map(value -> Pattern.compile(String.format(patternStr, value)))
130+
.collect(Collectors.toList());
131+
}
132+
78133
public UserConfiguredUrlRestrictions build() {
134+
// Combine and build all excluded domains based on the specified names, env vars, and pattern
135+
List<String> allExcludedDomains = new ArrayList<>();
136+
allExcludedDomains.addAll(excludedDomains);
137+
allExcludedDomains.addAll(getEnvValues(excludedDomainsFromEnvironment));
138+
139+
// Collect any extra patterns and provide the final list of patterns
140+
List<Pattern> allExcludedPatterns = new ArrayList<>();
141+
allExcludedPatterns.addAll(compilePatterns(allExcludedDomains, excludedDomainTemplate, true));
142+
allExcludedPatterns.addAll(compilePatterns(extraExcludedPatterns, "%s", false));
143+
79144
return new UserConfiguredUrlRestrictions(
80145
Pattern.compile(allowedHostnamesRegex),
81146
allowedSchemes,
82147
rejectLocalhost,
83148
rejectLinkLocal,
149+
rejectVerbatimIps,
84150
rejectedIps,
85-
httpClientProperties);
151+
httpClientProperties,
152+
allExcludedPatterns);
86153
}
87154
}
88155

89156
private final Pattern allowedHostnames;
90157
private final Set<String> allowedSchemes;
91158
private final boolean rejectLocalhost;
92159
private final boolean rejectLinkLocal;
160+
private final boolean rejectVerbatimIps;
93161
private final Set<String> rejectedIps;
94162
private final HttpClientProperties clientProperties;
163+
private final List<Pattern> excludedPatterns;
95164

96-
public UserConfiguredUrlRestrictions(
165+
protected UserConfiguredUrlRestrictions(
97166
Pattern allowedHostnames,
98167
Collection<String> allowedSchemes,
99168
boolean rejectLocalhost,
100169
boolean rejectLinkLocal,
170+
boolean rejectVerbatimIps,
101171
Collection<String> rejectedIps,
102-
HttpClientProperties clientProperties) {
172+
HttpClientProperties clientProperties,
173+
List<Pattern> excludedPatterns) {
103174
this.allowedHostnames = allowedHostnames;
104175
this.allowedSchemes =
105176
allowedSchemes == null
106177
? Collections.emptySet()
107178
: Collections.unmodifiableSet(new HashSet<>(allowedSchemes));
108179
this.rejectLocalhost = rejectLocalhost;
109180
this.rejectLinkLocal = rejectLinkLocal;
181+
this.rejectVerbatimIps = rejectVerbatimIps;
110182
this.rejectedIps =
111183
rejectedIps == null
112184
? Collections.emptySet()
113185
: Collections.unmodifiableSet(new HashSet<>(rejectedIps));
114186
this.clientProperties = clientProperties;
187+
this.excludedPatterns = excludedPatterns;
188+
}
189+
190+
InetAddress resolveHost(String host) throws UnknownHostException {
191+
return InetAddress.getByName(host);
192+
}
193+
194+
boolean isLocalhost(InetAddress addr) throws SocketException {
195+
return addr.isLoopbackAddress()
196+
|| Optional.ofNullable(NetworkInterface.getByInetAddress(addr)).isPresent();
197+
}
198+
199+
boolean isLinkLocal(InetAddress addr) {
200+
return addr.isLinkLocalAddress();
201+
}
202+
203+
boolean isValidHostname(String host) {
204+
return allowedHostnames.matcher(host).matches()
205+
&& excludedPatterns.stream().noneMatch(p -> p.matcher(host).matches());
206+
}
207+
208+
boolean isValidIpAddress(String host) {
209+
var matcher = new IpAddressMatcher(host);
210+
return rejectedIps.stream().noneMatch(matcher::matches);
211+
}
212+
213+
boolean isIpAddress(String host) {
214+
return InetAddresses.isInetAddress(host);
115215
}
116216

117217
public URI validateURI(String url) throws IllegalArgumentException {
@@ -130,12 +230,17 @@ public URI validateURI(String url) throws IllegalArgumentException {
130230
if (host == null) {
131231
String authority = u.getAuthority();
132232
if (authority != null) {
133-
int portIndex = authority.indexOf(":");
134-
host = (portIndex > -1) ? authority.substring(0, portIndex) : authority;
233+
// Don't attempt to colon-substring ipv6 addresses
234+
if (isIpAddress(authority)) {
235+
host = authority;
236+
} else {
237+
int portIndex = authority.indexOf(":");
238+
host = (portIndex > -1) ? authority.substring(0, portIndex) : authority;
239+
}
135240
}
136241
}
137242

138-
if (host == null) {
243+
if (host == null || host.isEmpty()) {
139244
throw new IllegalArgumentException("Unable to determine host for the url provided " + url);
140245
}
141246

@@ -144,37 +249,40 @@ public URI validateURI(String url) throws IllegalArgumentException {
144249
"Allowed Hostnames are not set, external HTTP requests are not enabled. Please configure 'user-configured-url-restrictions.allowedHostnamesRegex' in your orca config.");
145250
}
146251

147-
if (!allowedHostnames.matcher(host).matches()) {
148-
throw new IllegalArgumentException(
149-
"Host not allowed " + host + ". Host much match " + allowedHostnames.toString() + ".");
150-
}
252+
// Strip ipv6 brackets if present
253+
// InetAddress.getHost() retains them, but other code doesn't quite understand
254+
host = host.replace("[", "").replace("]", "");
151255

152-
if (rejectLocalhost || rejectLinkLocal) {
153-
InetAddress addr = InetAddress.getByName(host);
154-
if (rejectLocalhost) {
155-
if (addr.isLoopbackAddress()
156-
|| Optional.ofNullable(NetworkInterface.getByInetAddress(addr)).isPresent()) {
157-
throw new IllegalArgumentException("invalid address for " + host);
158-
}
159-
}
160-
if (rejectLinkLocal && addr.isLinkLocalAddress()) {
161-
throw new IllegalArgumentException("invalid address for " + host);
162-
}
256+
if (isIpAddress(host) && rejectVerbatimIps) {
257+
throw new IllegalArgumentException("Verbatim IP addresses are not allowed");
163258
}
164259

165-
for (String ip : rejectedIps) {
166-
IpAddressMatcher ipMatcher = new IpAddressMatcher(ip);
260+
var addr = resolveHost(host);
261+
var isLocalhost = isLocalhost(addr);
262+
var isLinkLocal = isLinkLocal(addr);
263+
264+
if ((isLocalhost && rejectLocalhost) || (isLinkLocal && rejectLinkLocal)) {
265+
throw new IllegalArgumentException("Host not allowed: " + host);
266+
}
167267

168-
if (ipMatcher.matches(host)) {
169-
throw new IllegalArgumentException("address " + host + " is within rejected IPs: " + ip);
268+
if (!isValidHostname(host) && !isIpAddress(host)) {
269+
// If localhost or link local is allowed, that takes precedence over the name filter
270+
// This avoids the need to include local names in the hostname pattern in addition to
271+
// setting the local config flag
272+
if (!(isLocalhost || isLinkLocal)) {
273+
throw new IllegalArgumentException("Host not allowed: " + host);
170274
}
171275
}
172276

277+
if (!isValidIpAddress(host)) {
278+
throw new IllegalArgumentException("Address not allowed: " + host);
279+
}
280+
173281
return u;
174282
} catch (IllegalArgumentException iae) {
175283
throw iae;
176284
} catch (Exception ex) {
177-
throw new IllegalArgumentException("URI not valid " + url, ex);
285+
throw new IllegalArgumentException("URI not valid: " + url, ex);
178286
}
179287
}
180288

0 commit comments

Comments
 (0)