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); + } + // =================