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
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,9 @@ public interface UserCredentials {
@JsonProperty("password")
String getPassword();
//this could potentially contain a TLS certificate as well, if the remote endpoint is a custom registry/repository

@Nullable
@Value.Parameter
@JsonProperty("token")
String getToken();
}
3 changes: 3 additions & 0 deletions multiapps-controller-api/src/main/resources/mtarest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -804,3 +804,6 @@ definitions:
password:
type: "string"
readOnly: true
token:
type: "string"
readOnly: true
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ public ResponseEntity<Void> startUploadFromUrl(String spaceGuid, String namespac
.decode(fileUrl.getFileUrl()));
String urlWithoutUserInfo = UriUtil.stripUserInfo(decodedUrl);
LOGGER.trace(Messages.RECEIVED_UPLOAD_FROM_URL_REQUEST, urlWithoutUserInfo);
filesApiServiceAuditLog.logStartUploadFromUrl(SecurityContextUtil.getUsername(), spaceGuid, decodedUrl);
filesApiServiceAuditLog.logStartUploadFromUrl(SecurityContextUtil.getUsername(), spaceGuid, urlWithoutUserInfo);

var existingJob = getExistingJob(spaceGuid, namespace, urlWithoutUserInfo);
if (existingJob == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@
import java.text.MessageFormat;
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;

import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.apache.commons.io.IOUtils;
Expand All @@ -27,6 +25,7 @@
import org.cloudfoundry.multiapps.controller.web.Constants;
import org.cloudfoundry.multiapps.controller.web.Messages;
import org.cloudfoundry.multiapps.controller.web.upload.UploadFromUrlContext;
import org.cloudfoundry.multiapps.controller.web.upload.client.auth.AuthenticatorResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
Expand All @@ -36,7 +35,6 @@
public class DeployFromUrlRemoteClient {

private static final Duration HTTP_CONNECT_TIMEOUT = Duration.ofMinutes(10);
private static final String USERNAME_PASSWORD_URL_FORMAT = "{0}:{1}";
private static final int ERROR_RESPONSE_BODY_MAX_LENGTH = 4 * 1024;
// Ten is value that is used by most HTTP clients (JDK HttpClient, curl, browsers)
private static final int MAX_REDIRECTS = 10;
Expand All @@ -49,11 +47,13 @@ public class DeployFromUrlRemoteClient {

private final ApplicationConfiguration applicationConfiguration;
private final AddressValidator addressValidator;
private final AuthenticatorResolver authenticatorResolver;

@Inject
public DeployFromUrlRemoteClient(ApplicationConfiguration applicationConfiguration, AddressValidator addressValidator) {
public DeployFromUrlRemoteClient(ApplicationConfiguration applicationConfiguration, AddressValidator addressValidator, AuthenticatorResolver authenticatorResolver) {
this.applicationConfiguration = applicationConfiguration;
this.addressValidator = addressValidator;
this.authenticatorResolver = authenticatorResolver;
}

public FileFromUrlData downloadFileFromUrl(UploadFromUrlContext uploadFromUrlContext) throws Exception {
Expand Down Expand Up @@ -175,22 +175,8 @@ private HttpRequest buildFetchFileRequest(String decodedUrl, UserCredentials use
.GET()
.timeout(Duration.ofMinutes(15));
var uri = URI.create(decodedUrl);
var userInfo = uri.getUserInfo();
if (userCredentials != null) {
builder.uri(uri);
String userCredentialsUrlFormat = MessageFormat.format(USERNAME_PASSWORD_URL_FORMAT, userCredentials.getUsername(),
userCredentials.getPassword());
String encodedAuth = Base64.getEncoder()
.encodeToString(userCredentialsUrlFormat.getBytes());
builder.header(HttpHeaders.AUTHORIZATION, "Basic " + encodedAuth);
} else if (userInfo != null) {
builder.uri(URI.create(decodedUrl.replace(uri.getRawUserInfo() + "@", "")));
String encodedAuth = Base64.getEncoder()
.encodeToString(userInfo.getBytes());
builder.header(HttpHeaders.AUTHORIZATION, "Basic " + encodedAuth);
} else {
builder.uri(uri);
}
authenticatorResolver.resolve(userCredentials, decodedUrl)
.applyTo(builder, uri);
return builder.build();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.cloudfoundry.multiapps.controller.web.upload.client.auth;

import java.net.URI;

import jakarta.inject.Named;

import org.cloudfoundry.multiapps.controller.api.model.UserCredentials;

/**
* Resolves the single {@link RequestAuthenticator} to apply to a deploy-from-URL fetch, from the
* request's credentials and the target URI. The precedence is stated here, in one place:
* <ol>
* <li>{@code token} on the credentials object &rarr; {@link BearerTokenAuthenticator}</li>
* <li>username/password on the credentials object &rarr; {@link BasicAuthenticator}</li>
* <li>user info embedded in the URL ({@code user:pass@host}) &rarr; {@link UrlUserInfoAuthenticator}</li>
* <li>otherwise &rarr; {@link NoAuthenticator}</li>
* </ol>
* Adding a new scheme means adding one branch here plus its authenticator class — the downloader and
* the job/orchestration code stay untouched.
*/
@Named
public class AuthenticatorResolver {

public RequestAuthenticator resolve(UserCredentials userCredentials, String decodedUrl) {
if (userCredentials != null && userCredentials.getToken() != null) {
return new BearerTokenAuthenticator(userCredentials.getToken());
}
if (userCredentials != null) {
return new BasicAuthenticator(userCredentials.getUsername(), userCredentials.getPassword());
}
if (URI.create(decodedUrl).getUserInfo() != null) {
return new UrlUserInfoAuthenticator(decodedUrl);
}
return new NoAuthenticator();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.cloudfoundry.multiapps.controller.web.upload.client.auth;

import java.net.URI;
import java.net.http.HttpRequest;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

import org.springframework.http.HttpHeaders;

/**
* HTTP Basic authenticator built from an explicit username and password. Encodes {@code username:password}
* as Base64 and sets the {@code Authorization: Basic ...} header. The URI is used as-is.
*/
public class BasicAuthenticator implements RequestAuthenticator {

private final String username;
private final String password;

public BasicAuthenticator(String username, String password) {
this.username = username;
this.password = password;
}

@Override
public void applyTo(HttpRequest.Builder builder, URI uri) {
builder.uri(uri);
String credentials = username + ":" + password;
String encodedAuth = Base64.getEncoder()
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
builder.header(HttpHeaders.AUTHORIZATION, "Basic " + encodedAuth);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package org.cloudfoundry.multiapps.controller.web.upload.client.auth;

import java.net.URI;
import java.net.http.HttpRequest;

import org.springframework.http.HttpHeaders;

/**
* Bearer-token authenticator. Sets {@code Authorization: Bearer <token>} with the caller-supplied token
* verbatim and targets the URI as-is. This is the scheme required by GitHub (fine-grained/classic PATs,
* GitHub App installation tokens, {@code GITHUB_TOKEN}), which all present identically on the wire.
*/
public class BearerTokenAuthenticator implements RequestAuthenticator {

private final String token;

public BearerTokenAuthenticator(String token) {
this.token = token;
}

@Override
public void applyTo(HttpRequest.Builder builder, URI uri) {
builder.uri(uri);
builder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.cloudfoundry.multiapps.controller.web.upload.client.auth;

import java.net.URI;
import java.net.http.HttpRequest;

/**
* No-op authenticator: targets the URI as-is and attaches no {@code Authorization} header. Used when
* the request carries no credentials.
*/
public class NoAuthenticator implements RequestAuthenticator {

@Override
public void applyTo(HttpRequest.Builder builder, URI uri) {
builder.uri(uri);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.cloudfoundry.multiapps.controller.web.upload.client.auth;

import java.net.URI;
import java.net.http.HttpRequest;

/**
* Strategy for attaching authentication to an outbound deploy-from-URL fetch request.
* <p>
* Each implementation owns a single authentication scheme: how it encodes its credential and which
* header (if any) it sets. The downloader stays agnostic of Base64 encoding and header names.
* <p>
* The resolver ({@link AuthenticatorResolver}) picks exactly one authenticator per request based on
* the supplied credentials and the URL. Implementations must not mutate anything other than the
* request builder (and the request URI, when they strip embedded user info from it).
*/
public interface RequestAuthenticator {

/**
* Applies this scheme to the request being built.
*
* @param builder the request builder to mutate (set the URI and, when applicable, the {@code Authorization} header)
* @param uri the target URI as parsed from the decoded URL
*/
void applyTo(HttpRequest.Builder builder, URI uri);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.cloudfoundry.multiapps.controller.web.upload.client.auth;

import java.net.URI;
import java.net.http.HttpRequest;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

import org.springframework.http.HttpHeaders;

/**
* HTTP Basic authenticator built from user info embedded in the URL itself ({@code https://user:pass@host/...}).
* Strips the raw user-info component from the URI (so it is not sent in the request line) and sets
* {@code Authorization: Basic ...} from the decoded user-info string.
* <p>
* This preserves the legacy behavior for callers that embed credentials in the URL; it is deliberately
* not extended for new schemes — new credentials should be passed as explicit fields instead.
*/
public class UrlUserInfoAuthenticator implements RequestAuthenticator {

private final String decodedUrl;

public UrlUserInfoAuthenticator(String decodedUrl) {
this.decodedUrl = decodedUrl;
}

@Override
public void applyTo(HttpRequest.Builder builder, URI uri) {
builder.uri(URI.create(decodedUrl.replace(uri.getRawUserInfo() + "@", "")));
String encodedAuth = Base64.getEncoder()
.encodeToString(uri.getUserInfo()
.getBytes(StandardCharsets.UTF_8));
builder.header(HttpHeaders.AUTHORIZATION, "Basic " + encodedAuth);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,35 @@
.executeUploadFromUrl(eq(SPACE_GUID), eq(NAMESPACE), any(String.class), any(String.class), eq(null));
}

@Test
void testStartDeployFromUrlAuditLogsStrippedUrl() {
AsyncUploadJobsQuery asyncUploadJobsQuery = mock(AsyncUploadJobsQuery.class, RETURNS_SELF);
when(uploadJobService.createQuery()).thenReturn(asyncUploadJobsQuery);
when(asyncUploadJobsQuery.list()).thenReturn(Collections.emptyList());

when(asyncUploadJobOrchestrator.executeUploadFromUrl(any(), any(), any(), any(), any())).thenReturn(
ImmutableAsyncUploadJobEntry.builder()
.id(UUID.randomUUID()
.toString())
.url("https://google.com")
.startedAt(JOB_TIMESTAMP)
.state(State.INITIAL)
.user("user1")
.spaceGuid(SPACE_GUID)
.instanceIndex(0)
.build());

String fileUrlWithUserInfo = Base64.getUrlEncoder()
.encodeToString(DECODED_URL_WITH_CREDENTIALS_IN_THE_URL.getBytes(StandardCharsets.UTF_8));
testedClass.startUploadFromUrl(SPACE_GUID, NAMESPACE, ImmutableFileUrl.builder()
.fileUrl(fileUrlWithUserInfo)
.build());

// The audit log must receive the userinfo-stripped URL, never the raw user:pass@ form.
verify(filesApiServiceAuditLog).logStartUploadFromUrl(eq("user1"), eq(SPACE_GUID), eq("https://google.com"));

Check warning on line 262 in multiapps-controller-web/src/test/java/org/cloudfoundry/multiapps/controller/web/api/impl/FilesApiServiceImplTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this and every subsequent useless "eq(...)" invocation; pass the values directly.

See more on https://sonarcloud.io/project/issues?id=cloudfoundry_multiapps-controller&issues=AaFdmkt9QMrO2HNxhTv6&open=AaFdmkt9QMrO2HNxhTv6&pullRequest=1928
verify(filesApiServiceAuditLog, never()).logStartUploadFromUrl(any(), any(), eq(DECODED_URL_WITH_CREDENTIALS_IN_THE_URL));
}

@Test
void testStartDeployFromUrlWhenExecutorRejectsJob() {
AsyncUploadJobsQuery asyncUploadJobsQuery = mock(AsyncUploadJobsQuery.class, RETURNS_SELF);
Expand Down
Loading
Loading