Skip to content

Commit 7dede2c

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 7dede2c

File tree

2 files changed

+339
-40
lines changed

2 files changed

+339
-40
lines changed

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

Lines changed: 130 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,117 @@ public Builder withHttpClientProperties(HttpClientProperties httpClientPropertie
7596
return this;
7697
}
7798

99+
public Builder withExtraExcludedPatterns(List<String> patterns) {
100+
setExtraExcludedPatterns(patterns);
101+
return this;
102+
}
103+
104+
String getEnvValue(String envVarName) {
105+
return System.getenv(envVarName);
106+
}
107+
108+
List<String> getEnvValues(List<String> envVars) {
109+
if (envVars == null) return List.of();
110+
111+
return envVars.stream()
112+
.map(this::getEnvValue)
113+
.filter(Objects::nonNull)
114+
.collect(Collectors.toList());
115+
}
116+
117+
List<Pattern> compilePatterns(List<String> values, String patternStr, boolean quote) {
118+
if (values == null || patternStr == null) {
119+
return List.of();
120+
}
121+
122+
return values.stream()
123+
.map(value -> quote ? Pattern.quote(value) : value)
124+
.map(value -> Pattern.compile(String.format(patternStr, value)))
125+
.collect(Collectors.toList());
126+
}
127+
78128
public UserConfiguredUrlRestrictions build() {
129+
// Combine and build all excluded domains based on the specified names, env vars, and pattern
130+
List<String> allExcludedDomains = new ArrayList<>();
131+
allExcludedDomains.addAll(excludedDomains);
132+
allExcludedDomains.addAll(getEnvValues(excludedDomainsFromEnvironment));
133+
134+
// Collect any extra patterns and provide the final list of patterns
135+
List<Pattern> allExcludedPatterns = new ArrayList<>();
136+
allExcludedPatterns.addAll(compilePatterns(allExcludedDomains, excludedDomainTemplate, true));
137+
allExcludedPatterns.addAll(compilePatterns(extraExcludedPatterns, "%s", false));
138+
79139
return new UserConfiguredUrlRestrictions(
80140
Pattern.compile(allowedHostnamesRegex),
81141
allowedSchemes,
82142
rejectLocalhost,
83143
rejectLinkLocal,
144+
rejectVerbatimIps,
84145
rejectedIps,
85-
httpClientProperties);
146+
httpClientProperties,
147+
allExcludedPatterns);
86148
}
87149
}
88150

89151
private final Pattern allowedHostnames;
90152
private final Set<String> allowedSchemes;
91153
private final boolean rejectLocalhost;
92154
private final boolean rejectLinkLocal;
155+
private final boolean rejectVerbatimIps;
93156
private final Set<String> rejectedIps;
94157
private final HttpClientProperties clientProperties;
158+
private final List<Pattern> excludedPatterns;
95159

