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
6 changes: 6 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## v1.12.2

### Jul 06, 2026

- Snyk fix

## v1.12.1

### Jun 29, 2026
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<artifactId>cms</artifactId>
<packaging>jar</packaging>
<name>contentstack-management-java</name>
<version>1.12.1</version>
<version>1.12.2</version>
<description>Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an
API-first approach
</description>
Expand Down
14 changes: 11 additions & 3 deletions src/main/java/com/contentstack/cms/Contentstack.java
Original file line number Diff line number Diff line change
Expand Up @@ -965,8 +965,7 @@ public Builder setOAuth(String appId, String clientId, String redirectUri, Strin
OAuthConfig.OAuthConfigBuilder builder = OAuthConfig.builder()
.appId(appId)
.clientId(clientId)
.redirectUri(redirectUri)
.host(host);
.redirectUri(redirectUri);

// Only set clientSecret if provided (otherwise PKCE flow will be used)
if (clientSecret != null && !clientSecret.trim().isEmpty()) {
Expand All @@ -978,7 +977,16 @@ public Builder setOAuth(String appId, String clientId, String redirectUri, Strin
builder.tokenCallback(this.tokenCallback);
}

this.oauthConfig = builder.build();
// Validate the required fields first so their errors surface ahead of
// host validation, matching the previous configuration-error behaviour.
builder.build().validate();

// Validate the host before it is stored on the OAuth config, so the same
// SSRF protection that guards the main API host also applies here
// regardless of how the host was sourced (setHost, region resolution,
// or a value passed directly by the caller).
String validatedHost = host != null ? validateHostname(host) : null;
this.oauthConfig = builder.host(validatedHost).build();
return this;
}

Expand Down
50 changes: 48 additions & 2 deletions src/main/java/com/contentstack/cms/core/Endpoint.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ public class Endpoint {
private static final String REGIONS_URL = "https://artifacts.contentstack.com/regions.json";
private static final String REGIONS_RESOURCE = "regions.json";

/**
* Domain suffixes that endpoint URLs are permitted to target. The regions
* registry is fetched from a remote resource, so every URL derived from it
* is validated against this allowlist before it can be used as a request
* target. This prevents Server-Side Request Forgery (SSRF) should the
* registry ever be tampered with or point at an unexpected host.
*/
private static final String[] TRUSTED_DOMAIN_SUFFIXES = {
".contentstack.com",
".contentstack.io"
};

private static volatile JsonArray regionsData = null;

private Endpoint() {}
Expand Down Expand Up @@ -82,7 +94,7 @@ public static String getContentstackEndpoint(String region, String service, bool
throw new IllegalArgumentException(
"Service \"" + service + "\" not found for region \"" + regionRow.get("id").getAsString() + "\"");
}
String url = endpoints.get(service).getAsString();
String url = validateTrustedEndpoint(endpoints.get(service).getAsString());
return omitHttps ? stripHttps(url) : url;
}

Expand Down Expand Up @@ -113,7 +125,7 @@ public static Map<String, String> getContentstackEndpoints(String region, boolea
Map<String, String> result = new LinkedHashMap<>();
if (endpoints != null) {
for (Map.Entry<String, JsonElement> entry : endpoints.entrySet()) {
String url = entry.getValue().getAsString();
String url = validateTrustedEndpoint(entry.getValue().getAsString());
result.put(entry.getKey(), omitHttps ? stripHttps(url) : url);
}
}
Expand Down Expand Up @@ -227,6 +239,40 @@ public static synchronized int refresh() {
}
}

/**
* Validates that an endpoint URL sourced from the regions registry is a
* well-formed HTTPS URL whose host belongs to a trusted Contentstack domain.
*
* <p>The regions registry is loaded from a remote resource; constraining the
* host to an allowlist here neutralizes any Server-Side Request Forgery (SSRF)
* risk before the value can flow into an outbound request.
*
* @param url the endpoint URL read from the registry
* @return the URL, unchanged, when it targets a trusted host
* @throws IllegalArgumentException if the URL is malformed, not HTTPS, or
* targets an untrusted host
*/
private static String validateTrustedEndpoint(String url) {
URL parsed;
try {
parsed = new URL(url);
} catch (java.net.MalformedURLException e) {
throw new IllegalArgumentException("Malformed endpoint URL in regions registry: " + url, e);
}
if (!"https".equalsIgnoreCase(parsed.getProtocol())) {
throw new IllegalArgumentException("Endpoint URL must use HTTPS: " + url);
}
String host = parsed.getHost() == null ? "" : parsed.getHost().toLowerCase();
for (String suffix : TRUSTED_DOMAIN_SUFFIXES) {
// suffix begins with '.', so "contentstack.com" itself is matched via
// the leading-dot form only for subdomains; also allow the bare apex.
if (host.endsWith(suffix) || host.equals(suffix.substring(1))) {
return url;
}
}
throw new IllegalArgumentException("Endpoint URL targets an untrusted host: " + url);
}

private static String stripHttps(String url) {
return url.replaceAll("^https?://", "");
}
Expand Down
92 changes: 70 additions & 22 deletions src/main/java/com/contentstack/cms/models/OAuthConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -73,20 +73,18 @@ public boolean isPkceEnabled() {
*/
public String getFormattedAuthorizationEndpoint() {
if (authEndpoint != null) {
return authEndpoint;
return validateHttpsEndpoint(authEndpoint);
}

String hostname = host != null ? host : Util.OAUTH_APP_HOST;
// Only use the configured host when it is a genuine Contentstack host;
// otherwise fall back to the default. This prevents SSRF via a host such
// as "evil-contentstack.attacker.com" that merely contains the substring.
String hostname = isTrustedContentstackHost(host) ? host : Util.OAUTH_APP_HOST;

// Transform hostname if needed
if (hostname.contains("contentstack")) {
hostname = hostname
.replaceAll("-api\\.", "-app.") // eu-api.contentstack.com -> eu-app.contentstack.com
.replaceAll("^api\\.", "app.") // api.contentstack.io -> app.contentstack.io
.replaceAll("\\.io$", ".com"); // *.io -> *.com
} else {
hostname = Util.OAUTH_APP_HOST;
}
hostname = hostname
.replaceAll("-api\\.", "-app.") // eu-api.contentstack.com -> eu-app.contentstack.com
.replaceAll("^api\\.", "app.") // api.contentstack.io -> app.contentstack.io
.replaceAll("\\.io$", ".com"); // *.io -> *.com

return "https://" + hostname + String.format(Util.OAUTH_AUTHORIZE_ENDPOINT, appId);
}
Expand All @@ -98,24 +96,74 @@ public String getFormattedAuthorizationEndpoint() {
*/
public String getTokenEndpoint() {
if (tokenEndpoint != null) {
return tokenEndpoint;
return validateHttpsEndpoint(tokenEndpoint);
}

String hostname = host != null ? host : Util.OAUTH_API_HOST;
// Only use the configured host when it is a genuine Contentstack host;
// otherwise fall back to the default. This prevents SSRF via a host such
// as "evil-contentstack.attacker.com" that merely contains the substring.
String hostname = isTrustedContentstackHost(host) ? host : Util.OAUTH_API_HOST;

// Transform hostname if needed
if (hostname.contains("contentstack")) {
hostname = hostname
.replaceAll("-api\\.", "-developerhub-api.") // eu-api.contentstack.com -> eu-developerhub-api.contentstack.com
.replaceAll("^api\\.", "developerhub-api.") // api.contentstack.io -> developerhub-api.contentstack.io
.replaceAll("\\.io$", ".com"); // *.io -> *.com
} else {
hostname = Util.OAUTH_API_HOST;
}
hostname = hostname
.replaceAll("-api\\.", "-developerhub-api.") // eu-api.contentstack.com -> eu-developerhub-api.contentstack.com
.replaceAll("^api\\.", "developerhub-api.") // api.contentstack.io -> developerhub-api.contentstack.io
.replaceAll("\\.io$", ".com"); // *.io -> *.com

return "https://" + hostname + Util.OAUTH_TOKEN_ENDPOINT;
}

/**
* Determines whether the supplied host is a bare Contentstack hostname that
* is safe to use as an outbound request target.
*
* <p>The host must be a bare host name (optionally with a port) — no embedded
* scheme, credentials, path, query, fragment, or whitespace — and must resolve
* to a {@code contentstack.com} or {@code contentstack.io} domain. This guards
* against Server-Side Request Forgery (SSRF) when the host originates from
* untrusted input.
*
* @param candidate the candidate host
* @return true if the host is a trusted, well-formed Contentstack host
*/
private static boolean isTrustedContentstackHost(String candidate) {
if (candidate == null) {
return false;
}
String host = candidate.trim();
// Reject embedded scheme, credentials, path/query/fragment, whitespace,
// and any other characters that would change the request target.
if (!host.matches("^[A-Za-z0-9.-]+(:\\d{1,5})?$")) {
return false;
}
int portIndex = host.indexOf(':');
String hostOnly = (portIndex >= 0 ? host.substring(0, portIndex) : host).toLowerCase();
return hostOnly.equals("contentstack.com")
|| hostOnly.equals("contentstack.io")
|| hostOnly.endsWith(".contentstack.com")
|| hostOnly.endsWith(".contentstack.io");
}

/**
* Validates that an explicitly configured OAuth endpoint is a well-formed
* absolute HTTPS URL. Rejecting non-HTTPS schemes prevents the endpoint from
* being pointed at internal services or non-web protocols (SSRF).
*
* @param endpoint the configured endpoint URL
* @return the endpoint, unchanged, when valid
* @throws IllegalArgumentException if the endpoint is not a valid HTTPS URL
*/
private static String validateHttpsEndpoint(String endpoint) {
try {
URL url = new URL(endpoint);
if (!"https".equalsIgnoreCase(url.getProtocol())) {
throw new IllegalArgumentException("OAuth endpoint must use HTTPS: " + endpoint);
}
return endpoint;
} catch (MalformedURLException e) {
throw new IllegalArgumentException("Invalid OAuth endpoint URL: " + endpoint, e);
}
}

/**
* Gets the response type, defaulting to "code"
*
Expand Down
50 changes: 50 additions & 0 deletions src/test/java/com/contentstack/cms/oauth/OAuthTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,56 @@ public void testCustomEndpoints() {
customTokenEndpoint, tokenUrl);
}

@Test
public void testSsrfHostIsRejected() {
// Hosts that merely contain the "contentstack" substring, or that smuggle
// a scheme/credentials/path, must NOT be used as the request target.
String[] maliciousHosts = {
"evil-contentstack.attacker.com",
"contentstack.attacker.com",
"attacker.com",
"attacker.com/contentstack.com",
"user@contentstack.com.attacker.com",
"http://contentstack.com@attacker.com"
};

for (String malicious : maliciousHosts) {
OAuthConfig config = OAuthConfig.builder()
.appId(TEST_APP_ID)
.clientId(TEST_CLIENT_ID)
.redirectUri(TEST_REDIRECT_URI)
.host(malicious)
.build();

String authUrl = config.getFormattedAuthorizationEndpoint();
String tokenUrl = config.getTokenEndpoint();

assertFalse("Auth URL must not target malicious host " + malicious + ": " + authUrl,
authUrl.contains("attacker.com"));
assertFalse("Token URL must not target malicious host " + malicious + ": " + tokenUrl,
tokenUrl.contains("attacker.com"));
// The request target must fall back to a genuine Contentstack domain.
assertTrue("Auth URL should target a Contentstack host: " + authUrl,
authUrl.startsWith("https://") && authUrl.contains(".contentstack.com/"));
assertTrue("Token URL should target a Contentstack host: " + tokenUrl,
tokenUrl.startsWith("https://") && tokenUrl.contains(".contentstack.com/"));
}
}

@Test
public void testNonHttpsCustomEndpointRejected() {
// An explicitly configured endpoint must be HTTPS; non-HTTPS schemes that
// could reach internal services or non-web protocols are rejected.
OAuthConfig config = OAuthConfig.builder()
.appId(TEST_APP_ID)
.clientId(TEST_CLIENT_ID)
.redirectUri(TEST_REDIRECT_URI)
.tokenEndpoint("http://169.254.169.254/latest/meta-data")
.build();

assertThrows(IllegalArgumentException.class, config::getTokenEndpoint);
}



// =================
Expand Down
Loading