Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions src/main/java/groovy/grape/Grape.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,18 @@
import org.codehaus.groovy.tools.GrapeUtil;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.ServiceConfigurationError;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

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

/**
* Adds a resolver to the shared grape engine.
* <p>
* A resolver root using a plaintext protocol is subject to
* {@value #INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY}: {@code warn} (the default) logs a
* warning and adds the resolver, {@code fail} rejects it, and {@code ignore} skips the
* check. Roots naming a loopback host are exempt under every policy.
*
* @param args the resolver descriptor
* @throws RuntimeException under the {@code fail} policy, if the root is a plaintext remote root
*/
public static void addResolver(Map<String, Object> args) {
if (enableGrapes) {
checkResolverRootProtocol(args);
GrapeEngine instance = getInstance();
if (instance != null) {
instance.addResolver(args);
}
}
}

/**
* Applies {@value #INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY} to a resolver descriptor.
*
* @param args the resolver descriptor
* @throws RuntimeException under the {@code fail} policy, if the root is a plaintext remote root
*/
private static void checkResolverRootProtocol(Map<String, Object> args) {
if (args == null) {
return;
}
String policy = insecureProtocolPolicy();
if (INSECURE_PROTOCOL_POLICY_IGNORE.equals(policy)) {
return;
}
Object value = args.get("root");
if (value == null) value = args.get("value");
if (!(value instanceof CharSequence)) {
return;
}
String root = value.toString();
if (!isInsecureResolverRoot(root)) {
return;
}
Object name = args.get("name");
Object label = name != null ? name : root;
if (INSECURE_PROTOCOL_POLICY_FAIL.equals(policy)) {
throw new RuntimeException("Grape resolver '" + label + "' uses the plaintext root '" + root
+ "' and was rejected because -D" + INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY + "="
+ INSECURE_PROTOCOL_POLICY_FAIL + " is set. Use an https root, or relax the policy to '"
+ INSECURE_PROTOCOL_POLICY_WARN + "' or '" + INSECURE_PROTOCOL_POLICY_IGNORE + "'.");
}
// Warn once per distinct root; a script may add the same resolver repeatedly.
if (WARNED_INSECURE_ROOTS.add(root)) {
LOGGER.log(WARNING,
"Grape resolver ''{0}'' uses the plaintext root ''{1}''; artifacts fetched from it can be"
+ " read or modified in transit. Prefer https, or set -D{2}={3} to silence this warning.",
label, root, INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY, INSECURE_PROTOCOL_POLICY_IGNORE);
}
Comment on lines +443 to +461
}

/**
* Returns the configured insecure-protocol policy, defaulting to {@code warn}. An
* unrecognised value falls back to {@code warn} rather than to the laxer {@code ignore},
* so that a typo cannot silently disable the check; the fallback is reported once per
* offending value.
*
* @return one of {@code fail}, {@code warn} or {@code ignore}
*/
static String insecureProtocolPolicy() {
String policy = System.getProperty(INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY, INSECURE_PROTOCOL_POLICY_WARN)
.trim().toLowerCase(Locale.ROOT);
if (INSECURE_PROTOCOL_POLICY_FAIL.equals(policy)
|| INSECURE_PROTOCOL_POLICY_WARN.equals(policy)
|| INSECURE_PROTOCOL_POLICY_IGNORE.equals(policy)) {
return policy;
}
if (WARNED_POLICY_VALUES.add(policy)) {
LOGGER.log(WARNING, "Unrecognised -D{0} value ''{1}''; using ''{2}''. Expected one of {3}, {4}, {5}.",
INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY, policy, INSECURE_PROTOCOL_POLICY_WARN,
INSECURE_PROTOCOL_POLICY_FAIL, INSECURE_PROTOCOL_POLICY_WARN, INSECURE_PROTOCOL_POLICY_IGNORE);
}
return INSECURE_PROTOCOL_POLICY_WARN;
}

