Skip to content

Commit 7f2d92e

Browse files
committed
GROOVY-12266: Add a policy for Grape resolver roots using a plaintext protocol
Neither engine checked the scheme of a resolver root, so a @GrabResolver naming an http:// repository silently fetched artifacts that anyone on the path could read or replace, and placed that repository first in lookup order. Both engines default to an https central repository, so this only arose for explicitly configured resolvers. Add -Dgroovy.grape.insecureProtocolPolicy, taking fail, warn or ignore and defaulting to warn, so that the resolver is reported but still added. The values mirror Maven's checksum-policy vocabulary. An unrecognised value falls back to warn rather than ignore, reported once, so that a typo cannot silently disable the check. The check lives in the Grape facade rather than in either engine because every documented route to adding a resolver -- @GrabResolver, the grape command line tool, and Grape.addResolver -- passes through it, so the Ivy and Maven engines are covered by one implementation and behave alike. Roots naming a loopback host are exempt under every policy since they do not cross a network, and each distinct root is reported at most once. Classification is an allow-list of known-plaintext schemes, currently http and ftp, so that encrypted transports such as s3 and gs are not reported falsely; file: roots are never insecure, as they cross no network and a network-mounted one cannot be told apart from a local one by inspecting the URI. Integrity for those repositories is checksum verification's job, which applies to every transport rather than only to remote ones.
1 parent 5f56279 commit 7f2d92e

2 files changed

Lines changed: 326 additions & 0 deletions

File tree

src/main/java/groovy/grape/Grape.java

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,18 @@
2121
import org.codehaus.groovy.tools.GrapeUtil;
2222

2323
import java.net.URI;
24+
import java.net.URISyntaxException;
2425
import java.util.Collections;
2526
import java.util.LinkedHashMap;
2627
import java.util.List;
28+
import java.util.Locale;
2729
import java.util.Map;
2830
import java.util.ServiceConfigurationError;
2931
import java.util.ServiceLoader;
32+
import java.util.Set;
33+
import java.util.concurrent.ConcurrentHashMap;
34+
import java.util.regex.Matcher;
35+
import java.util.regex.Pattern;
3036

