diff --git a/compat/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java b/compat/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java index 28b912868dec..8e41862b2b18 100644 --- a/compat/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java +++ b/compat/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java @@ -80,7 +80,10 @@ public AuthenticationInfo getAuthenticationInfo(String id) { if (servers != null) { for (Server server : servers) { - if (id.equalsIgnoreCase(server.getId())) { + // Server ids are matched exactly, consistent with + // LegacyRepositorySystem.injectAuthentication and the resolver's + // authentication selector. + if (id.equals(server.getId())) { SettingsDecryptionResult result = settingsDecrypter.decrypt(new DefaultSettingsDecryptionRequest(server)); server = result.getServer(); diff --git a/compat/maven-compat/src/test/java/org/apache/maven/project/artifact/MavenMetadataSourceRelocationTest.java b/compat/maven-compat/src/test/java/org/apache/maven/project/artifact/MavenMetadataSourceRelocationTest.java index 34ed688eb3b3..c4f18d75a82b 100644 --- a/compat/maven-compat/src/test/java/org/apache/maven/project/artifact/MavenMetadataSourceRelocationTest.java +++ b/compat/maven-compat/src/test/java/org/apache/maven/project/artifact/MavenMetadataSourceRelocationTest.java @@ -43,6 +43,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -130,8 +131,8 @@ void testRelocationInvalidArtifactIdIsRejected() throws Exception { ArtifactMetadataRetrievalException exception = assertThrows(ArtifactMetadataRetrievalException.class, () -> source.retrieve(request)); - assertEquals(true, exception.getMessage().contains("a/b")); - assertEquals(true, exception.getMessage().contains("artifactId")); + assertTrue(exception.getMessage().contains("a/b")); + assertTrue(exception.getMessage().contains("artifactId")); } @Test diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java index 6648ffc9d8de..d1ee91a346e3 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java @@ -27,6 +27,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import org.apache.maven.api.Constants; @@ -36,6 +37,7 @@ import org.apache.maven.api.feature.Features; import org.apache.maven.api.services.TypeRegistry; import org.apache.maven.api.xml.XmlNode; +import org.apache.maven.artifact.repository.ArtifactRepository; import org.apache.maven.eventspy.internal.EventSpyDispatcher; import org.apache.maven.execution.MavenExecutionRequest; import org.apache.maven.impl.resolver.MavenSessionBuilderSupplier; @@ -53,6 +55,7 @@ import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.RepositorySystemSession.SessionBuilder; import org.eclipse.aether.collection.VersionFilterBuilder; +import org.eclipse.aether.repository.AuthenticationSelector; import org.eclipse.aether.repository.RepositoryPolicy; import org.eclipse.aether.resolution.ResolutionErrorPolicy; import org.eclipse.aether.util.listener.ChainedRepositoryListener; @@ -96,6 +99,24 @@ public class DefaultRepositorySystemSessionFactory implements RepositorySystemSe public static final String MAVEN_RESOLVER_TRANSPORT_AUTO = "auto"; + /** + * User property selecting how server credentials configured in settings are scoped to repositories: + * + * + * @since 4.0.0 + */ + public static final String MAVEN_REPOSITORY_CREDENTIAL_SCOPE = "maven.repository.credentialScope"; + private static final String WAGON_TRANSPORTER_PRIORITY_KEY = "aether.priority.WagonTransporterFactory"; private static final String APACHE_HTTP_TRANSPORTER_PRIORITY_KEY = "aether.priority.ApacheTransporterFactory"; @@ -191,6 +212,10 @@ public SessionBuilder newRepositorySessionBuilder(MavenExecutionRequest request) .buildVersionFilter(mergedProps.get(Constants.MAVEN_VERSION_FILTER), this::parseVersionConstraint) .ifPresent(sessionBuilder::setVersionFilter); + // origins of the repositories and mirrors the operator declared for a given server id, used below + // to scope that id's credentials to the origin(s) it was actually configured for + Map> declaredRepositoryOrigins = new HashMap<>(); + DefaultMirrorSelector mirrorSelector = new DefaultMirrorSelector(); for (Mirror mirror : request.getMirrors()) { mirrorSelector.add( @@ -201,8 +226,17 @@ public SessionBuilder newRepositorySessionBuilder(MavenExecutionRequest request) mirror.isBlocked(), mirror.getMirrorOf(), mirror.getMirrorOfLayouts()); + OriginBoundAuthenticationSelector.addOrigin(declaredRepositoryOrigins, mirror.getId(), mirror.getUrl()); } sessionBuilder.setMirrorSelector(mirrorSelector); + for (ArtifactRepository repository : request.getRemoteRepositories()) { + OriginBoundAuthenticationSelector.addOrigin( + declaredRepositoryOrigins, repository.getId(), repository.getUrl()); + } + for (ArtifactRepository repository : request.getPluginArtifactRepositories()) { + OriginBoundAuthenticationSelector.addOrigin( + declaredRepositoryOrigins, repository.getId(), repository.getUrl()); + } DefaultProxySelector proxySelector = new DefaultProxySelector(); for (Proxy proxy : request.getProxies()) { @@ -306,7 +340,11 @@ public SessionBuilder newRepositorySessionBuilder(MavenExecutionRequest request) configProps.put("aether.transport.wagon.perms.fileMode." + server.getId(), server.getFilePermissions()); configProps.put("aether.transport.wagon.perms.dirMode." + server.getId(), server.getDirectoryPermissions()); } - sessionBuilder.setAuthenticationSelector(authSelector); + String credentialScope = mergedProps.getOrDefault( + MAVEN_REPOSITORY_CREDENTIAL_SCOPE, OriginBoundAuthenticationSelector.SCOPE_ORIGIN); + AuthenticationSelector effectiveAuthSelector = OriginBoundAuthenticationSelector.wrap( + authSelector, credentialScope, declaredRepositoryOrigins, logger); + sessionBuilder.setAuthenticationSelector(effectiveAuthSelector); Object transport = mergedProps.getOrDefault(Constants.MAVEN_RESOLVER_TRANSPORT, MAVEN_RESOLVER_TRANSPORT_DEFAULT); diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelector.java b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelector.java new file mode 100644 index 000000000000..030b38742d32 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelector.java @@ -0,0 +1,197 @@ +/* + * 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 org.apache.maven.internal.aether; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.aether.repository.Authentication; +import org.eclipse.aether.repository.AuthenticationSelector; +import org.eclipse.aether.repository.RemoteRepository; +import org.slf4j.Logger; + +import static java.util.Objects.requireNonNull; + +/** + * An {@link AuthenticationSelector} that scopes server credentials to the origin (protocol, host and + * port) of the repository or mirror the operator declared for the same server id. + *

+ * A repository's id and its origin are independent: this selector serves a server id's credentials + * only to a repository whose origin matches one the operator declared for that id, in settings or on + * the command line. Ids with no operator-declared origin keep the previous behaviour unless + * {@code strict} scope is requested, and a warning naming the target origin is emitted once per + * id/origin pair. + * + * @see DefaultRepositorySystemSessionFactory#MAVEN_REPOSITORY_CREDENTIAL_SCOPE + */ +class OriginBoundAuthenticationSelector implements AuthenticationSelector { + /** + * Credentials are bound to operator-declared origins; ids without a declared origin keep legacy + * behavior, with a warning. + */ + static final String SCOPE_ORIGIN = "origin"; + + /** + * Credentials are bound to operator-declared origins; ids without a declared origin get no + * credentials. + */ + static final String SCOPE_STRICT = "strict"; + + /** + * Legacy behavior: credentials are matched by server id only. + */ + static final String SCOPE_ID = "id"; + + private final AuthenticationSelector delegate; + + private final Map> declaredOrigins; + + private final boolean strict; + + private final Logger logger; + + private final Set reported = ConcurrentHashMap.newKeySet(); + + private OriginBoundAuthenticationSelector( + AuthenticationSelector delegate, Map> declaredOrigins, boolean strict, Logger logger) { + this.delegate = requireNonNull(delegate, "delegate"); + this.declaredOrigins = requireNonNull(declaredOrigins, "declaredOrigins"); + this.strict = strict; + this.logger = requireNonNull(logger, "logger"); + } + + /** + * Wraps the given selector according to the requested credential scope. + * + * @param delegate the selector holding the actual credentials, keyed by server id + * @param credentialScope one of {@link #SCOPE_ORIGIN}, {@link #SCOPE_STRICT} or {@link #SCOPE_ID} + * @param declaredOrigins origins of operator-declared repositories and mirrors, keyed by id + * @param logger logger used to report id/origin mismatches + * @return the delegate itself for {@link #SCOPE_ID}, an origin-bound wrapper otherwise + */ + static AuthenticationSelector wrap( + AuthenticationSelector delegate, + String credentialScope, + Map> declaredOrigins, + Logger logger) { + if (SCOPE_ID.equals(credentialScope)) { + return delegate; + } else if (SCOPE_ORIGIN.equals(credentialScope) || SCOPE_STRICT.equals(credentialScope)) { + return new OriginBoundAuthenticationSelector( + delegate, declaredOrigins, SCOPE_STRICT.equals(credentialScope), logger); + } else { + throw new IllegalArgumentException("Unknown value '" + credentialScope + "' for " + + DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE + + ". Supported values are: " + SCOPE_ORIGIN + ", " + SCOPE_STRICT + ", " + SCOPE_ID); + } + } + + /** + * Records the origin of an operator-declared repository or mirror for the given id. URLs without a + * parseable server authority (for example {@code file:} URLs) are ignored. + */ + static void addOrigin(Map> declaredOrigins, String id, String url) { + String origin = originOf(url); + if (id != null && origin != null) { + declaredOrigins.computeIfAbsent(id, k -> new HashSet<>()).add(origin); + } + } + + @Override + public Authentication getAuthentication(RemoteRepository repository) { + Authentication auth = delegate.getAuthentication(repository); + if (auth == null) { + return null; + } + String id = repository.getId(); + String origin = originOf(repository.getUrl()); + Set origins = declaredOrigins.get(id); + if (origins != null && !origins.isEmpty()) { + if (origin != null && origins.contains(origin)) { + return auth; + } + warnOnce( + id, + origin, + "Not using credentials of server '" + id + "' for repository " + repository.getUrl() + + ": the repository or mirror declared for this id resides at " + origins + + ". Set " + + DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE + "=" + + SCOPE_ID + " to restore legacy id-only credential matching."); + return null; + } + if (strict) { + warnOnce( + id, + origin, + "Not using credentials of server '" + id + "' for repository " + repository.getUrl() + + ": no repository or mirror with this id is declared in settings or on the command" + + " line, and " + DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE + + "=" + SCOPE_STRICT + " is in effect."); + return null; + } + warnOnce( + id, + origin, + "Using credentials of server '" + id + "' for repository " + repository.getUrl() + + ", although no repository or mirror with this id is declared in settings or on the" + + " command line. Set " + + DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE + + "=" + SCOPE_STRICT + " to refuse such credential use."); + return auth; + } + + private void warnOnce(String id, String origin, String message) { + if (reported.add(id + "->" + origin)) { + logger.warn(message); + } + } + + /** + * Returns the normalized origin ({@code protocol://host[:port]}, lower-cased, default http/https + * ports elided) of the given URL, or {@code null} if the URL has no parseable server authority. + */ + static String originOf(String url) { + if (url == null) { + return null; + } + try { + URI uri = new URI(url).parseServerAuthority(); + String scheme = uri.getScheme(); + String host = uri.getHost(); + if (scheme == null || host == null) { + return null; + } + scheme = scheme.toLowerCase(Locale.ROOT); + host = host.toLowerCase(Locale.ROOT); + int port = uri.getPort(); + if ((port == 80 && "http".equals(scheme)) || (port == 443 && "https".equals(scheme))) { + port = -1; + } + return port >= 0 ? scheme + "://" + host + ":" + port : scheme + "://" + host; + } catch (URISyntaxException e) { + return null; + } + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelectorTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelectorTest.java new file mode 100644 index 000000000000..794213ab17be --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelectorTest.java @@ -0,0 +1,149 @@ +/* + * 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 org.apache.maven.internal.aether; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.eclipse.aether.repository.AuthenticationSelector; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.util.repository.AuthenticationBuilder; +import org.eclipse.aether.util.repository.DefaultAuthenticationSelector; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * UT for {@link OriginBoundAuthenticationSelector}. + */ +class OriginBoundAuthenticationSelectorTest { + + private static final Logger LOGGER = LoggerFactory.getLogger(OriginBoundAuthenticationSelectorTest.class); + + private static AuthenticationSelector serverCredentials(String... ids) { + DefaultAuthenticationSelector selector = new DefaultAuthenticationSelector(); + for (String id : ids) { + selector.add( + id, + new AuthenticationBuilder() + .addUsername("user") + .addPassword("pass") + .build()); + } + return selector; + } + + private static Map> declared(String id, String url) { + Map> origins = new HashMap<>(); + OriginBoundAuthenticationSelector.addOrigin(origins, id, url); + return origins; + } + + private static RemoteRepository repo(String id, String url) { + return new RemoteRepository.Builder(id, "default", url).build(); + } + + @Test + void credentialsServedForDeclaredOrigin() { + AuthenticationSelector selector = OriginBoundAuthenticationSelector.wrap( + serverCredentials("releases"), + OriginBoundAuthenticationSelector.SCOPE_ORIGIN, + declared("releases", "https://repo.example.org/releases/"), + LOGGER); + + assertNotNull(selector.getAuthentication(repo("releases", "https://repo.example.org/releases/"))); + } + + @Test + void authenticationScopedToDeclaredOrigin() { + // credentials are scoped to the declared origin, so a different-origin + // repository with the same id is not served + AuthenticationSelector selector = OriginBoundAuthenticationSelector.wrap( + serverCredentials("releases"), + OriginBoundAuthenticationSelector.SCOPE_ORIGIN, + declared("releases", "https://repo.example.org/releases/"), + LOGGER); + + assertNull(selector.getAuthentication(repo("releases", "https://other.example.org/m2/"))); + // an unparseable URL on a bound id fails closed as well + assertNull(selector.getAuthentication(repo("releases", "notaurl"))); + } + + @Test + void undeclaredIdKeepsLegacyBehaviorInOriginScope() { + // e.g. a pure deployment server whose URL only exists in the project's distributionManagement + AuthenticationSelector selector = OriginBoundAuthenticationSelector.wrap( + serverCredentials("deploy-server"), + OriginBoundAuthenticationSelector.SCOPE_ORIGIN, + new HashMap<>(), + LOGGER); + + assertNotNull(selector.getAuthentication(repo("deploy-server", "https://deploy.example.org/releases/"))); + } + + @Test + void undeclaredIdRefusedInStrictScope() { + AuthenticationSelector selector = OriginBoundAuthenticationSelector.wrap( + serverCredentials("deploy-server"), + OriginBoundAuthenticationSelector.SCOPE_STRICT, + new HashMap<>(), + LOGGER); + + assertNull(selector.getAuthentication(repo("deploy-server", "https://deploy.example.org/releases/"))); + } + + @Test + void idScopeReturnsUnwrappedDelegate() { + AuthenticationSelector delegate = serverCredentials("releases"); + AuthenticationSelector selector = OriginBoundAuthenticationSelector.wrap( + delegate, + OriginBoundAuthenticationSelector.SCOPE_ID, + declared("releases", "https://repo.example.org/releases/"), + LOGGER); + + assertSame(delegate, selector); + assertNotNull(selector.getAuthentication(repo("releases", "https://other.example.org/m2/"))); + } + + @Test + void unknownScopeIsRejected() { + assertThrows( + IllegalArgumentException.class, + () -> OriginBoundAuthenticationSelector.wrap(serverCredentials(), "bogus", new HashMap<>(), LOGGER)); + } + + @Test + void originsAreNormalized() { + assertEquals( + OriginBoundAuthenticationSelector.originOf("https://repo.example.org/releases/"), + OriginBoundAuthenticationSelector.originOf("HTTPS://Repo.Example.Org:443/other/path")); + assertEquals( + OriginBoundAuthenticationSelector.originOf("http://repo.example.org:80/"), + OriginBoundAuthenticationSelector.originOf("http://repo.example.org/releases/")); + assertNull(OriginBoundAuthenticationSelector.originOf("file:/tmp/repo")); + assertNull(OriginBoundAuthenticationSelector.originOf(null)); + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionRangeResolver.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionRangeResolver.java index 5620c4c4268e..97a8853cd7b8 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionRangeResolver.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionRangeResolver.java @@ -18,7 +18,6 @@ */ package org.apache.maven.impl.resolver; -import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.util.ArrayList; diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSourceTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSourceTest.java index 28aa6386ecf6..c4cc9282e45b 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSourceTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSourceTest.java @@ -61,7 +61,7 @@ private static Model newModel(String groupId, String artifactId, String version) } @Test - void noRelocationReturnsNull() { + void noRelocationReturnsNull() throws Exception { Model model = Model.newBuilder().build(); Artifact result = source.relocatedTarget(null, newResult(), model); assertNull(result);