2121import org .codehaus .groovy .tools .GrapeUtil ;
2222
2323import java .net .URI ;
24+ import java .net .URISyntaxException ;
2425import java .util .Collections ;
2526import java .util .LinkedHashMap ;
2627import java .util .List ;
28+ import java .util .Locale ;
2729import java .util .Map ;
2830import java .util .ServiceConfigurationError ;
2931import 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
3137import static java .lang .System .Logger .Level .DEBUG ;
3238import 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}
0 commit comments