3137
import static java.lang.System.Logger.Level.DEBUG;
3238
import static java.lang.System.Logger.Level.ERROR;
@@ -51,6 +57,16 @@ public class Grape {
5157
* Argument key for additional system properties.
5258
*/
5359
public static final String SYSTEM_PROPERTIES_SETTING = "systemProperties";
60+
/**
61+
* System property selecting what happens when a resolver root uses a plaintext protocol:
62+
* {@code fail} to reject the resolver, {@code warn} (the default) to log a warning and add
63+
* it anyway, or {@code ignore} to skip the check. An unrecognised value is treated as
64+
* {@code warn}. Values mirror Maven's checksum-policy vocabulary.
65+
*/
66+
public static final String INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY = "groovy.grape.insecureProtocolPolicy";
67+
private static final String INSECURE_PROTOCOL_POLICY_FAIL = "fail";
68+
private static final String INSECURE_PROTOCOL_POLICY_WARN = "warn";
69+
private static final String INSECURE_PROTOCOL_POLICY_IGNORE = "ignore";
5470
private static final String GRAPE_IMPL_SYSTEM_PROPERTY = "groovy.grape.impl";
5571
private static final String DEFAULT_GRAPE_ENGINE = "groovy.grape.ivy.GrapeIvy";
5672
private static final URI[] EMPTY_URI_ARRAY = new URI[0];
@@ -59,6 +75,12 @@ public class Grape {
5975
private static boolean enableGrapes = Boolean.parseBoolean(System.getProperty("groovy.grape.enable", "true"));
6076
private static boolean enableAutoDownload = Boolean.parseBoolean(System.getProperty("groovy.grape.autoDownload", "true"));
6177
private static boolean disableChecksums = Boolean.parseBoolean(System.getProperty("groovy.grape.disableChecksums", "false"));
78+
/** Resolver roots already reported as plaintext, so each is warned about only once. */
79+
private static final Set<String> WARNED_INSECURE_ROOTS = ConcurrentHashMap.newKeySet();
80+
/** Unrecognised insecure-protocol policy values already reported. */
81+
private static final Set<String> WARNED_POLICY_VALUES = ConcurrentHashMap.newKeySet();
82+
/** Dotted-quad IPv4 literal, capturing the first octet. */
83+
private static final Pattern IPV4_LITERAL = Pattern.compile("(\\d{1,3})(?:\\.\\d{1,3}){3}");
6284
/**
6385
* Lazily created grape engine instance.
6486
*/
@@ -380,16 +402,147 @@ public static Map[] listDependencies(ClassLoader cl) {
380402

381403
/**
382404
* Adds a resolver to the shared grape engine.
405+
* <p>
406+
* A resolver root using a plaintext protocol is subject to
407+
* {@value #INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY}: {@code warn} (the default) logs a
408+
* warning and adds the resolver, {@code fail} rejects it, and {@code ignore} skips the
409+
* check. Roots naming a loopback host are exempt under every policy.
383410
*
384411
* @param args the resolver descriptor
412+
* @throws RuntimeException under the {@code fail} policy, if the root is a plaintext remote root
385413
*/
386414
public static void addResolver(Map<String, Object> args) {
387415
if (enableGrapes) {
416+
checkResolverRootProtocol(args);
388417
GrapeEngine instance = getInstance();
389418
if (instance != null) {
390419
instance.addResolver(args);
391420
}
392421
}
393422
}
394423

424+
/**
425+
* Applies {@value #INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY} to a resolver descriptor.
426+
*
427+
* @param args the resolver descriptor
428+
* @throws RuntimeException under the {@code fail} policy, if the root is a plaintext remote root
429+
*/
430+
private static void checkResolverRootProtocol(Map<String, Object> args) {
431+
if (args == null) {
432+
return;
433+
}
434+
String policy = insecureProtocolPolicy();
435+
if (INSECURE_PROTOCOL_POLICY_IGNORE.equals(policy)) {
436+
return;
437+
}
438+
Object value = args.get("root");
439+
if (value == null) value = args.get("value");
440+
if (!(value instanceof CharSequence)) {
441+
return;
442+
}
443+
String root = value.toString();
444+
if (!isInsecureResolverRoot(root)) {
445+
return;
446+
}
447+
Object name = args.get("name");
448+
Object label = name != null ? name : root;
449+
if (INSECURE_PROTOCOL_POLICY_FAIL.equals(policy)) {
450+
throw new RuntimeException("Grape resolver '" + label + "' uses the plaintext root '" + root
451+
+ "' and was rejected because -D" + INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY + "="
452+
+ INSECURE_PROTOCOL_POLICY_FAIL + " is set. Use an https root, or relax the policy to '"
453+
+ INSECURE_PROTOCOL_POLICY_WARN + "' or '" + INSECURE_PROTOCOL_POLICY_IGNORE + "'.");
454+
}
455+
// Warn once per distinct root; a script may add the same resolver repeatedly.
456+
if (WARNED_INSECURE_ROOTS.add(root)) {
457+
LOGGER.log(WARNING,
458+
"Grape resolver ''{0}'' uses the plaintext root ''{1}''; artifacts fetched from it can be"
459+
+ " read or modified in transit. Prefer https, or set -D{2}={3} to silence this warning.",
460+
label, root, INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY, INSECURE_PROTOCOL_POLICY_IGNORE);
461+
}
462+
}
463+
464+
/**
465+
* Returns the configured insecure-protocol policy, defaulting to {@code warn}. An
466+
* unrecognised value falls back to {@code warn} rather than to the laxer {@code ignore},
467+
* so that a typo cannot silently disable the check; the fallback is reported once per
468+
* offending value.
469+
*
470+
* @return one of {@code fail}, {@code warn} or {@code ignore}
471+
*/
472+
static String insecureProtocolPolicy() {
473+
String policy = System.getProperty(INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY, INSECURE_PROTOCOL_POLICY_WARN)
474+
.trim().toLowerCase(Locale.ROOT);
475+
if (INSECURE_PROTOCOL_POLICY_FAIL.equals(policy)
476+
|| INSECURE_PROTOCOL_POLICY_WARN.equals(policy)
477+
|| INSECURE_PROTOCOL_POLICY_IGNORE.equals(policy)) {
478+
return policy;
479+
}
480+
if (WARNED_POLICY_VALUES.add(policy)) {
481+
LOGGER.log(WARNING, "Unrecognised -D{0} value ''{1}''; using ''{2}''. Expected one of {3}, {4}, {5}.",
482+
INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY, policy, INSECURE_PROTOCOL_POLICY_WARN,
483+
INSECURE_PROTOCOL_POLICY_FAIL, INSECURE_PROTOCOL_POLICY_WARN, INSECURE_PROTOCOL_POLICY_IGNORE);
484+
}
485+
return INSECURE_PROTOCOL_POLICY_WARN;
486+
}
487+
488+
/**
489+
* Returns whether the given resolver root fetches over a plaintext protocol from a host
490+
* other than loopback.
491+
* <p>
492+
* Only schemes known to be plaintext are classified as insecure, currently {@code http}
493+
* and {@code ftp}. This is deliberately an allow-list of bad schemes rather than a
494+
* deny-list of good ones: transports such as {@code s3} and {@code gs} are encrypted in
495+
* practice and would otherwise be reported falsely. The consequence is that an exotic
496+
* plaintext scheme is not reported, so {@code fail} means "reject known-plaintext roots",
497+
* not "reject anything not proven safe".
498+
* <p>
499+
* {@code file:} roots are never insecure. They cross no network, and a {@code file:} root
500+
* on a network mount cannot be distinguished from a local one by inspecting the URI.
501+
* Integrity for such repositories is the job of checksum verification, which applies to
502+
* every transport rather than only to remote ones. Roots which are not valid URIs, or
503+
* which name no scheme at all, are likewise left to the engine.
504+
*
505+
* @param root the resolver root
506+
* @return true if the root is a plaintext remote root
507+
*/
508+
static boolean isInsecureResolverRoot(String root) {
509+
if (root == null) {
510+
return false;
511+
}
512+
String scheme;
513+
String host;
514+
try {
515+
URI uri = new URI(root.trim());
516+
scheme = uri.getScheme();
517+
host = uri.getHost();
518+
} catch (URISyntaxException e) {
519+
return false; // not a URI we can reason about; leave it to the engine
520+
}
521+
if (scheme == null) {
522+
return false;
523+
}
524+
scheme = scheme.toLowerCase(Locale.ROOT);
525+
if (!"http".equals(scheme) && !"ftp".equals(scheme)) {
526+
return false;
527+
}
528+
return !isLoopbackHost(host);
529+
}
530+
531+
private static boolean isLoopbackHost(String host) {
532+
if (host == null) {
533+
return false;
534+
}
535+
String name = host.toLowerCase(Locale.ROOT);
536+
if (name.startsWith("[") && name.endsWith("]")) { // IPv6 literal
537+
name = name.substring(1, name.length() - 1);
538+
}
539+
if ("localhost".equals(name) || "::1".equals(name)) {
540+
return true;
541+
}
542+
// 127.0.0.0/8, matched as a dotted quad so that a host merely beginning with "127."
543+
// (such as 127.example.com) is not mistaken for a loopback address.
544+
Matcher ipv4 = IPV4_LITERAL.matcher(name);
545+
return ipv4.matches() && "127".equals(ipv4.group(1));
546+
}
547+
395548
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package groovy.grape
20+
21+
import groovy.transform.CompileStatic
22+
import org.junit.jupiter.api.Test
23+
24+
import static groovy.test.GroovyAssert.shouldFail
25+
26+
/**
27+
* Tests the classification behind the warning Grape logs for plaintext resolver roots.
28+
* The classification is shared by both engines because every documented route to adding a
29+
* resolver -- {@code @GrabResolver}, the {@code grape} command line tool and
30+
* {@link Grape#addResolver(java.util.Map)} -- passes through the same facade method.
31+
*/
32+
@CompileStatic
33+
final class GrapeInsecureResolverRootTest {
34+
35+
@Test
36+
void testPlaintextRemoteRootsAreInsecure() {
37+
assert Grape.isInsecureResolverRoot('http://repo.corp.example/maven2')
38+
assert Grape.isInsecureResolverRoot('ftp://repo.corp.example/maven2')
39+
}
40+
41+
@Test
42+
void testSchemeComparisonIgnoresCase() {
43+
assert Grape.isInsecureResolverRoot('HTTP://repo.corp.example/maven2')
44+
assert !Grape.isInsecureResolverRoot('HTTPS://repo.corp.example/maven2')
45+
}
46+
47+
@Test
48+
void testSurroundingWhitespaceIsIgnored() {
49+
assert Grape.isInsecureResolverRoot(' http://repo.corp.example/maven2 ')
50+
}
51+
52+
@Test
53+
void testEncryptedRootsAreNotInsecure() {
54+
assert !Grape.isInsecureResolverRoot('https://repo.maven.apache.org/maven2/')
55+
}
56+
57+
@Test
58+
void testLocalRootsAreNotInsecure() {
59+
// file: roots never cross a network, so the warning would be noise.
60+
assert !Grape.isInsecureResolverRoot('file:/home/dev/repo')
61+
assert !Grape.isInsecureResolverRoot(new File('build').toURI().toString())
62+
}
63+
64+
@Test
65+
void testLoopbackRootsAreExempt() {
66+
// A local mirror or proxy over plaintext is not exposed in transit.
67+
assert !Grape.isInsecureResolverRoot('http://localhost:8081/repository/maven-public')
68+
assert !Grape.isInsecureResolverRoot('http://LocalHost:8081/repository/maven-public')
69+
assert !Grape.isInsecureResolverRoot('http://127.0.0.1:8081/repo')
70+
assert !Grape.isInsecureResolverRoot('http://127.1.2.3/repo')
71+
assert !Grape.isInsecureResolverRoot('http://[::1]:8081/repo')
72+
}
73+
74+
@Test
75+
void testNonLoopbackLookalikesAreStillInsecure() {
76+
// Guard the prefix test against hosts that merely start with the same text.
77+
assert Grape.isInsecureResolverRoot('http://127.evil.example/repo')
78+
assert Grape.isInsecureResolverRoot('http://localhost.evil.example/repo')
79+
}
80+
81+
@Test
82+
void testUnusableRootsAreLeftToTheEngine() {
83+
assert !Grape.isInsecureResolverRoot(null)
84+
assert !Grape.isInsecureResolverRoot('')
85+
assert !Grape.isInsecureResolverRoot('not a uri at all')
86+
assert !Grape.isInsecureResolverRoot('repo.corp.example/maven2') // no scheme
87+
}
88+
89+
@Test
90+
void testUnknownSchemesAreNotReported() {
91+
// Deliberate: an allow-list of known-plaintext schemes, so encrypted transports such
92+
// as s3 and gs are not reported falsely. See the isInsecureResolverRoot javadoc.
93+
assert !Grape.isInsecureResolverRoot('s3://corp-artifacts/maven2')
94+
assert !Grape.isInsecureResolverRoot('gs://corp-artifacts/maven2')
95+
}
96+
97+
// --- policy selection ---
98+
99+
@Test
100+
void testPolicyDefaultsToWarn() {
101+
withPolicy(null) {
102+
assert Grape.insecureProtocolPolicy() == 'warn'
103+
}
104+
}
105+
106+
@Test
107+
void testPolicyValuesAreRecognised() {
108+
withPolicy('fail') { assert Grape.insecureProtocolPolicy() == 'fail' }
109+
withPolicy('warn') { assert Grape.insecureProtocolPolicy() == 'warn' }
110+
withPolicy('ignore') { assert Grape.insecureProtocolPolicy() == 'ignore' }
111+
}
112+
113+
@Test
114+
void testPolicyIsCaseInsensitiveAndTrimmed() {
115+
withPolicy(' FAIL ') { assert Grape.insecureProtocolPolicy() == 'fail' }
116+
}
117+
118+
@Test
119+
void testUnrecognisedPolicyFallsBackToWarnNotIgnore() {
120+
// A typo must not silently disable the check, so the fallback is the stricter of the
121+
// two non-failing policies.
122+
withPolicy('flase') { assert Grape.insecureProtocolPolicy() == 'warn' }
123+
withPolicy('true') { assert Grape.insecureProtocolPolicy() == 'warn' }
124+
}
125+
126+
// --- policy application ---
127+
128+
@Test
129+
void testFailPolicyRejectsPlaintextRoot() {
130+
withPolicy('fail') {
131+
def ex = shouldFail(RuntimeException) {
132+
Grape.addResolver([name: 'corp', root: 'http://repo.corp.example/maven2'] as Map<String, Object>)
133+
}
134+
assert ex.message.contains('plaintext root')
135+
assert ex.message.contains(Grape.INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY)
136+
}
137+
}
138+
139+
@Test
140+
void testFailPolicyAllowsSecureLoopbackAndFileRoots() {
141+
withPolicy('fail') {
142+
Grape.addResolver([name: 'secure', root: 'https://repo.corp.example/maven2'] as Map<String, Object>)
143+
Grape.addResolver([name: 'local', root: 'http://localhost:8081/repo'] as Map<String, Object>)
144+
Grape.addResolver([name: 'onDisk', root: 'file:/home/dev/repo'] as Map<String, Object>)
145+
}
146+
}
147+
148+
@Test
149+
void testIgnorePolicyAcceptsPlaintextRoot() {
150+
withPolicy('ignore') {
151+
Grape.addResolver([name: 'corp', root: 'http://repo.corp.example/maven2'] as Map<String, Object>)
152+
}
153+
}
154+
155+
private static void withPolicy(String value, Closure body) {
156+
String property = Grape.INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY
157+
String previous = System.getProperty(property)
158+
if (value == null) {
159+
System.clearProperty(property)
160+
} else {
161+
System.setProperty(property, value)
162+
}
163+
try {
164+
body()
165+
} finally {
166+
if (previous == null) {
167+
System.clearProperty(property)
168+
} else {
169+
System.setProperty(property, previous)
170+
}
171+
}
172+
}
173+
}

0 commit comments

Comments
 (0)