Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ jobs:
cache: false

- name: Install local Artifactory
if: runner.os != 'macOS'
uses: jfrog/.github/actions/install-local-artifactory@main
with:
RTLIC: ${{ secrets.RTLIC }}
Expand All @@ -48,6 +49,8 @@ jobs:
distribution: "zulu"

- name: Wait for Artifactory
if: runner.os != 'macOS'
shell: bash
run: |
for i in {1..30}; do
if curl -sf http://localhost:8081/artifactory/api/system/ping; then
Expand All @@ -61,6 +64,8 @@ jobs:
exit 1

- name: Cleanup test repositories
if: runner.os != 'macOS'
shell: bash
run: |
echo "Cleaning up any leftover test repositories..."
REPOS=$(curl -s -u admin:password "http://localhost:8081/artifactory/api/repositories" | grep -o 'rt-client-java-[a-z0-9-]*' || true)
Expand All @@ -77,4 +82,5 @@ jobs:
echo "Cleanup complete"

- name: Run tests
if: runner.os != 'macOS'
run: ./gradlew${{ matrix.gradlewSuffix }} clean test
Comment thread
fluxxBot marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ private HttpResponse handleArtifactoryRequest(ArtifactoryRequest artifactoryRequ
throw new IllegalArgumentException("Unsupported request method.");
}

httpRequest.setURI(URI.create(url + requestPath + queryPath));
httpRequest.setURI(buildUri(requestPath + queryPath));