/**
* Returns whether the given resolver root fetches over a plaintext protocol from a host
* other than loopback.
* <p>
* Only schemes known to be plaintext are classified as insecure, currently {@code http}
* and {@code ftp}. This is deliberately an allow-list of bad schemes rather than a
* deny-list of good ones: transports such as {@code s3} and {@code gs} are encrypted in
* practice and would otherwise be reported falsely. The consequence is that an exotic
* plaintext scheme is not reported, so {@code fail} means "reject known-plaintext roots",
* not "reject anything not proven safe".
* <p>
* {@code file:} roots are never insecure. They cross no network, and a {@code file:} root
* on a network mount cannot be distinguished from a local one by inspecting the URI.
* Integrity for such repositories is the job of checksum verification, which applies to
* every transport rather than only to remote ones. Roots which are not valid URIs, or
* which name no scheme at all, are likewise left to the engine.
*
* @param root the resolver root
* @return true if the root is a plaintext remote root
*/
static boolean isInsecureResolverRoot(String root) {
if (root == null) {
return false;
}
String scheme;
String host;
try {
URI uri = new URI(root.trim());
scheme = uri.getScheme();
host = uri.getHost();
} catch (URISyntaxException e) {
return false; // not a URI we can reason about; leave it to the engine
}
if (scheme == null) {
return false;
}
scheme = scheme.toLowerCase(Locale.ROOT);
if (!"http".equals(scheme) && !"ftp".equals(scheme)) {
return false;
}
return !isLoopbackHost(host);
}

private static boolean isLoopbackHost(String host) {
if (host == null) {
return false;
}
String name = host.toLowerCase(Locale.ROOT);
if (name.startsWith("[") && name.endsWith("]")) { // IPv6 literal
name = name.substring(1, name.length() - 1);
}
if ("localhost".equals(name) || "::1".equals(name)) {
return true;
}
// 127.0.0.0/8, matched as a dotted quad so that a host merely beginning with "127."
// (such as 127.example.com) is not mistaken for a loopback address.
Matcher ipv4 = IPV4_LITERAL.matcher(name);
return ipv4.matches() && "127".equals(ipv4.group(1));
}

Comment on lines +545 to +547
}
173 changes: 173 additions & 0 deletions src/test/groovy/groovy/grape/GrapeInsecureResolverRootTest.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package groovy.grape

import groovy.transform.CompileStatic
import org.junit.jupiter.api.Test

import static groovy.test.GroovyAssert.shouldFail

