diff --git a/changelog.md b/changelog.md index b364d46a..da5b3d9e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # Changelog +## v1.12.2 + +### Jul 06, 2026 + +- Snyk fix + ## v1.12.1 ### Jun 29, 2026 diff --git a/pom.xml b/pom.xml index f81fe82f..57672e64 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ cms jar contentstack-management-java - 1.12.1 + 1.12.2 Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an API-first approach diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index 933bb717..e0f1f7f5 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -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()) { @@ -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; } diff --git a/src/main/java/com/contentstack/cms/core/Endpoint.java b/src/main/java/com/contentstack/cms/core/Endpoint.java index 1d5afabb..74d67242 100644 --- a/src/main/java/com/contentstack/cms/core/Endpoint.java +++ b/src/main/java/com/contentstack/cms/core/Endpoint.java @@ -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() {} @@ -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; } @@ -113,7 +125,7 @@ public static Map getContentstackEndpoints(String region, boolea Map result = new LinkedHashMap<>(); if (endpoints != null) { for (Map.Entry entry : endpoints.entrySet()) { - String url = entry.getValue().getAsString(); + String url = validateTrustedEndpoint(entry.getValue().getAsString()); result.put(entry.getKey(), omitHttps ? stripHttps(url) : url); } } @@ -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. + * + *

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?://", ""); } diff --git a/src/main/java/com/contentstack/cms/models/OAuthConfig.java b/src/main/java/com/contentstack/cms/models/OAuthConfig.java index 0b29f02a..f998b345 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthConfig.java +++ b/src/main/java/com/contentstack/cms/models/OAuthConfig.java @@ -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); } @@ -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. + * + *

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" * diff --git a/src/test/java/com/contentstack/cms/oauth/OAuthTest.java b/src/test/java/com/contentstack/cms/oauth/OAuthTest.java index ea664960..c0cc32c0 100644 --- a/src/test/java/com/contentstack/cms/oauth/OAuthTest.java +++ b/src/test/java/com/contentstack/cms/oauth/OAuthTest.java @@ -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); + } + // =================