96-
public UserConfiguredUrlRestrictions(
160+
protected UserConfiguredUrlRestrictions(
97161
Pattern allowedHostnames,
98162
Collection<String> allowedSchemes,
99163
boolean rejectLocalhost,
100164
boolean rejectLinkLocal,
165+
boolean rejectVerbatimIps,
101166
Collection<String> rejectedIps,
102-
HttpClientProperties clientProperties) {
167+
HttpClientProperties clientProperties,
168+
List<Pattern> excludedPatterns) {
103169
this.allowedHostnames = allowedHostnames;
104170
this.allowedSchemes =
105171
allowedSchemes == null
106172
? Collections.emptySet()
107173
: Collections.unmodifiableSet(new HashSet<>(allowedSchemes));
108174
this.rejectLocalhost = rejectLocalhost;
109175
this.rejectLinkLocal = rejectLinkLocal;
176+
this.rejectVerbatimIps = rejectVerbatimIps;
110177
this.rejectedIps =
111178
rejectedIps == null
112179
? Collections.emptySet()
113180
: Collections.unmodifiableSet(new HashSet<>(rejectedIps));
114181
this.clientProperties = clientProperties;
182+
this.excludedPatterns = excludedPatterns;
183+
}
184+
185+
InetAddress resolveHost(String host) throws UnknownHostException {
186+
return InetAddress.getByName(host);
187+
}
188+
189+
boolean isLocalhost(InetAddress addr) throws SocketException {
190+
return addr.isLoopbackAddress()
191+
|| Optional.ofNullable(NetworkInterface.getByInetAddress(addr)).isPresent();
192+
}
193+
194+
boolean isLinkLocal(InetAddress addr) {
195+
return addr.isLinkLocalAddress();
196+
}
197+
198+
boolean isValidHostname(String host) {
199+
return allowedHostnames.matcher(host).matches()
200+
&& excludedPatterns.stream().noneMatch(p -> p.matcher(host).matches());
201+
}
202+
203+
boolean isValidIpAddress(String host) {
204+
var matcher = new IpAddressMatcher(host);
205+
return rejectedIps.stream().noneMatch(matcher::matches);
206+
}
207+
208+
boolean isIpAddress(String host) {
209+
return InetAddresses.isInetAddress(host);
115210
}
116211

117212
public URI validateURI(String url) throws IllegalArgumentException {
@@ -130,12 +225,17 @@ public URI validateURI(String url) throws IllegalArgumentException {
130225
if (host == null) {
131226
String authority = u.getAuthority();
132227
if (authority != null) {
133-
int portIndex = authority.indexOf(":");
134-
host = (portIndex > -1) ? authority.substring(0, portIndex) : authority;
228+
// Don't attempt to colon-substring ipv6 addresses
229+
if (isIpAddress(authority)) {
230+
host = authority;
231+
} else {
232+
int portIndex = authority.indexOf(":");
233+
host = (portIndex > -1) ? authority.substring(0, portIndex) : authority;
234+
}
135235
}
136236
}
137237

138-
if (host == null) {
238+
if (host == null || host.isEmpty()) {
139239
throw new IllegalArgumentException("Unable to determine host for the url provided " + url);
140240
}
141241

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

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

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-
}
251+
if (isIpAddress(host) && rejectVerbatimIps) {
252+
throw new IllegalArgumentException("Verbatim IP addresses are not allowed");
163253
}
164254

165-
for (String ip : rejectedIps) {
166-
IpAddressMatcher ipMatcher = new IpAddressMatcher(ip);
255+
var addr = resolveHost(host);
256+
var isLocalhost = isLocalhost(addr);
257+
var isLinkLocal = isLinkLocal(addr);
258+
259+
if ((isLocalhost && rejectLocalhost) || (isLinkLocal && rejectLinkLocal)) {
260+
throw new IllegalArgumentException("Host not allowed: " + host);
261+
}
167262

168-
if (ipMatcher.matches(host)) {
169-
throw new IllegalArgumentException("address " + host + " is within rejected IPs: " + ip);
263+
if (!isValidHostname(host) && !isIpAddress(host)) {
264+
// If localhost or link local is allowed, that takes precedence over the name filter
265+
// This avoids the need to include local names in the hostname pattern in addition to
266+
// setting the local config flag
267+
if (!(isLocalhost || isLinkLocal)) {
268+
throw new IllegalArgumentException("Host not allowed: " + host);
170269
}
171270
}
172271

272+
if (!isValidIpAddress(host)) {
273+
throw new IllegalArgumentException("Address not allowed: " + host);
274+
}
275+
173276
return u;
174277
} catch (IllegalArgumentException iae) {
175278
throw iae;
176279
} catch (Exception ex) {
177-
throw new IllegalArgumentException("URI not valid " + url, ex);
280+
throw new IllegalArgumentException("URI not valid: " + url, ex);
178281
}
179282
}
180283

0 commit comments

Comments
 (0)