/**
* Tests the classification behind the warning Grape logs for plaintext resolver roots.
* The classification is shared by both engines because every documented route to adding a
* resolver -- {@code @GrabResolver}, the {@code grape} command line tool and
* {@link Grape#addResolver(java.util.Map)} -- passes through the same facade method.
*/
@CompileStatic
final class GrapeInsecureResolverRootTest {

@Test
void testPlaintextRemoteRootsAreInsecure() {
assert Grape.isInsecureResolverRoot('http://repo.corp.example/maven2')
assert Grape.isInsecureResolverRoot('ftp://repo.corp.example/maven2')
}

@Test
void testSchemeComparisonIgnoresCase() {
assert Grape.isInsecureResolverRoot('HTTP://repo.corp.example/maven2')
assert !Grape.isInsecureResolverRoot('HTTPS://repo.corp.example/maven2')
}

@Test
void testSurroundingWhitespaceIsIgnored() {
assert Grape.isInsecureResolverRoot(' http://repo.corp.example/maven2 ')
}

@Test
void testEncryptedRootsAreNotInsecure() {
assert !Grape.isInsecureResolverRoot('https://repo.maven.apache.org/maven2/')
}

@Test
void testLocalRootsAreNotInsecure() {
// file: roots never cross a network, so the warning would be noise.
assert !Grape.isInsecureResolverRoot('file:/home/dev/repo')
assert !Grape.isInsecureResolverRoot(new File('build').toURI().toString())
}

@Test
void testLoopbackRootsAreExempt() {
// A local mirror or proxy over plaintext is not exposed in transit.
assert !Grape.isInsecureResolverRoot('http://localhost:8081/repository/maven-public')
assert !Grape.isInsecureResolverRoot('http://LocalHost:8081/repository/maven-public')
assert !Grape.isInsecureResolverRoot('http://127.0.0.1:8081/repo')
assert !Grape.isInsecureResolverRoot('http://127.1.2.3/repo')
assert !Grape.isInsecureResolverRoot('http://[::1]:8081/repo')
}

@Test
void testNonLoopbackLookalikesAreStillInsecure() {
// Guard the prefix test against hosts that merely start with the same text.
assert Grape.isInsecureResolverRoot('http://127.evil.example/repo')
assert Grape.isInsecureResolverRoot('http://localhost.evil.example/repo')
}

@Test
void testUnusableRootsAreLeftToTheEngine() {
assert !Grape.isInsecureResolverRoot(null)
assert !Grape.isInsecureResolverRoot('')
assert !Grape.isInsecureResolverRoot('not a uri at all')
assert !Grape.isInsecureResolverRoot('repo.corp.example/maven2') // no scheme
}

@Test
void testUnknownSchemesAreNotReported() {
// Deliberate: an allow-list of known-plaintext schemes, so encrypted transports such
// as s3 and gs are not reported falsely. See the isInsecureResolverRoot javadoc.
assert !Grape.isInsecureResolverRoot('s3://corp-artifacts/maven2')
assert !Grape.isInsecureResolverRoot('gs://corp-artifacts/maven2')
}

// --- policy selection ---

@Test
void testPolicyDefaultsToWarn() {
withPolicy(null) {
assert Grape.insecureProtocolPolicy() == 'warn'
}
}

@Test
void testPolicyValuesAreRecognised() {
withPolicy('fail') { assert Grape.insecureProtocolPolicy() == 'fail' }
withPolicy('warn') { assert Grape.insecureProtocolPolicy() == 'warn' }
withPolicy('ignore') { assert Grape.insecureProtocolPolicy() == 'ignore' }
}

@Test
void testPolicyIsCaseInsensitiveAndTrimmed() {
withPolicy(' FAIL ') { assert Grape.insecureProtocolPolicy() == 'fail' }
}

@Test
void testUnrecognisedPolicyFallsBackToWarnNotIgnore() {
// A typo must not silently disable the check, so the fallback is the stricter of the
// two non-failing policies.
withPolicy('flase') { assert Grape.insecureProtocolPolicy() == 'warn' }
withPolicy('true') { assert Grape.insecureProtocolPolicy() == 'warn' }
}

// --- policy application ---

@Test
void testFailPolicyRejectsPlaintextRoot() {
withPolicy('fail') {
def ex = shouldFail(RuntimeException) {
Grape.addResolver([name: 'corp', root: 'http://repo.corp.example/maven2'] as Map<String, Object>)
}
Comment on lines +131 to +133
assert ex.message.contains('plaintext root')
assert ex.message.contains(Grape.INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY)
}
}

@Test
void testFailPolicyAllowsSecureLoopbackAndFileRoots() {
withPolicy('fail') {
Grape.addResolver([name: 'secure', root: 'https://repo.corp.example/maven2'] as Map<String, Object>)
Grape.addResolver([name: 'local', root: 'http://localhost:8081/repo'] as Map<String, Object>)
Grape.addResolver([name: 'onDisk', root: 'file:/home/dev/repo'] as Map<String, Object>)
}
Comment on lines +141 to +145
}

@Test
void testIgnorePolicyAcceptsPlaintextRoot() {
withPolicy('ignore') {
Grape.addResolver([name: 'corp', root: 'http://repo.corp.example/maven2'] as Map<String, Object>)
}
Comment on lines +150 to +152
}

private static void withPolicy(String value, Closure body) {
String property = Grape.INSECURE_PROTOCOL_POLICY_SYSTEM_PROPERTY
String previous = System.getProperty(property)
if (value == null) {
System.clearProperty(property)
} else {
System.setProperty(property, value)
}
try {
body()
} finally {
if (previous == null) {
System.clearProperty(property)
} else {
System.setProperty(property, previous)
}
}
}
}
Loading