diff --git a/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java b/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java index 4a61ba7608d2..d8df37230f6a 100644 --- a/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java +++ b/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java @@ -75,7 +75,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/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java b/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java index bcdf438db2ec..bff814877881 100644 --- a/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java +++ b/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java @@ -28,10 +28,12 @@ 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.RepositoryUtils; import org.apache.maven.artifact.handler.manager.ArtifactHandlerManager; +import org.apache.maven.artifact.repository.ArtifactRepository; import org.apache.maven.bridge.MavenRepositorySystem; import org.apache.maven.eventspy.internal.EventSpyDispatcher; import org.apache.maven.execution.MavenExecutionRequest; @@ -52,6 +54,7 @@ import org.eclipse.aether.ConfigurationProperties; import org.eclipse.aether.DefaultRepositorySystemSession; import org.eclipse.aether.RepositorySystem; +import org.eclipse.aether.repository.AuthenticationSelector; import org.eclipse.aether.repository.LocalRepository; import org.eclipse.aether.repository.LocalRepositoryManager; import org.eclipse.aether.repository.RepositoryPolicy; @@ -109,6 +112,24 @@ public class DefaultRepositorySystemSessionFactory { */ private static final String MAVEN_REPO_LOCAL_RECORD_REVERSE_TREE = "maven.repo.local.recordReverseTree"; + /** + * User property selecting how server credentials configured in settings are scoped to repositories: + * + * + * @since 3.9.10 + */ + public static final String MAVEN_REPOSITORY_CREDENTIAL_SCOPE = "maven.repository.credentialScope"; + private static final String MAVEN_RESOLVER_TRANSPORT_KEY = "maven.resolver.transport"; private static final String MAVEN_RESOLVER_TRANSPORT_DEFAULT = "default"; @@ -208,6 +229,10 @@ public DefaultRepositorySystemSession newRepositorySession(MavenExecutionRequest } } + // 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( @@ -218,8 +243,17 @@ public DefaultRepositorySystemSession newRepositorySession(MavenExecutionRequest mirror.isBlocked(), mirror.getMirrorOf(), mirror.getMirrorOfLayouts()); + OriginBoundAuthenticationSelector.addOrigin(declaredRepositoryOrigins, mirror.getId(), mirror.getUrl()); } session.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 : decrypted.getProxies()) { @@ -324,7 +358,11 @@ public DefaultRepositorySystemSession newRepositorySession(MavenExecutionRequest configProps.put("aether.connector.perms.fileMode." + server.getId(), server.getFilePermissions()); configProps.put("aether.connector.perms.dirMode." + server.getId(), server.getDirectoryPermissions()); } - session.setAuthenticationSelector(authSelector); + String credentialScope = ConfigUtils.getString( + configProps, OriginBoundAuthenticationSelector.SCOPE_ORIGIN, MAVEN_REPOSITORY_CREDENTIAL_SCOPE); + AuthenticationSelector effectiveAuthSelector = OriginBoundAuthenticationSelector.wrap( + authSelector, credentialScope, declaredRepositoryOrigins, logger); + session.setAuthenticationSelector(effectiveAuthSelector); Object transport = configProps.getOrDefault(MAVEN_RESOLVER_TRANSPORT_KEY, MAVEN_RESOLVER_TRANSPORT_DEFAULT); if (MAVEN_RESOLVER_TRANSPORT_DEFAULT.equals(transport)) { diff --git a/maven-core/src/main/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelector.java b/maven-core/src/main/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelector.java new file mode 100644 index 000000000000..1898bf9666af --- /dev/null +++ b/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.codehaus.plexus.logging.Logger; +import org.eclipse.aether.repository.Authentication; +import org.eclipse.aether.repository.AuthenticationSelector; +import org.eclipse.aether.repository.RemoteRepository; + +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/maven-core/src/test/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelectorTest.java b/maven-core/src/test/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelectorTest.java new file mode 100644 index 000000000000..d62c1498456a --- /dev/null +++ b/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.codehaus.plexus.logging.Logger; +import org.codehaus.plexus.logging.console.ConsoleLogger; +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 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 = new ConsoleLogger(Logger.LEVEL_DEBUG, "test"); + + 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/maven-core/src/test/java/org/apache/maven/project/artifact/MavenMetadataSourceRelocationTest.java b/maven-core/src/test/java/org/apache/maven/project/artifact/MavenMetadataSourceRelocationTest.java index 33699037cdaa..344089676b1b 100644 --- a/maven-core/src/test/java/org/apache/maven/project/artifact/MavenMetadataSourceRelocationTest.java +++ b/maven-core/src/test/java/org/apache/maven/project/artifact/MavenMetadataSourceRelocationTest.java @@ -45,6 +45,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; @@ -142,8 +143,8 @@ void testRelocationWithInvalidArtifactIdIsRejected() 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