Skip to content

Commit d0b44da

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 d0b44da

File tree

2 files changed

+341
-40
lines changed

2 files changed

+341
-40
lines changed

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

Lines changed: 132 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,27 @@
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 webhook requests to the current k8s namespace, for
61+
// example
62+
private List<String> excludedDomainsFromEnvironment =
63+
List.of("POD_NAMESPACE", "ISTIO_META_MESH_ID");
64+
private List<String> extraExcludedPatterns = List.of();
65+
4866
public Builder withAllowedHostnamesRegex(String allowedHostnamesRegex) {
4967
setAllowedHostnamesRegex(allowedHostnamesRegex);
5068
return this;
@@ -65,6 +83,11 @@ public Builder withRejectLinkLocal(boolean rejectLinkLocal) {
6583
return this;
6684
}
6785

86+
public Builder withRejectVerbatimIps(boolean rejectVerbatimIps) {
87+
setRejectVerbatimIps(rejectVerbatimIps);
88+
return this;
89+
}
90+
6891
public Builder withRejectedIps(List<String> rejectedIpRanges) {
6992
setRejectedIps(rejectedIpRanges);
7093
return this;
@@ -75,43 +98,117 @@ public Builder withHttpClientProperties(HttpClientProperties httpClientPropertie
7598
return this;
7699
}
77100

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

89153
private final Pattern allowedHostnames;
90154
private final Set<String> allowedSchemes;
91155
private final boolean rejectLocalhost;
92156
private final boolean rejectLinkLocal;
157+
private final boolean rejectVerbatimIps;
93158
private final Set<String> rejectedIps;
94159
private final HttpClientProperties clientProperties;
160+
private final List<Pattern> excludedPatterns;
95161

96-
public UserConfiguredUrlRestrictions(
162+
protected UserConfiguredUrlRestrictions(
97163
Pattern allowedHostnames,
98164
Collection<String> allowedSchemes,
99165
boolean rejectLocalhost,
100166
boolean rejectLinkLocal,
167+
boolean rejectVerbatimIps,
101168
Collection<String> rejectedIps,
102-
HttpClientProperties clientProperties) {
169+
HttpClientProperties clientProperties,
170+
List<Pattern> excludedPatterns) {
103171
this.allowedHostnames = allowedHostnames;
104172
this.allowedSchemes =
105173
allowedSchemes == null
106174
? Collections.emptySet()
107175
: Collections.unmodifiableSet(new HashSet<>(allowedSchemes));
108176
this.rejectLocalhost = rejectLocalhost;
109177
this.rejectLinkLocal = rejectLinkLocal;
178+
this.rejectVerbatimIps = rejectVerbatimIps;
110179
this.rejectedIps =
111180
rejectedIps == null
112181
? Collections.emptySet()
113182
: Collections.unmodifiableSet(new HashSet<>(rejectedIps));
114183
this.clientProperties = clientProperties;
184+
this.excludedPatterns = excludedPatterns;
185+
}
186+
187+
InetAddress resolveHost(String host) throws UnknownHostException {
188+
return InetAddress.getByName(host);
189+
}
190+
191+
boolean isLocalhost(InetAddress addr) throws SocketException {
192+
return addr.isLoopbackAddress()
193+
|| Optional.ofNullable(NetworkInterface.getByInetAddress(addr)).isPresent();
194+
}
195+
196+
boolean isLinkLocal(InetAddress addr) {
197+
return addr.isLinkLocalAddress();
198+
}
199+
200+
boolean isValidHostname(String host) {
201+
return allowedHostnames.matcher(host).matches()
202+
&& excludedPatterns.stream().noneMatch(p -> p.matcher(host).matches());
203+
}
204+
205+
boolean isValidIpAddress(String host) {
206+
var matcher = new IpAddressMatcher(host);
207+
return rejectedIps.stream().noneMatch(matcher::matches);
208+
}
209+
210+
boolean isIpAddress(String host) {
211+
return InetAddresses.isInetAddress(host);
115212
}
116213

117214
public URI validateURI(String url) throws IllegalArgumentException {
@@ -130,12 +227,17 @@ public URI validateURI(String url) throws IllegalArgumentException {
130227
if (host == null) {
131228
String authority = u.getAuthority();
132229
if (authority != null) {
133-
int portIndex = authority.indexOf(":");
134-
host = (portIndex > -1) ? authority.substring(0, portIndex) : authority;
230+
// Don't attempt to colon-substring ipv6 addresses
231+
if (isIpAddress(authority)) {
232+
host = authority;
233+
} else {
234+
int portIndex = authority.indexOf(":");
235+
host = (portIndex > -1) ? authority.substring(0, portIndex) : authority;
236+
}
135237
}
136238
}
137239

138-
if (host == null) {
240+
if (host == null || host.isEmpty()) {
139241
throw new IllegalArgumentException("Unable to determine host for the url provided " + url);
140242
}
141243

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

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

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-
}
253+
if (isIpAddress(host) && rejectVerbatimIps) {
254+
throw new IllegalArgumentException("Verbatim IP addresses are not allowed");
163255
}
164256

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

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

274+
if (!isValidIpAddress(host)) {
275+
throw new IllegalArgumentException("Address not allowed: " + host);
276+
}
277+
173278
return u;
174279
} catch (IllegalArgumentException iae) {
175280
throw iae;
176281
} catch (Exception ex) {
177-
throw new IllegalArgumentException("URI not valid " + url, ex);
282+
throw new IllegalArgumentException("URI not valid: " + url, ex);
178283
}
179284
}
180285

0 commit comments

Comments
 (0)