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
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ on:
branches:
- master

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Set up JDK 8
uses: actions/setup-java@v3
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0
with:
java-version: 8
distribution: 'temurin'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URI;
import java.util.List;
import com.browserstack.automate.Automate.BuildStatus;
import com.browserstack.automate.exception.AppAutomateException;
Expand Down Expand Up @@ -32,7 +34,8 @@ public class AppAutomateClient extends BrowserStackClient implements AppAutomate
* @param accessKey Browserstack accessKey
*/
public AppAutomateClient(String username, String accessKey) {
super(System.getProperty("browserstack.app-automate.api", BASE_URL), username, accessKey);
super(validateApiBaseUrl(System.getProperty("browserstack.app-automate.api", BASE_URL)),
username, accessKey);
}

/**
Expand Down Expand Up @@ -71,9 +74,21 @@ public AppUploadResponse uploadApp(String filePath)
throw new FileNotFoundException("File not found at " + filePath);
}

if (!filePath.endsWith(".apk") && !filePath.endsWith(".ipa")) {
// Canonicalize before validating extension so a symlink whose name ends in
// .apk/.ipa but whose target file does not is rejected. getCanonicalFile()
// resolves symlinks and ".." segments; the extension check then runs on the
// canonical name.
final File canonical;
try {
canonical = file.getCanonicalFile();
} catch (IOException e) {
throw new AppAutomateException("Unable to canonicalize file path: " + e.getMessage(), 0);
}
final String canonicalName = canonical.getName().toLowerCase();
if (!canonicalName.endsWith(".apk") && !canonicalName.endsWith(".ipa")) {
throw new InvalidFileExtensionException("File extension should be only .apk or .ipa.");
}
file = canonical;

MultipartContent content = new MultipartContent().setMediaType(
new HttpMediaType("multipart/form-data").setParameter("boundary", "__END_OF_PART__"));
Expand Down Expand Up @@ -282,5 +297,43 @@ public List<Session> getSessions(final String buildId, final BuildStatus status)
return getSessions(buildId, status, 0);
}

}

/**
* Validates the App Automate API base URL read from the JVM system property
* {@code browserstack.app-automate.api} (or the built-in default).
* <p>
* Only {@code https} URLs whose host (parsed via
* {@link java.net.URI#getHost()}) ends in {@code .browserstack.com} are
* accepted.
*
* @param url URL to validate. Must be a syntactically-valid absolute URL.
* @return the same URL, unchanged, when validation passes.
* @throws IllegalArgumentException if the URL is null, malformed, uses a
* non-https scheme, or has a host outside
* {@code *.browserstack.com}.
*/
private static String validateApiBaseUrl(String url) {
if (url == null) {
throw new IllegalArgumentException("App Automate API base URL is null");
}

final URI uri;
try {
uri = URI.create(url);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Malformed App Automate API base URL", e);
}

final String scheme = uri.getScheme();
if (!"https".equalsIgnoreCase(scheme)) {
throw new IllegalArgumentException("Insecure App Automate API base URL scheme: " + scheme);
}

final String host = uri.getHost();
if (host == null || !host.toLowerCase().endsWith(".browserstack.com")) {
throw new IllegalArgumentException("Untrusted App Automate API base URL host: " + host);
}

return url;
}
}
80 changes: 79 additions & 1 deletion src/main/java/com/browserstack/automate/AutomateClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import javax.annotation.Nonnull;
import java.util.ArrayList;
import java.util.Arrays;
import java.net.URI;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
Expand All @@ -35,7 +36,8 @@ public final class AutomateClient extends BrowserStackClient implements Automate
* @param accessKey Access Key for your BrowserStack Automate account.
*/
public AutomateClient(String username, String accessKey) {
super(System.getProperty("browserstack.automate.api", BASE_URL), username, accessKey);
super(validateApiBaseUrl(System.getProperty("browserstack.automate.api", BASE_URL)),
username, accessKey);
}

/**
Expand Down Expand Up @@ -458,6 +460,7 @@ public String getSessionLogs(final Session session) throws AutomateException {
}

try {
validateBrowserStackUrl(session.getLogUrl());
BrowserStackRequest request = newRequest(Method.GET, session.getLogUrl(), false);
request.getHttpRequest().getHeaders().setAccept("*/*");
return request.asString();
Expand Down Expand Up @@ -529,4 +532,79 @@ public String recycleKey() throws AutomateException {
setAccessKey(newAccessKey);
return newAccessKey;
}

