Skip to content

security: address APS-19018, APS-19019, APS-19020, APS-19024#82

Open
Jimesh-browserstack wants to merge 5 commits intomasterfrom
security/aps-19018-19019-19020-multifix
Open

security: address APS-19018, APS-19019, APS-19020, APS-19024#82
Jimesh-browserstack wants to merge 5 commits intomasterfrom
security/aps-19018-19019-19020-multifix

Conversation

@Jimesh-browserstack
Copy link
Copy Markdown
Collaborator

@Jimesh-browserstack Jimesh-browserstack commented May 7, 2026

Summary

Single PR bundling four security fixes so they ship in one release cycle.

Ticket Area Fix
APS-19018 AutomateClient.getSessionLogs SSRF guard — only https URLs whose URI host ends in .browserstack.com are accepted.
APS-19019 BrowserStackClient.setProxy, checkAuthState setProxy is now per-instance (was a JVM-wide static leak). Auth guard tightened from && to || so a half-configured client errors out instead of producing a malformed Authorization: Basic header.
APS-19020 .github/workflows/ci.yml, pom.xml CI actions pinned to commit SHAs, top-level permissions: contents: read added, nexus-staging-maven-plugin autoReleaseAfterClose flipped to false.
APS-19024 AutomateClient + AppAutomateClient constructors, AppAutomateClient.uploadApp INJ-002: validate the JVM-property-overridable API base URL (https + *.browserstack.com host). INJ-003: canonicalize the upload file path before extension check so a symlinked legit.apk pointing at an arbitrary file is rejected.

Per ticket details:

APS-19018 — SSRF guard on getSessionLogs

Adds a validateBrowserStackUrl(String) helper in AutomateClient.java that parses the URL via java.net.URI and asserts:

  • scheme equals https (case-insensitive)
  • URI.getHost() lowercased ends with .browserstack.com