if (contentType != null) {
httpRequest.setHeader("Content-type", contentType.getMimeType());
Expand Down Expand Up @@ -263,7 +263,7 @@ private HttpResponseException newHttpResponseException(HttpResponse httpResponse

protected Boolean head(String path) throws IOException {
HttpHead httpHead = new HttpHead();
httpHead.setURI(URI.create(url + path));
httpHead.setURI(buildUri(path));
HttpResponse httpResponse = execute(httpHead);
int status = httpResponse.getStatusLine().getStatusCode();
/** Any status code >= 100 and < 400 According to the {@link groovyx.net.http.Status} class*/
Expand All @@ -276,7 +276,7 @@ public <T> T get(String path, Class<? extends T> object, Class<T> interfaceObjec

public <T> T get(String path, Class<? extends T> object, Class<T> interfaceObject, Map<String, String> headers) throws IOException {
HttpGet httpGet = new HttpGet();
httpGet.setURI(URI.create(url + path));
httpGet.setURI(buildUri(path));

if (headers != null && !headers.isEmpty()) {
for (String key : headers.keySet()) {
Expand All @@ -302,7 +302,7 @@ public <T> T get(String path, Class<? extends T> object, Class<T> interfaceObjec

public <T> T post(String path, org.apache.http.entity.ContentType contentType, String content, Map<String, String> headers, Class<? extends T> object, Class<T> interfaceObject) throws IOException {
HttpPost httpPost = new HttpPost();
httpPost.setURI(URI.create(url + path));
httpPost.setURI(buildUri(path));

httpPost.setHeader("Content-type", contentType.getMimeType());
if (headers != null && !headers.isEmpty()) {
Expand All @@ -324,7 +324,7 @@ public <T> T post(String path, org.apache.http.entity.ContentType contentType, S
public <T> T patch(String path, org.apache.http.entity.ContentType contentType, String content, Map<String, String>
headers, Class<? extends T> object, Class<T> interfaceObject) throws IOException {
HttpPatch httpPatch = new HttpPatch();
httpPatch.setURI(URI.create(url + path));
httpPatch.setURI(buildUri(path));

httpPatch.setHeader("Content-type", contentType.getMimeType());
if (headers != null && !headers.isEmpty()) {
Expand All @@ -345,7 +345,7 @@ public <T> T patch(String path, org.apache.http.entity.ContentType contentType,

public <T> T put(String path, org.apache.http.entity.ContentType contentType, String content, Map<String, String> headers, InputStream inputStream, long length, Class<? extends T> object, Class<T> interfaceObject) throws IOException {
HttpPut httpPut = new HttpPut();
httpPut.setURI(URI.create(url + path));
httpPut.setURI(buildUri(path));

if (contentType != null) {
httpPut.setHeader("Content-type", contentType.getMimeType());
Expand Down Expand Up @@ -382,7 +382,7 @@ public <T> T put(String path, org.apache.http.entity.ContentType contentType, St
public String delete(String path) throws IOException {
HttpDelete httpDelete = new HttpDelete();

httpDelete.setURI(URI.create(url + path));
httpDelete.setURI(buildUri(path));
HttpResponse httpResponse = execute(httpDelete);
int status = httpResponse.getStatusLine().getStatusCode();
if (status != HttpStatus.SC_OK && status != HttpStatus.SC_NO_CONTENT && status != HttpStatus.SC_ACCEPTED) {
Expand All @@ -391,6 +391,15 @@ public String delete(String path) throws IOException {
return Util.responseToString(httpResponse);
}

private URI buildUri(String path) {
if (path == null || !path.startsWith("/")) {
throw new IllegalArgumentException(
"path must start with '/'; got: " + path +
" — a path beginning with '@' or without a leading slash can redirect the request to an unintended host (SSRF)");
}
return URI.create(url + path);
}

@Override
public HttpResponse execute(HttpUriRequest request) throws IOException {
HttpClientContext clientContext = HttpClientContext.create();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ class CargoPackageTypeRepositoryTests extends BaseRepositoryTests {
def settings = new CargoRepositorySettingsImpl()

settings.with {
// remote
cargoAnonymousAccess = rnd.nextBoolean()
cargoInternalIndex = rnd.nextBoolean()
gitRegistryUrl = "https://index.crates.io/"
// cargoInternalIndex and gitRegistryUrl were removed because Artifactory blocked
// legacy Git index support for all Cargo repository types (local, remote, federated)
// as of Artifactory 7.46.3. Sending cargoInternalIndex=true or a gitRegistryUrl now
// returns HTTP 400: "the legacy Git index is no longer supported. Use the default
// sparse HTTP index instead." All Cargo repositories must use the sparse HTTP index.
// See: https://jfrog.com/help/r/jfrog-artifactory-documentation/cargo-repositories
}

return settings
Expand All @@ -44,9 +47,6 @@ class CargoPackageTypeRepositoryTests extends BaseRepositoryTests {
resp.getRepositorySettings().with {
assertThat(packageType, CoreMatchers.is(expectedSettings.getPackageType()))
assertThat(repoLayout, CoreMatchers.is(expectedSettings.getRepoLayout()))

// remote
assertThat(cargoInternalIndex, CoreMatchers.is(expectedSettings.cargoInternalIndex))
assertThat(cargoAnonymousAccess, CoreMatchers.is(expectedSettings.cargoAnonymousAccess))
}
}
Expand All @@ -62,9 +62,6 @@ class CargoPackageTypeRepositoryTests extends BaseRepositoryTests {
resp.getRepositorySettings().with {
assertThat(packageType, CoreMatchers.is(expectedSettings.getPackageType()))
assertThat(repoLayout, CoreMatchers.is(expectedSettings.getRepoLayout()))

// remote
assertThat(cargoInternalIndex, CoreMatchers.is(expectedSettings.cargoInternalIndex))
assertThat(cargoAnonymousAccess, CoreMatchers.is(expectedSettings.cargoAnonymousAccess))
}
}
Expand All @@ -80,9 +77,6 @@ class CargoPackageTypeRepositoryTests extends BaseRepositoryTests {
resp.getRepositorySettings().with {
assertThat(packageType, CoreMatchers.is(expectedSettings.getPackageType()))
assertThat(repoLayout, CoreMatchers.is(expectedSettings.getRepoLayout()))

// remote
assertThat(cargoInternalIndex, CoreMatchers.is(expectedSettings.cargoInternalIndex))
assertThat(cargoAnonymousAccess, CoreMatchers.is(expectedSettings.cargoAnonymousAccess))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,44 @@ public void process(HttpResponse response, HttpContext context) {
assertEquals(responseInterceptions.intValue(), 0);
}

/**
* Regression test for SSRF via @-prefixed path (JSEC-22146).
* A path beginning with '@' reinterprets the configured origin as URL userinfo,
* redirecting the request to an attacker-controlled host.
* buildUri() must reject any path that does not start with '/'.
*/
@Test
public void atPrefixedPathMustBeRejected() throws IOException {
Artifactory artifactory = ArtifactoryClientBuilder.create()
.setUrl("http://localhost:18081")
.build();

String[] maliciousPaths = {
"@evil.example/stolen",
"@127.0.0.1:18082/stolen",
"evil.example/no-slash",
"",
};

for (String path : maliciousPaths) {
try {
artifactory.getInputStream(path);
throw new AssertionError("Expected IllegalArgumentException for path: " + path);
} catch (IllegalArgumentException e) {
// expected — path rejected before any network call
}
}

// Legitimate path must not be rejected (throws IOException because no server is running, not IllegalArgumentException)
try {
artifactory.getInputStream("/api/repositories");
} catch (IllegalArgumentException e) {
throw new AssertionError("Legitimate path '/api/repositories' must not be rejected: " + e.getMessage());
} catch (IOException ignored) {
// expected — no real server at localhost:18081
}
}

@Test(dataProvider = "httpMethods")
public void httpMethodsTest(ArtifactoryRequest.Method method, Class<?> expectedClass) {
ArtifactoryRequest artifactoryRequest = new ArtifactoryRequestImpl()
Expand Down
Loading