/**
* Validates the API base URL read from the JVM system property
* {@code browserstack.automate.api} (or the built-in default).
* <p>
* Only {@code https} URLs whose host (parsed via
* {@link java.net.URI#getHost()}) ends in {@code .browserstack.com} are
* accepted.
*
* @param url URL to validate. Must be a syntactically-valid absolute URL.
* @return the same URL, unchanged, when validation passes.
* @throws IllegalArgumentException if the URL is null, malformed, uses a
* non-https scheme, or has a host outside
* {@code *.browserstack.com}.
*/
private static String validateApiBaseUrl(String url) {
if (url == null) {
throw new IllegalArgumentException("API base URL is null");
}

final URI uri;
try {
uri = URI.create(url);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Malformed API base URL", e);
}

final String scheme = uri.getScheme();
if (!"https".equalsIgnoreCase(scheme)) {
throw new IllegalArgumentException("Insecure API base URL scheme: " + scheme);
}

final String host = uri.getHost();
if (host == null || !host.toLowerCase().endsWith(".browserstack.com")) {
throw new IllegalArgumentException("Untrusted API base URL host: " + host);
}

return url;
}

/**
* Validates that a URL is a BrowserStack-issued logs URL before fetching.
* <p>
* Only {@code https} scheme is allowed, and the host (parsed via
* {@link java.net.URI#getHost()}, not a string suffix on the raw URL) must
* end with {@code .browserstack.com}. A URL whose host has
* {@code .browserstack.com} only as an internal substring — e.g.
* {@code https://automate.browserstack.com.example/} — is rejected.
*
* @param url URL to validate. Must be a syntactically-valid absolute URL.
* @throws AutomateException if the URL is malformed, uses a non-https scheme,
* or has a host outside {@code *.browserstack.com}.
*/
private static void validateBrowserStackUrl(String url) throws AutomateException {
if (url == null) {
throw new AutomateException("Logs URL is null", 400);
}

final URI uri;
try {
uri = URI.create(url);
} catch (IllegalArgumentException e) {
throw new AutomateException("Malformed logs URL", 400);
}

final String scheme = uri.getScheme();
if (!"https".equalsIgnoreCase(scheme)) {
throw new AutomateException("Insecure logs URL scheme: " + scheme, 400);
}

final String host = uri.getHost();
if (host == null || !host.toLowerCase().endsWith(".browserstack.com")) {
throw new AutomateException("Untrusted logs URL host: " + host, 400);
}
}
}
12 changes: 6 additions & 6 deletions src/main/java/com/browserstack/client/BrowserStackClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public Object parseAndClose(Reader reader, Type type) throws IOException {
throw new IOException("Unsupported operation");
}
};
private static HttpTransport HTTP_TRANSPORT = new ApacheHttpTransport();
private HttpTransport httpTransport = new ApacheHttpTransport();
protected final BrowserStackCache<String, Object> cacheMap;

private HttpRequestFactory requestFactory;
Expand Down Expand Up @@ -105,8 +105,8 @@ public BrowserStackClient(String baseUrl, String username, String accessKey) {
this.accessKey = accessKey.trim();
}

static HttpRequestFactory newRequestFactory() {
return HTTP_TRANSPORT.createRequestFactory(httpRequest -> httpRequest.setParser(OBJECT_PARSER));
HttpRequestFactory newRequestFactory() {
return httpTransport.createRequestFactory(httpRequest -> httpRequest.setParser(OBJECT_PARSER));
}

static HttpRequest newRequest(final HttpRequestFactory requestFactory, final Method method, final GenericUrl url) throws BrowserStackException {
Expand Down Expand Up @@ -174,7 +174,7 @@ public void setProxy(final String proxyHost, final int proxyPort, final String p
}

final HttpClient client = clientBuilder.build();
HTTP_TRANSPORT = new ApacheHttpTransport(client);
this.httpTransport = new ApacheHttpTransport(client);
this.requestFactory = newRequestFactory();
}

Expand All @@ -187,8 +187,8 @@ protected synchronized void setAccessKey(final String accessKey) {
}

private void checkAuthState() {
if (this.accessKey == null && this.username == null) {
throw new IllegalStateException("Missing API credentials");
if (this.accessKey == null || this.username == null) {
throw new IllegalStateException("Missing API credentials (username or access key is null)");
}
}

Expand Down
Loading
Loading