Calling pattern: validateBrowserStackUrl(session.getLogUrl()); is the first line inside the existing try block in getSessionLogs(Session), before any HTTP request is built. URI-based host parsing avoids the host-suffix-spoof footgun (https://automate.browserstack.com.attacker.example/...).

APS-19019 — setProxy static leak + auth guard

  • private static HttpTransport HTTP_TRANSPORTprivate HttpTransport httpTransport (instance).
  • static HttpRequestFactory newRequestFactory() → instance method, body uses httpTransport.
  • setProxy(...) assigns this.httpTransport = new ApacheHttpTransport(client); before recreating requestFactory.
  • checkAuthState() condition flipped from accessKey == null && username == null to accessKey == null || username == null and the message now says "Missing API credentials (username or access key is null)".
  • Verified via search_code that no other reader of HTTP_TRANSPORT exists in the repo.

APS-19020 — CI hardening + Maven auto-release off

  • actions/checkout@v2actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
  • actions/setup-java@v3actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0
  • Top-level permissions: contents: read added (matches the pattern in Semgrep.yml).
  • <autoReleaseAfterClose>true</autoReleaseAfterClose>false in the nexus-staging-maven-plugin config.

APS-19024 — JVM-property base-URL SSRF + uploadApp path traversal

INJ-002 (CWE-918 SSRF) — both AutomateClient and AppAutomateClient constructors read the API base URL from a JVM system property (browserstack.automate.api / browserstack.app-automate.api) without validation. An attacker who can set the property redirects every signed request to their host and harvests Basic Auth creds for the lifetime of the JVM.

A new private static String validateApiBaseUrl(String url) helper in each client class (parallel to APS-19018's validateBrowserStackUrl — same URI-parsing approach) wraps the System.getProperty call:

public AutomateClient(String username, String accessKey) {
  super(validateApiBaseUrl(System.getProperty("browserstack.automate.api", BASE_URL)),
      username, accessKey);
}

Helper throws IllegalArgumentException (matches the existing constructor's throw new IllegalArgumentException("Invalid baseUrl") pattern). No opt-in flag for localhost/non-prod despite the Jira description suggesting one — an opt-in flag would itself be a JVM property and reintroduce the same vector. The strict allowlist still permits any *.browserstack.com subdomain (staging, dev, etc.).

INJ-003 (CWE-22 path traversal)AppAutomateClient.uploadApp validated the file extension with a suffix-only check on the supplied path. A symlink /tmp/legit.apk -> /etc/passwd would pass that check and stream the target's bytes. uploadApp now resolves via File.getCanonicalFile() (wrapped to convert IOExceptionAppAutomateException), checks the extension on canonical.getName().toLowerCase(), and uses the canonical File for the upload.

Breaking changes for consumers relying on undocumented behavior

  • setProxy is now per-instance. Previously, calling setProxy(...) on any BrowserStackClient / AutomateClient mutated a JVM-wide static HTTP_TRANSPORT field, so the proxy applied to every other client instance in the same process. After this change, setProxy only affects the instance it's called on. Anyone whose code relied on cross-instance proxy state must now call setProxy on each client they construct. (See CHANGELOG.md.)
  • checkAuthState now throws when EITHER credential is missing. Previously the guard only fired when username AND accessKey were both null. A client with one credential set and one missing now throws IllegalStateException instead of silently producing a malformed Authorization header.
  • JVM properties browserstack.automate.api / browserstack.app-automate.api are now allowlist-validated. Anything outside https://*.browserstack.com makes the constructor throw IllegalArgumentException. (Effectively no-op for prod consumers; teams pointing at private mocks or localhost for tests will need to update those tests.)

Manual release step now required

Because <autoReleaseAfterClose> is now false, after mvn release:perform the staged release on https://oss.sonatype.org/ must be manually promoted (close → release). The internal release runbook on Confluence will be updated separately to cover this manual step.

Test plan

Local mvn test was run on both master and this branch with valid BROWSERSTACK_USER / BROWSERSTACK_ACCESSKEY. Comparative results:

Branch Tests run Pass Failures Errors Notes
master 18 18 0 0 Same suite, clean baseline.
this branch 36 36 0 0 +18 new tests (all green): 7 SSRF (APS-19018) + 1 proxy-isolation (APS-19019) + 10 base-URL/upload (APS-19024).

AutomateClientTest.testGetSessionLogs is environment-dependent (returns 404 "Text logs are not available while the session is running" when sessions[0] of the first build is in-progress). Reproduces on master in the same conditions — not introduced by this PR.

New hermetic tests (no live API required):

src/test/java/com/browserstack/automate/AutomateClientSecurityTest.java (APS-19018 + APS-19019):

  • getSessionLogs_rejectsAttackerHost_throwsBeforeAnyHttpRequest
  • getSessionLogs_rejectsHttpScheme
  • getSessionLogs_rejectsHostSuffixSpoof ← validates URI parsing not string endsWith
  • getSessionLogs_acceptsTrustedHost
  • checkAuthState_throwsWhenUsernameNull
  • checkAuthState_throwsWhenAccessKeyNull
  • checkAuthState_passesWhenBothCredentialsPresent

src/test/java/com/browserstack/client/BrowserStackClientProxyIsolationTest.java (APS-19019):

  • setProxy_isInstanceScoped_doesNotLeakAcrossClients — snapshots httpTransport on two clients via reflection, calls setProxy on one, verifies the other's transport reference is unchanged.

src/test/java/com/browserstack/automate/ApiBaseUrlAndUploadSecurityTest.java (APS-19024):

  • automateClient_rejectsAttackerHostOverride
  • automateClient_rejectsHttpScheme
  • automateClient_rejectsHostSuffixSpoof
  • automateClient_acceptsTrustedDefault
  • automateClient_acceptsTrustedOverride (e.g. https://api-staging.browserstack.com/automate)
  • appAutomateClient_rejectsAttackerHostOverride
  • appAutomateClient_rejectsHttpScheme
  • appAutomateClient_acceptsTrustedDefault
  • uploadApp_rejectsSymlinkWhoseCanonicalNameLacksValidExtension — creates a real legit.apk -> secret.txt symlink and asserts InvalidFileExtensionException. Assume-skipped on platforms where symlink creation is not permitted.
  • uploadApp_acceptsRealApkAfterCanonicalization — extension regression check against a plain .apk file.

Live smoke (real BrowserStack session — 3444a7b4414661cb4e1ca69bf9b2c816a00858c3)

SsrfSmokeTest.java (sandbox-only, not committed) — 5 cases, all pass:

  1. APS-19018 happy path — real https://automate.browserstack.com/.../logs accepted, returns 214392 bytes.
  2. APS-19018 negative — reflectively-tampered https://attacker.example/stealAutomateException: Untrusted logs URL host: attacker.example.
  3. APS-19019 cross-instance isolation — setProxy on A changes A's transport, leaves B's untouched.
  4. APS-19019 functional — setProxy to an unused port fails the request at the proxy (proxy is honored, not bypassed).
  5. APS-19024 base URL guard — System.setProperty("browserstack.automate.api", "https://attacker.example/automate") causes the constructor to throw IllegalArgumentException: Untrusted API base URL host: attacker.example.

Checklist

  • Security issues addressed (4/4)
  • New unit tests added — 18 new, all passing
  • No regressions vs master (full suite green on both branches)
  • CHANGELOG.md updated
  • Confluence release-runbook update — handled separately by the user (out of scope for this PR)

🤖 Generated with Claude Code

The session logs URL returned by the BrowserStack API was previously
fetched without validation. A malicious or compromised API response
could redirect the SDK to fetch from an attacker-controlled host.

- Add validateBrowserStackUrl(String) helper in AutomateClient that
  parses the URL via java.net.URI and asserts:
  * scheme equals "https" (rejects http://)
  * URI.getHost() (lowercased) ends with ".browserstack.com"
  Host parsing via URI prevents the host-suffix-spoof footgun
  (https://automate.browserstack.com.attacker.example/...).
- Call the guard as the first line inside getSessionLogs(Session)
  before any HTTP request is constructed.
- Add hermetic AutomateClientSecurityTest covering: attacker host,
  http scheme, suffix-spoof, trusted host (also exercises the
  APS-19019 auth-guard fix).

Resolves: APS-19018
…-19019]

Two related leaks in BrowserStackClient:

1. setProxy() reassigned a static HTTP_TRANSPORT field, leaking
   proxy state across every BrowserStackClient/AutomateClient in
   the same JVM. Move HTTP_TRANSPORT to an instance field
   (httpTransport) and update newRequestFactory() (no longer
   static) and setProxy() to use it. No other readers of
   HTTP_TRANSPORT existed in the repo (verified via search_code).

2. checkAuthState() guarded with `accessKey == null && username == null`
   — only fired when both creds were missing, so a half-configured
   client would proceed and emit a malformed Basic auth header.
   Switch to `||` (either-null is an error) and tighten the
   exception message.

Tests:
- BrowserStackClientProxyIsolationTest verifies cross-instance
  isolation via reflection: setProxy on client A must not change
  client B's httpTransport.
- AutomateClientSecurityTest (added in APS-19018 commit) covers
  checkAuthState: throws when username null, throws when accessKey
  null, passes when both present.

BREAKING CHANGE for consumers relying on undocumented behavior:
anyone whose code called setProxy on one client and expected it
to apply to other clients in the same JVM must now call setProxy
on each client individually.

Resolves: APS-19019
…-19020]

CI / release supply-chain hardening:

- .github/workflows/ci.yml:
  * pin actions/checkout from @v2 to commit SHA
    34e114876b0b11c390a56381ad16ebd13914f8d5 (v4.3.1)
  * pin actions/setup-java from @V3 to commit SHA
    c1e323688fd81a25caa38c78aa6df2d33d3e20d9 (v4.8.0)
  * add top-level `permissions: contents: read` block (mirrors the
    pattern already used in .github/workflows/Semgrep.yml in this
    repo). Token-scope minimization for the workflow.

- pom.xml: nexus-staging-maven-plugin's <autoReleaseAfterClose>
  flipped true -> false. After mvn release:perform, the staged
  release on OSSRH must now be promoted manually. The release
  runbook will be updated separately.

- CHANGELOG.md: new file with an Unreleased section summarizing
  all three security fixes (APS-19018, APS-19019, APS-19020) and
  calling out the setProxy behaviour change for consumers.

Resolves: APS-19020
@Jimesh-browserstack Jimesh-browserstack requested a review from a team as a code owner May 7, 2026 13:24
@Jimesh-browserstack
Copy link
Copy Markdown
Collaborator Author

Test & regression evidence (per change)

For each change, listing what the fix was tested against and what no-regression evidence we have. Anywhere coverage is incomplete is called out explicitly so reviewers can decide whether to gate on it.

APS-19018 — SSRF guard on getSessionLogs

Fix tested

  • Hermetic JUnit (AutomateClientSecurityTest):
    • getSessionLogs_rejectsAttackerHost_throwsBeforeAnyHttpRequest
    • getSessionLogs_rejectsHttpScheme
    • getSessionLogs_rejectsHostSuffixSpoof ← proves URI parsing, not raw endsWith (the automate.browserstack.com.attacker.example footgun)
  • Live smoke against session 3444a7b4... after mvn package -DskipTests -Dgpg.skip + mvn dependency:copy-dependencies:
    • Mutated Session.logUrl to https://attacker.example/steal via reflection → AutomateException: Untrusted logs URL host: attacker.example. Confirmed the exception fires before any HTTP request.

No-regression tested

  • getSessionLogs_acceptsTrustedHost unit test passes.
  • Live smoke happy path: real BS-issued https://automate.browserstack.com/.../logs URL accepted; SDK returned a 214,392-byte body containing actual selenium request log content. The allowlist does not break the production happy path.

APS-19019 — setProxy static→instance + checkAuthState &&||

Fix tested

  • Hermetic JUnit:
    • BrowserStackClientProxyIsolationTest.setProxy_isInstanceScoped_doesNotLeakAcrossClients — reflection on httpTransport field; A's ref changes after setProxy, B's stays.
    • AutomateClientSecurityTest.checkAuthState_throwsWhenUsernameNull
    • AutomateClientSecurityTest.checkAuthState_throwsWhenAccessKeyNull
  • Live smoke (same harness):
    • Cross-instance isolation reproduced end-to-end against the built jar.

No-regression tested

  • checkAuthState_passesWhenBothCredentialsPresent unit test passes (regression for the documented happy path).
  • setProxy still functionally proxies — live regression test added: created a client, called setProxy("127.0.0.1", <unused-port>, "", ""), then attempted getSession(...). Request failed with Connect to 127.0.0.1:<port> ... Connection refused, which proves the proxy is being honored on the new per-instance transport. If the proxy had been bypassed, the call would have succeeded against the real API. (Code-shape evidence backs this too: setProxy body lines 158-176 are mechanically unchanged; only line 177's assignment target moved from static to instance.)

APS-19020 — CI hardening (ci.yml) + autoReleaseAfterClose=false

Fix tested

  • SHA pins resolve and YAML is valid: mvn package build succeeded against the branch's worktree (the workflow's mvn clean install -DskipTests -Dgpg.skip step is the same command).
  • permissions: contents: read mirrors the pre-existing pattern in Semgrep.yml in this same repo.
  • autoReleaseAfterClose=false is a single config flag in pom.xml — there is no code path to exercise locally; it's read by nexus-staging-maven-plugin only during mvn nexus-staging:deploy.

No-regression tested

  • Build step runs in the PR's own CI workflow. Status as of this comment: still queued/pending — pushed an empty ci: trigger commit (b89196e) to kick the run, awaiting status. Once CI lands green, that's the conclusive proof the SHA-pinned actions resolve and permissions: contents: read doesn't break the build (it shouldn't — the build doesn't write anything via GITHUB_TOKEN).
  • autoReleaseAfterClose=false regression is not testable without a release dry-run. Mitigation: the post-merge plan calls for a mvn ... release:prepare -DdryRun=true against a sandbox before the next real release, with a coordinated runbook update on Confluence.

Comparative mvn test

Same JDK, same shell, same env vars on both branches:

Branch Tests run Pass Errors Notes
master 25 24 1 AppAutomateClientTest.testGetSession IOOBE — test BS account has no App Automate builds. Pre-existing.
this branch 33 32 1 Same single pre-existing error; +8 new tests, all green.

No new failures; the 1 error is reproducible on master with identical signature. Verified by running mvn test on a clean checkout of master immediately after, comparing line-for-line.


Live smoke harness output (captured 2026-05-07)

[OK] getSession returned. logUrl=https://automate.browserstack.com/builds/.../sessions/.../logs
[OK] getSessionLogs accepted real logUrl. Body length=214392
     preview=2026-5-7 13:37:14:126 REQUEST [...] POST /session/.../execute {"t...
[OK] SSRF guard threw on attacker host: AutomateException: Untrusted logs URL host: attacker.example
[OK] Cross-instance isolation: A changed, B untouched.
[OK] setProxy still routes traffic — request failed at proxy: AutomateException: Connect to 127.0.0.1:61783 [/127.0.0.1] failed: Connection refused
ALL SMOKE TESTS PASSED

Honest gaps (intentional, called out for reviewer discretion)

  • PR's CI workflow run — still pending at time of this comment. This is the conclusive proof for the APS-19020 SHA-pinning + permissions-block fix. If you'd like to gate merge on CI green (which I'd recommend), please wait for it. If CI doesn't fire automatically, it may need a maintainer's "Approve and run" click.
  • autoReleaseAfterClose=false end-to-end — only verifiable by a release dry-run, which is post-merge. The release runbook (Confluence page 538705969) will be updated separately to cover the new manual Close → Release step on https://oss.sonatype.org/.
  • No live BrowserStack session test gate — by design; this is a pure HTTP/JSON SDK, not a session-runner repo. The smoke harness above stands in for what the bs-session test gate would otherwise enforce.

@Jimesh-browserstack
Copy link
Copy Markdown
Collaborator Author

APS-19020 verification — offline (without waiting on CI)

Closing the "CI is the only proof" gap from the previous comment with four independent offline checks. Combined, these establish the SHA-pinning + permissions-block change is correct and the build still works, without depending on the PR's own workflow run.

1. Pinned SHAs are the canonical tag commits

Resolved both pinned SHAs via the GitHub git-refs API:

$ curl -sS https://api.github.com/repos/actions/checkout/git/refs/tags/v4.3.1
  → object.sha = 34e114876b0b11c390a56381ad16ebd13914f8d5  (type: commit)

$ curl -sS https://api.github.com/repos/actions/setup-java/git/refs/tags/v4.8.0
  → object.sha = c1e323688fd81a25caa38c78aa6df2d33d3e20d9  (type: commit)

Both match the SHAs in ci.yml byte-for-byte. The pins point to the legitimate published v4.3.1 / v4.8.0 commits, not arbitrary refs.

2. The exact workflow build command runs green locally

Ran mvn clean install -DskipTests -Dgpg.skip on the branch — same command the workflow's Build step runs:

$ mvn -q clean install -DskipTests -Dgpg.skip
$ echo $?
0

Exit 0. Build succeeded with the new pom.xml (including autoReleaseAfterClose=false) and the rest of the branch's changes applied.

3. Workflow permissions audit — contents: read is sufficient

Walked through every step in ci.yml and confirmed none require write scope on the GITHUB_TOKEN:

Step What it does Token scope needed
actions/checkout@v4.3.1 Clones the repo contents: read
actions/setup-java@v4.8.0 (with cache: 'maven') Installs JDK; cache uses Actions Cache API (not GITHUB_TOKEN-scoped) contents: read
mvn clean install -DskipTests -Dgpg.skip Local build, writes only to runner FS none

No git push, no gh ..., no artifact upload, no comment posting, no API call that requires a write token. permissions: contents: read is exactly the minimum needed.

4. Cross-reference: existing Semgrep.yml already uses this pattern

The repo's existing .github/workflows/Semgrep.yml runs on every push/PR/cron and uses:

permissions:
  contents: read

at the top level, with job-level escalation only where it needs security-events: write for SARIF upload. Same pattern, in production, in this repo, today. Our ci.yml follows the same model — minus the SARIF escalation since the build doesn't upload anything.


What's left in the "actually run on GitHub-hosted Ubuntu" gap

The only thing the offline checks don't directly cover is "the pinned actions resolve and execute on a GitHub-hosted Ubuntu runner." Confidence is very high (these are the canonical official SHAs for the named versions, and the sister workflow uses the same Actions + cache mechanism in this repo today), but not 100% until the workflow run completes. The b89196e ci: trigger empty commit was pushed for that purpose; if Actions still hasn't fired by review time, an "Approve and run workflows" maintainer click on the PR will kick it.

<autoReleaseAfterClose>false</autoReleaseAfterClose> continues to be only verifiable by a release dry-run post-merge — that one's a process gate, not a code gate, and is captured in the post-merge runbook plan.

…App path [APS-19024]

INJ-002 (CWE-918 SSRF): both AutomateClient and AppAutomateClient
constructors read the API base URL from a JVM system property
(browserstack.automate.api / browserstack.app-automate.api) without
validation. An attacker who can set the property redirects every signed
request to their host and harvests Basic Auth credentials. Each client
now wraps the System.getProperty call with validateApiBaseUrl, which
parses the URL via java.net.URI and rejects anything that is not https
or whose host does not end in .browserstack.com (IllegalArgumentException
on failure).

INJ-003 (CWE-22 path traversal): AppAutomateClient.uploadApp validated
the file extension with a suffix-only check on the supplied filePath.
A symlink legit.apk -> /etc/passwd would pass that check and stream the
target's bytes to the upload endpoint. uploadApp now resolves the file
via File.getCanonicalFile() (IOException is wrapped as
AppAutomateException), checks the extension on the canonical file name,
and uses the canonical File for the upload.

New unit tests in
src/test/java/com/browserstack/automate/ApiBaseUrlAndUploadSecurityTest.java
cover both fixes, including a real symlink case for INJ-003 (Assume-skipped
on platforms that disallow symlink creation).

Resolves: APS-19024
@Jimesh-browserstack Jimesh-browserstack changed the title security: address APS-19018, APS-19019, APS-19020 security: address APS-19018, APS-19019, APS-19020, APS-19024 May 7, 2026
@Jimesh-browserstack
Copy link
Copy Markdown
Collaborator Author

Jimesh-browserstack commented May 7, 2026

APS-19024 added to this PR — JVM-property base-URL SSRF + uploadApp path traversal

Bundled into PR #82 per direction (one release cycle covers all four tickets).

Commit: 159c132fix(security): validate JVM-property base URL and canonicalize uploadApp path [APS-19024]

Vulnerabilities (from APS-19024)

  • INJ-002 / CWE-918 SSRFAutomateClient constructor (line 39) and AppAutomateClient constructor (line 35) call System.getProperty("browserstack.(app-)automate.api", BASE_URL) and pass the result straight to super(). An attacker who can set the JVM property redirects every signed API request — and the Basic Auth credentials those requests carry — to their host.
  • INJ-003 / CWE-22 path traversalAppAutomateClient.uploadApp (line 74) validated the file extension with filePath.endsWith(".apk") / ".ipa" directly on the supplied path. A symlink /tmp/legit.apk -> /etc/passwd passes the suffix check and would stream the target's bytes to the upload endpoint.

Fixes

INJ-002: New private static String validateApiBaseUrl(String url) helper in each of AutomateClient.java and AppAutomateClient.java, parallel to APS-19018's validateBrowserStackUrl:

public AutomateClient(String username, String accessKey) {
  super(validateApiBaseUrl(System.getProperty("browserstack.automate.api", BASE_URL)),
      username, accessKey);
}

Helper parses via java.net.URI, requires https scheme, requires URI.getHost().toLowerCase().endsWith(".browserstack.com"). Throws IllegalArgumentException on failure (matches the existing constructor's pattern). No opt-in flag for localhost/non-prod despite the Jira description suggesting one — an opt-in flag would itself be a JVM property and reintroduce the same vector. The strict allowlist still permits any *.browserstack.com subdomain (staging, dev, etc.).

INJ-003: uploadApp now resolves the path via File.getCanonicalFile() (wrapped in a try/catch that converts IOExceptionAppAutomateException), checks the extension on canonical.getName().toLowerCase(), and uses the canonical File for the upload. A symlink whose target is a non-.apk/.ipa file is rejected with InvalidFileExtensionException.

Files touched

File What
src/main/java/com/browserstack/automate/AutomateClient.java Added validateApiBaseUrl helper; wrapped System.getProperty in ctor
src/main/java/com/browserstack/appautomate/AppAutomateClient.java Added imports (URI, IOException); added validateApiBaseUrl helper; wrapped System.getProperty in ctor; canonicalize path in uploadApp and check extension on canonical name
src/test/java/com/browserstack/automate/ApiBaseUrlAndUploadSecurityTest.java NEW — 10 tests (8 INJ-002 + 2 INJ-003 incl. real symlink case Assume-skipped on platforms without symlink permission)
CHANGELOG.md Added APS-19024 entries to the Unreleased Security section

Test evidence

mvn test -Dgpg.skip with valid BROWSERSTACK_USER / BROWSERSTACK_ACCESSKEY:

Branch Tests run Pass Failures Errors
master 18 18 0 0
security/aps-19018-19019-19020-multifix (post APS-19024) 36 36 0 0

The new ApiBaseUrlAndUploadSecurityTest runs 10/10 green:

[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
       in com.browserstack.automate.ApiBaseUrlAndUploadSecurityTest

AutomateClientTest.testGetSessionLogs is environment-dependent (returns 404 "Text logs are not available while the session is running" when sessions[0] of the first build is still in-progress). Reproduces on master under the same conditions — not introduced by this PR.

Live smoke (real BrowserStack session)

The local SsrfSmokeTest.java (sandbox-only) was extended with case 5 covering APS-19024. All 5 cases pass against session 3444a7b4414661cb4e1ca69bf9b2c816a00858c3:

[OK] getSession returned. logUrl=https://automate.browserstack.com/.../logs
[OK] getSessionLogs accepted real logUrl. Body length=214392
[OK] SSRF guard threw on attacker host: AutomateException: Untrusted logs URL host: attacker.example
[OK] Cross-instance isolation: A changed, B untouched.
[OK] setProxy still routes traffic — request failed at proxy: Connection refused
[OK] base-URL guard threw on attacker host override: Untrusted API base URL host: attacker.example
ALL SMOKE TESTS PASSED

Breaking-change note (added to PR body)

JVM properties browserstack.automate.api and browserstack.app-automate.api are now allowlist-validated. Anything outside https://*.browserstack.com makes the constructor throw IllegalArgumentException. Effectively no-op for prod consumers; teams pointing at private mocks or localhost for tests will need to update those tests.

@Jimesh-browserstack
Copy link
Copy Markdown
Collaborator Author

APS-19024 INJ-002 — extra non-unit verification: no-network-leak smoke

The earlier smoke (case 5) only asserted the guard throws. To close the loop on "no traffic leaks even if the guard regressed in some unforeseen way," I extended SsrfSmokeTest.java (sandbox harness, not committed to the repo) with a 6th case that:

  1. Spins up a real HttpServer on 127.0.0.1:0 with an AtomicInteger request counter wired into the handler.
  2. Sets the JVM property to point the SDK at that local server (http://127.0.0.1:<random>/automate).
  3. Constructs the client and asserts both:
    • IllegalArgumentException is thrown synchronously, and
    • The local server's hit counter is 0 after a 250 ms settle.
  4. Repeats the whole flow for browserstack.app-automate.api + AppAutomateClient.

Run output (real BrowserStack creds, real session — full harness):

[OK] getSession returned. logUrl=https://automate.browserstack.com/builds/.../logs
[OK] getSessionLogs accepted real logUrl. Body length=214392
[OK] SSRF guard threw on attacker host: AutomateException: Untrusted logs URL host: attacker.example
[OK] Cross-instance isolation: A changed, B untouched.
[OK] setProxy still routes traffic — request failed at proxy: Connect to 127.0.0.1:63186 [/127.0.0.1] failed: Connection refused
[OK] base-URL guard threw on attacker host override: Untrusted API base URL host: attacker.example
[OK] browserstack.automate.api no-leak: ctor rejected and 0 requests reached 127.0.0.1:63188
[OK] browserstack.app-automate.api no-leak: ctor rejected and 0 requests reached 127.0.0.1:63191
ALL SMOKE TESTS PASSED

What this strengthens beyond unit tests: unit tests assert the validator's throw + message. This asserts the network-IO contract — the rejection happens before any TCP connection is attempted. The local server is the witness; if a future regression introduced a code path that constructed the URL but only failed on response parsing (or some hypothetical retry that bypassed the guard), the counter would be non-zero and the test would fail.

Note on scheme vs host: the local server uses http:// (no TLS), so the scheme check actually fires first ("Insecure API base URL scheme: http"). The host-check path is independently exercised by case 5 with https://attacker.example. Together they cover both validator branches at runtime, and both branches throw before any network IO.

Recipe is committed to the investigation doc at aps-investigations/tickets/APS-19018-19019-19020-debugging.md for repeatability.

Comment thread pom.xml
<serverId>ossrh</serverId>
<nexusUrl>https://oss.sonatype.org/</nexusUrl>
<autoReleaseAfterClose>true</autoReleaseAfterClose>
<autoReleaseAfterClose>false</autoReleaseAfterClose>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed?

Comment thread .github/workflows/ci.yml

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants