diff --git a/components/apimgt/org.wso2.carbon.apimgt.api/src/main/java/org/wso2/carbon/apimgt/api/ExceptionCodes.java b/components/apimgt/org.wso2.carbon.apimgt.api/src/main/java/org/wso2/carbon/apimgt/api/ExceptionCodes.java index b723e4ef3ca1..8c05652ad6b5 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.api/src/main/java/org/wso2/carbon/apimgt/api/ExceptionCodes.java +++ b/components/apimgt/org.wso2.carbon.apimgt.api/src/main/java/org/wso2/carbon/apimgt/api/ExceptionCodes.java @@ -145,6 +145,13 @@ public enum ExceptionCodes implements ErrorHandler { RESOURCE_RETRIEVAL_FAILED(900402, "Resource retrieval failed", 400, "Resource retrieval failed"), USER_MAPPING_RETRIEVAL_FAILED(900404, "User mapping retrieval failed", 404, "User mapping retrieval failed"), MALFORMED_URL(900403, "Malformed URL", 400, "Malformed URL"), + UNTRUSTED_URL(900405, "URL could not be resolved", 400, + "The provided URL could not be resolved."), + UNTRUSTED_URL_IN_DEFINITION(900407, "Remote reference could not be resolved", 400, + "A remote reference in the definition could not be resolved."), + NETWORK_SECURITY_ACCESS_CONTROL_MISCONFIGURED(900406, + "Internal server error. Please contact the system administrator.", 500, + "Internal server error. Please contact the system administrator."), // Endpoint related codes ENDPOINT_NOT_FOUND(900450, "Endpoint Not Found", 404, "Endpoint Not Found"), diff --git a/components/apimgt/org.wso2.carbon.apimgt.api/src/main/java/org/wso2/carbon/apimgt/api/model/OASParserOptions.java b/components/apimgt/org.wso2.carbon.apimgt.api/src/main/java/org/wso2/carbon/apimgt/api/model/OASParserOptions.java index 6c46a0c05dfe..24589ee6e140 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.api/src/main/java/org/wso2/carbon/apimgt/api/model/OASParserOptions.java +++ b/components/apimgt/org.wso2.carbon.apimgt.api/src/main/java/org/wso2/carbon/apimgt/api/model/OASParserOptions.java @@ -19,6 +19,8 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import java.util.List; + /** * Model class to hold OpenAPI Specification parser options. */ @@ -28,10 +30,29 @@ public class OASParserOptions { private boolean explicitStyleAndExplode = true; private Integer yamlCodePointLimit = null; + private List remoteRefAllowList; + private List remoteRefBlockList; + private boolean networkAccessControlEnabled = false; public OASParserOptions() { } + /** + * Copy-constructor. Copies fields directly (no re-conversion) so that already-computed values such as + * {@code yamlCodePointLimit} are not re-interpreted through their String setters. + * + * @param other the instance to copy from; if {@code null}, this instance retains its default values + */ + public OASParserOptions(OASParserOptions other) { + if (other != null) { + this.explicitStyleAndExplode = other.explicitStyleAndExplode; + this.yamlCodePointLimit = other.yamlCodePointLimit; + this.remoteRefAllowList = other.remoteRefAllowList; + this.remoteRefBlockList = other.remoteRefBlockList; + this.networkAccessControlEnabled = other.networkAccessControlEnabled; + } + } + public boolean isExplicitStyleAndExplode() { return explicitStyleAndExplode; } @@ -84,4 +105,36 @@ public void setYamlCodePointLimit(String snakeYamlMaxFileSizeLimit) { this.yamlCodePointLimit = limit > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) limit; } -} \ No newline at end of file + public List getRemoteRefAllowList() { + return remoteRefAllowList; + } + + public void setRemoteRefAllowList(List remoteRefAllowList) { + this.remoteRefAllowList = remoteRefAllowList; + } + + public List getRemoteRefBlockList() { + return remoteRefBlockList; + } + + public void setRemoteRefBlockList(List remoteRefBlockList) { + this.remoteRefBlockList = remoteRefBlockList; + } + + public boolean isNetworkAccessControlEnabled() { + return networkAccessControlEnabled; + } + + /** + * Configure whether the network access-control policy is in force for remote {@code $ref} resolution. When + * {@code false} (the default), the parser retains its historical behaviour and resolves remote refs without any + * host validation - preserving backwards compatibility for deployments that have not configured the policy. It + * is set to {@code true} only when a platform or tenant network access-control policy is present. + * + * @param networkAccessControlEnabled whether the network access-control policy is configured + */ + public void setNetworkAccessControlEnabled(boolean networkAccessControlEnabled) { + this.networkAccessControlEnabled = networkAccessControlEnabled; + } + +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.api/src/test/java/org/wso2/carbon/apimgt/api/model/OASParserOptionsTest.java b/components/apimgt/org.wso2.carbon.apimgt.api/src/test/java/org/wso2/carbon/apimgt/api/model/OASParserOptionsTest.java new file mode 100644 index 000000000000..3d11e9e242a4 --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.api/src/test/java/org/wso2/carbon/apimgt/api/model/OASParserOptionsTest.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.apimgt.api.model; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.Arrays; + +public class OASParserOptionsTest { + @Test + public void testRemoteRefListsDefaultNullAndRoundTrip() { + OASParserOptions o = new OASParserOptions(); + Assert.assertNull(o.getRemoteRefAllowList()); + Assert.assertNull(o.getRemoteRefBlockList()); + // Backwards compatibility: network access control is off unless explicitly enabled. + Assert.assertFalse(o.isNetworkAccessControlEnabled()); + o.setRemoteRefAllowList(Arrays.asList("*.wso2.com", "api.github.com")); + o.setRemoteRefBlockList(Arrays.asList("*.internal")); + o.setNetworkAccessControlEnabled(true); + Assert.assertEquals(2, o.getRemoteRefAllowList().size()); + Assert.assertEquals("*.internal", o.getRemoteRefBlockList().get(0)); + Assert.assertTrue(o.isNetworkAccessControlEnabled()); + } + + @Test + public void testCopyConstructorCopiesFieldsRaw() { + OASParserOptions that = new OASParserOptions(); + that.setExplicitStyleAndExplode("false"); + that.setYamlCodePointLimit("10"); + that.setRemoteRefAllowList(Arrays.asList("*.wso2.com", "api.github.com")); + that.setRemoteRefBlockList(Arrays.asList("*.internal")); + that.setNetworkAccessControlEnabled(true); + + OASParserOptions copy = new OASParserOptions(that); + + Assert.assertEquals(that.isExplicitStyleAndExplode(), copy.isExplicitStyleAndExplode()); + // Must be copied verbatim (already a code-point count), not re-converted as if it were MB. + Assert.assertEquals(that.getYamlCodePointLimit(), copy.getYamlCodePointLimit()); + Assert.assertNotEquals(Integer.valueOf(Integer.MAX_VALUE), copy.getYamlCodePointLimit()); + Assert.assertEquals(that.getRemoteRefAllowList(), copy.getRemoteRefAllowList()); + Assert.assertEquals(that.getRemoteRefBlockList(), copy.getRemoteRefBlockList()); + Assert.assertTrue(copy.isNetworkAccessControlEnabled()); + + OASParserOptions fromNull = new OASParserOptions(null); + Assert.assertNull(fromNull.getRemoteRefAllowList()); + Assert.assertNull(fromNull.getRemoteRefBlockList()); + Assert.assertNull(fromNull.getYamlCodePointLimit()); + Assert.assertTrue(fromNull.isExplicitStyleAndExplode()); + Assert.assertFalse(fromNull.isNetworkAccessControlEnabled()); + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/APIConstants.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/APIConstants.java index ba8a22a5014e..6219174c29af 100755 --- a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/APIConstants.java +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/APIConstants.java @@ -4050,4 +4050,24 @@ public static class SynapseArtifactGenerator { public static final String QUEUE_CAPACITY = THREAD_POOL_CONFIG + "QueueCapacity"; } + // Constants related to network security access control + public static class NetworkSecurityAccessControl { + + private static final String CONFIG_PREFIX = "NetworkSecurityAccessControl."; + public static final String ENABLED = CONFIG_PREFIX + "Enabled"; + public static final String MODE = CONFIG_PREFIX + "Mode"; + public static final String HOSTS = CONFIG_PREFIX + "Host"; + public static final String BLOCK_PRIVATE_NETWORK_ACCESS = CONFIG_PREFIX + "BlockPrivateNetworkAccess"; + public static final String MODE_ALLOW = "allow"; + public static final String MODE_DENY = "deny"; + // Wildcard deny-list entry for the remote-$ref resolver: matches every host, so an allow-mode policy denies + // everything not on its allow-list (a restrictive whitelist). + public static final String MATCH_ALL_HOSTS = "*"; + + // Tenant config JSON keys (under "NetworkSecurityAccessControl" in tenant-conf.json) + public static final String TENANT_CONFIG_KEY = "NetworkSecurityAccessControl"; + public static final String TENANT_MODE = "Mode"; + public static final String TENANT_HOSTS = "Hosts"; + public static final String TENANT_BLOCK_PRIVATE_NETWORK_ACCESS = "BlockPrivateNetworkAccess"; + } } diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/restapi/publisher/ApisApiServiceImplUtils.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/restapi/publisher/ApisApiServiceImplUtils.java index 02ba8410c336..e225c00aece8 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/restapi/publisher/ApisApiServiceImplUtils.java +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/restapi/publisher/ApisApiServiceImplUtils.java @@ -78,6 +78,7 @@ import org.wso2.carbon.apimgt.spec.parser.definitions.OAS3Parser; import org.wso2.carbon.apimgt.spec.parser.definitions.OASParserUtil; import org.wso2.carbon.base.ServerConfiguration; +import org.wso2.carbon.context.CarbonContext; import org.wso2.carbon.core.util.CryptoException; import org.wso2.carbon.core.util.CryptoUtil; import org.wso2.carbon.utils.CarbonUtils; @@ -659,19 +660,22 @@ public static APIDefinitionValidationResponse validateOpenAPIDefinition(String u boolean returnContent) throws APIManagementException { APIDefinitionValidationResponse validationResponse = new APIDefinitionValidationResponse(); - OASParserOptions parserOptions = ServiceReferenceHolder.getInstance().getAPIMDependencyConfigurationService() - .getAPIMDependencyConfigurations().getOasParserOptions(); + OASParserOptions baseParserOptions = ServiceReferenceHolder.getInstance() + .getAPIMDependencyConfigurationService().getAPIMDependencyConfigurations().getOasParserOptions(); + OASParserOptions parserOptions = APIUtil.buildRefResolutionOptions(baseParserOptions, + CarbonContext.getThreadLocalCarbonContext().getTenantDomain()); + // Resolve the configured import size limit once so all validation paths honor it. + String maxContentSizeStr = ServiceReferenceHolder.getInstance().getAPIManagerConfigurationService() + .getAPIManagerConfiguration().getFirstProperty( + org.wso2.carbon.apimgt.api.APIConstants.API_PUBLISHER_IMPORT_OAS_FILE_SIZE_LIMIT); + if (maxContentSizeStr == null || maxContentSizeStr.trim().isEmpty()) { + maxContentSizeStr = org.wso2.carbon.apimgt.api. + APIConstants.API_PUBLISHER_IMPORT_OAS_FILE_SIZE_LIMIT_DEFAULT_MB; + } if (url != null) { try { URL urlObj = new URL(url); HttpClient httpClient = APIUtil.getHttpClient(urlObj.getPort(), urlObj.getProtocol()); - String maxContentSizeStr = ServiceReferenceHolder.getInstance().getAPIManagerConfigurationService() - .getAPIManagerConfiguration().getFirstProperty( - org.wso2.carbon.apimgt.api.APIConstants.API_PUBLISHER_IMPORT_OAS_FILE_SIZE_LIMIT); - if (maxContentSizeStr == null || maxContentSizeStr.trim().isEmpty()) { - maxContentSizeStr = org.wso2.carbon.apimgt.api. - APIConstants.API_PUBLISHER_IMPORT_OAS_FILE_SIZE_LIMIT_DEFAULT_MB; - } validationResponse = OASParserUtil.validateAPIDefinitionByURL(url, httpClient, returnContent, parserOptions, maxContentSizeStr); } catch (MalformedURLException e) { @@ -682,7 +686,7 @@ public static APIDefinitionValidationResponse validateOpenAPIDefinition(String u if (fileName != null) { if (fileName.endsWith(".zip")) { validationResponse = OASParserUtil.extractAndValidateOpenAPIArchive(inputStream, returnContent, - parserOptions); + parserOptions, maxContentSizeStr); } else { String openAPIContent = IOUtils.toString(inputStream, CHARSET); validationResponse = OASParserUtil.validateAPIDefinition(openAPIContent, returnContent, diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/utils/APIUtil.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/utils/APIUtil.java index e923176e42f4..b8de486c91fc 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/utils/APIUtil.java +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/utils/APIUtil.java @@ -131,6 +131,7 @@ import org.wso2.carbon.apimgt.api.model.KeyManagerConfiguration; import org.wso2.carbon.apimgt.api.model.KeyManagerConnectorConfiguration; import org.wso2.carbon.apimgt.api.model.Mediation; +import org.wso2.carbon.apimgt.api.model.OASParserOptions; import org.wso2.carbon.apimgt.api.model.OperationPolicyData; import org.wso2.carbon.apimgt.api.model.OperationPolicyDefinition; import org.wso2.carbon.apimgt.api.model.OperationPolicySpecification; @@ -284,6 +285,7 @@ import java.math.BigInteger; import java.math.RoundingMode; import java.net.Inet4Address; +import java.net.Inet6Address; import java.net.InetAddress; import java.net.MalformedURLException; import java.net.NetworkInterface; @@ -460,6 +462,10 @@ private APIUtil() { private static double retryProgressionFactor; private static String gatewayTypes; private static int maxRetryCount; + private static boolean networkSecurityEnabled; + private static String networkSecurityMode; + private static List networkSecurityHosts; + private static boolean networkSecurityBlockPrivateAccess; //constants for getting masked token private static final int MAX_LEN = 36; @@ -487,6 +493,14 @@ public static void init() throws APIManagementException { retryProgressionFactor = apiManagerConfiguration.getGatewayArtifactSynchronizerProperties() .getRetryProgressionFactor(); gatewayTypes = apiManagerConfiguration.getFirstProperty(APIConstants.API_GATEWAY_TYPE); + networkSecurityEnabled = Boolean.parseBoolean(apiManagerConfiguration + .getFirstProperty(APIConstants.NetworkSecurityAccessControl.ENABLED)); + networkSecurityMode = apiManagerConfiguration + .getFirstProperty(APIConstants.NetworkSecurityAccessControl.MODE); + networkSecurityHosts = apiManagerConfiguration + .getProperty(APIConstants.NetworkSecurityAccessControl.HOSTS); + networkSecurityBlockPrivateAccess = Boolean.parseBoolean(apiManagerConfiguration + .getFirstProperty(APIConstants.NetworkSecurityAccessControl.BLOCK_PRIVATE_NETWORK_ACCESS)); try { eventPublisherFactory = ServiceReferenceHolder.getInstance().getEventPublisherFactory(); eventPublishers.putIfAbsent(EventPublisherType.ASYNC_WEBHOOKS, @@ -12534,4 +12548,467 @@ public static boolean hasRestrictedScopePrefix(String scopeName) { } return hasRestrictedPrefix; } + + /** + * Validates an outbound URL against platform and tenant network security access control policies. + * Blank URLs are silently skipped. Malformed URLs throw with {@code ExceptionCodes.MALFORMED_URL}. + * + * @param url URL to validate; null or blank values are silently skipped + * @param tenantDomain tenant domain used to load tenant-level config + * @throws APIManagementException if the URL is malformed or blocked by an access control policy + */ + public static void validateRemoteURL(String url, String tenantDomain) throws APIManagementException { + if (StringUtils.isBlank(url)) { + if (log.isDebugEnabled()) { + log.debug("URL validation skipped - blank URL provided"); + } + return; + } + + // JMS and Consul endpoint URLs are not resolvable hosts and are validated elsewhere; skip them. + if (url.startsWith("jms:") || url.startsWith("consul(")) { + return; + } + + // A parameterized (templated) host cannot be resolved, so skip it; a concrete host is still validated + // even when only the path/query is parameterized. + String host = null; + if (url.contains("{") || url.contains("}")) { + host = extractConcreteHost(url); + if (host == null) { + if (log.isDebugEnabled()) { + log.debug("URL validation skipped - parameterized host: " + url); + } + return; + } + } + + JSONObject tenantConfig = getTenantConfig(tenantDomain); + JSONObject tenantAccessControl = null; + if (tenantConfig != null) { + tenantAccessControl = (JSONObject) tenantConfig.get( + APIConstants.NetworkSecurityAccessControl.TENANT_CONFIG_KEY); + } + boolean tenantEnabled = tenantAccessControl != null; + + if (!networkSecurityEnabled && !tenantEnabled) { + return; + } + + if (host == null) { + try { + host = new URI(url).getHost(); + if (StringUtils.isBlank(host)) { + throw new APIManagementException("Could not extract a valid host from the provided URL: " + url, + ExceptionCodes.MALFORMED_URL); + } + } catch (URISyntaxException e) { + throw new APIManagementException("The provided URL is malformed: " + url, + ExceptionCodes.MALFORMED_URL); + } + } + + if (networkSecurityEnabled) { + applyAccessControlPolicy(host, networkSecurityMode, networkSecurityHosts, + networkSecurityBlockPrivateAccess); + } + + if (tenantEnabled) { + String tenantMode = (String) tenantAccessControl.get( + APIConstants.NetworkSecurityAccessControl.TENANT_MODE); + boolean tenantBlockPrivate = Boolean.TRUE.equals( + tenantAccessControl.get( + APIConstants.NetworkSecurityAccessControl.TENANT_BLOCK_PRIVATE_NETWORK_ACCESS)); + JSONArray tenantHostsArray = (JSONArray) tenantAccessControl.get( + APIConstants.NetworkSecurityAccessControl.TENANT_HOSTS); + List tenantHosts = null; + if (tenantHostsArray != null) { + tenantHosts = new ArrayList<>(); + for (Object tenantHost : tenantHostsArray) { + tenantHosts.add(tenantHost.toString()); + } + } + applyAccessControlPolicy(host, tenantMode, tenantHosts, tenantBlockPrivate); + } + } + + /** + * Extracts the concrete host from a parameterized URL, ignoring a parameterized path or query. Returns + * {@code null} when the host (authority) is itself parameterized and therefore not resolvable. + * + * @param url the URL, which may contain '{'/'}' template markers + * @return the concrete host, or {@code null} if it cannot be determined + */ + private static String extractConcreteHost(String url) { + int schemeSeparator = url.indexOf("://"); + if (schemeSeparator < 0) { + return null; + } + int authorityEnd = url.length(); + for (int i = schemeSeparator + 3; i < url.length(); i++) { + char c = url.charAt(i); + if (c == '/' || c == '?' || c == '#') { + authorityEnd = i; + break; + } + } + String authority = url.substring(schemeSeparator + 3, authorityEnd); + if (authority.isEmpty() || authority.contains("{") || authority.contains("}")) { + return null; + } + try { + // Parse only scheme://authority so a parameterized path/query does not break host resolution. + String host = new URI(url.substring(0, authorityEnd)).getHost(); + return StringUtils.isBlank(host) ? null : host; + } catch (URISyntaxException e) { + return null; + } + } + + /** + * Builds a per-request {@link OASParserOptions} carrying the remote-$ref allow/block lists derived from the + * platform and tenant network access-control policy, combined as a logical AND (defense-in-depth): a remote + * reference must be permitted by both the platform and the tenant policy. Never mutates {@code base} (may be a + * shared singleton). + *

+ * The lists are mapped onto the resolver, whose allow-list short-circuits to ALLOW (bypassing the block-list) and + * whose block-list is a wildcard-capable denylist: + *

    + *
  • deny-mode hosts (from either policy) are unioned into the block-list;
  • + *
  • allow-mode hosts go to the allow-list - the intersection when both policies are allow-mode, otherwise the + * single allow-mode list;
  • + *
  • any denied host is removed from the allow-list so it cannot short-circuit the block-list;
  • + *
  • if either policy is allow-mode, a {@code "*"} entry is added to the block-list so everything not on the + * allow-list is denied - a restrictive whitelist, matching {@code applyAccessControlPolicy}.
  • + *
+ * Private-network blocking is handled by the resolver itself and needs no list entry here. + * + * @param base base options to copy non-access-control settings from (may be null) + * @param tenantDomain the tenant domain whose config should be merged in + * @return a new {@link OASParserOptions} instance; never null + */ + public static OASParserOptions buildRefResolutionOptions(OASParserOptions base, String tenantDomain) + throws APIManagementException { + OASParserOptions options = new OASParserOptions(base); + // Each allow-mode policy contributes one host set; deny-mode hosts from every policy are unioned. A remote ref + // must pass both policies (AND), so allow sets are intersected and deny sets are unioned (see combine below). + List> allowModeHostSets = new ArrayList<>(); + Set denyHosts = new HashSet<>(); + // Whether any network access-control policy is configured. With no policy (neither platform nor tenant), + // safe resolution stays off so the parser keeps resolving remote refs as before (backwards compatibility). + boolean policyConfigured = false; + + // Platform policy (static fields populated in init()). + if (networkSecurityEnabled) { + policyConfigured = true; + validateNetworkSecurityMode(networkSecurityMode); + if (APIConstants.NetworkSecurityAccessControl.MODE_ALLOW.equalsIgnoreCase(networkSecurityMode)) { + allowModeHostSets.add(networkSecurityHosts != null ? networkSecurityHosts : new ArrayList<>()); + } else if (APIConstants.NetworkSecurityAccessControl.MODE_DENY.equalsIgnoreCase(networkSecurityMode) + && networkSecurityHosts != null) { + denyHosts.addAll(networkSecurityHosts); + } + } + + // Tenant policy. Only the config read is guarded; a misconfigured tenant policy must surface, not be swallowed. + JSONObject tenantConfig; + try { + tenantConfig = getTenantConfig(tenantDomain); + } catch (APIManagementException e) { + log.warn("Could not read tenant network access-control policy for $ref resolution; " + + "proceeding with platform policy only.", e); + tenantConfig = null; + } + if (tenantConfig != null) { + Object nsac = tenantConfig.get(APIConstants.NetworkSecurityAccessControl.TENANT_CONFIG_KEY); + if (nsac instanceof JSONObject) { + policyConfigured = true; + JSONObject policy = (JSONObject) nsac; + String tMode = (String) policy.get(APIConstants.NetworkSecurityAccessControl.TENANT_MODE); + Object tHostsObj = policy.get(APIConstants.NetworkSecurityAccessControl.TENANT_HOSTS); + List tHosts = new ArrayList<>(); + if (tHostsObj instanceof JSONArray) { + for (Object h : (JSONArray) tHostsObj) { + tHosts.add(h.toString()); + } + } + validateNetworkSecurityMode(tMode); + if (APIConstants.NetworkSecurityAccessControl.MODE_ALLOW.equalsIgnoreCase(tMode)) { + allowModeHostSets.add(tHosts); + } else if (APIConstants.NetworkSecurityAccessControl.MODE_DENY.equalsIgnoreCase(tMode)) { + denyHosts.addAll(tHosts); + } + } + } + + // Intersect the allow-mode host sets (a host must be allowed by every allow-mode policy under the AND policy). + List allowList = intersectAllowModeHostSets(allowModeHostSets); + // A denied host must never remain on the allow-list: the resolver's allow-list short-circuits to ALLOW and + // would otherwise bypass the block-list for a host that another policy denies. + allowList.removeAll(denyHosts); + List blockList = new ArrayList<>(denyHosts); + // Allow-mode is a restrictive whitelist (deny everything not explicitly allowed). The resolver's allow-list + // only exempts hosts, so a wildcard deny is what enforces "block the rest". + if (!allowModeHostSets.isEmpty()) { + blockList.add(APIConstants.NetworkSecurityAccessControl.MATCH_ALL_HOSTS); + } + + if (!allowList.isEmpty()) { + options.setRemoteRefAllowList(allowList); + } + if (!blockList.isEmpty()) { + options.setRemoteRefBlockList(blockList); + } + options.setNetworkAccessControlEnabled(policyConfigured); + return options; + } + + /** + * Intersects the allow-mode host sets collected from the platform and tenant policies for the remote-$ref + * resolver. With both policies in allow mode the result is their intersection (a host must be allowed by both + * under the AND policy); with a single allow-mode policy it is that policy's list; with none it is empty. + * + * @param allowModeHostSets one host set per allow-mode policy (may be empty) + * @return a new, mutable list holding the intersection of the given sets; empty if none were provided + */ + private static List intersectAllowModeHostSets(List> allowModeHostSets) { + if (allowModeHostSets.isEmpty()) { + return new ArrayList<>(); + } + List combined = new ArrayList<>(allowModeHostSets.get(0)); + for (int i = 1; i < allowModeHostSets.size(); i++) { + combined.retainAll(allowModeHostSets.get(i)); + } + return combined; + } + + /** + * Validates the configured network access-control mode for an enabled policy. A blank mode is permitted (it means + * private-network blocking only, with no host allow/deny list). Any non-blank value other than + * {@code allow}/{@code deny} is a misconfiguration and is rejected, mirroring {@code applyAccessControlPolicy}. + * + * @param mode the configured mode, or {@code null}/blank for the private-network-only policy + * @throws APIManagementException with {@code NETWORK_SECURITY_ACCESS_CONTROL_MISCONFIGURED} if the mode is invalid + */ + private static void validateNetworkSecurityMode(String mode) throws APIManagementException { + // Blank mode is valid (private-network-only) and handled by applyAccessControlPolicy; not a misconfiguration. + if (StringUtils.isBlank(mode)) { + return; + } + if (!APIConstants.NetworkSecurityAccessControl.MODE_ALLOW.equalsIgnoreCase(mode) + && !APIConstants.NetworkSecurityAccessControl.MODE_DENY.equalsIgnoreCase(mode)) { + APIManagementException ex = new APIManagementException( + ExceptionCodes.NETWORK_SECURITY_ACCESS_CONTROL_MISCONFIGURED.getErrorMessage(), + ExceptionCodes.NETWORK_SECURITY_ACCESS_CONTROL_MISCONFIGURED); + log.error("Network security access control misconfiguration: mode='" + mode + "' is not a valid value " + + "(expected 'allow' or 'deny').", ex); + throw ex; + } + } + + /** + * Extract endpoint URLs from endpoint config object. + * + * @param endpointConfigObj Endpoint config JSON object + * @param endpointType Indicating which endpoint to be extracted + * @param endpoints List of URLs. Extracted URL(s), if any, are added to this list. + */ + public static void extractURLsFromEndpointConfig(org.json.JSONObject endpointConfigObj, String endpointType, + ArrayList endpoints) throws APIManagementException { + if (!endpointConfigObj.isNull(endpointType)) { + org.json.JSONObject endpointObj = endpointConfigObj.optJSONObject(endpointType); + if (endpointObj != null) { + String url = endpointObj.optString(APIConstants.API_DATA_URL, null); + if (StringUtils.isNotBlank(url)) { + endpoints.add(url); + } + } else { + org.json.JSONArray endpointArray = endpointConfigObj.optJSONArray(endpointType); + if (endpointArray != null) { + for (int i = 0; i < endpointArray.length(); i++) { + org.json.JSONObject endpointEntry = endpointArray.optJSONObject(i); + if (endpointEntry == null) { + // Skip malformed (non-object) entries instead of failing the request. + continue; + } + String url = endpointEntry.optString(APIConstants.API_DATA_URL, null); + if (StringUtils.isNotBlank(url)) { + endpoints.add(url); + } + } + } + } + } + } + + private static void applyAccessControlPolicy(String host, String mode, List hosts, + boolean blockPrivateNetworkAccess) throws APIManagementException { + + if (StringUtils.isBlank(mode)) { + if (hosts != null && !hosts.isEmpty()) { + log.warn("Network security access control has hosts configured but no mode is set. " + + "The hosts list will be ignored. Set mode to 'allow' or 'deny'."); + } + // fall through to blank-mode private network check below + } else if (APIConstants.NetworkSecurityAccessControl.MODE_ALLOW.equalsIgnoreCase(mode)) { + if (hosts == null || hosts.isEmpty()) { + log.warn("Network security access control is configured with mode 'allow' but no hosts are defined. " + + "All outbound requests will be blocked."); + throw buildURLBlockedException(host); + } + // hostname match → ALLOW immediately, DNS resolution skipped + if (isHostInList(host, hosts)) { + return; + } + // hostname did not match — resolve and check resolved IPs against allow list. + // hosts list is authoritative: blockPrivateNetworkAccess does not apply in allow mode. + InetAddress[] addresses; + try { + addresses = InetAddress.getAllByName(host); + } catch (UnknownHostException e) { + log.warn("Blocking outbound request to host: '" + host + "' — hostname could not be resolved."); + throw buildURLBlockedException(host); + } + if (isAnyResolvedIpInList(addresses, hosts)) { + return; + } + throw buildURLBlockedException(host); + + } else if (APIConstants.NetworkSecurityAccessControl.MODE_DENY.equalsIgnoreCase(mode)) { + if (isHostInList(host, hosts)) { + throw buildURLBlockedException(host); + } + // hostname did not match — resolve once, reused for IP deny list check and private network check + InetAddress[] addresses; + try { + addresses = InetAddress.getAllByName(host); + } catch (UnknownHostException e) { + log.warn("Blocking outbound request to host: '" + host + "' — hostname could not be resolved."); + throw buildURLBlockedException(host); + } + if (isAnyResolvedIpInList(addresses, hosts)) { + log.warn("Blocking outbound request to host: '" + host + "' — a resolved IP is in the deny list."); + throw buildURLBlockedException(host); + } + if (blockPrivateNetworkAccess) { + for (InetAddress address : addresses) { + if (isPrivateNetworkAddress(address)) { + log.warn("Blocking private network access attempt to host: '" + host + + "' (" + address.getHostAddress() + ")"); + throw buildURLBlockedException(host); + } + } + } + return; // deny mode fully handled — do not fall through + + } else { + APIManagementException ex = new APIManagementException( + ExceptionCodes.NETWORK_SECURITY_ACCESS_CONTROL_MISCONFIGURED.getErrorMessage(), + ExceptionCodes.NETWORK_SECURITY_ACCESS_CONTROL_MISCONFIGURED); + log.error("Network security access control misconfiguration: mode='" + mode + "' is not a valid value " + + "(expected 'allow' or 'deny').", ex); + throw ex; + } + + // Blank mode: hosts ignored, only blockPrivateNetworkAccess applies + if (blockPrivateNetworkAccess) { + try { + InetAddress[] addresses = InetAddress.getAllByName(host); + for (InetAddress address : addresses) { + if (isPrivateNetworkAddress(address)) { + log.warn("Blocking private network access attempt to host: '" + host + + "' (" + address.getHostAddress() + ")"); + throw buildURLBlockedException(host); + } + } + } catch (UnknownHostException e) { + log.warn("Blocking outbound request to host: '" + host + "' — hostname could not be resolved."); + throw buildURLBlockedException(host); + } + } + } + + private static boolean isAnyResolvedIpInList(InetAddress[] addresses, List hosts) { + if (hosts == null || addresses == null) { + return false; + } + for (InetAddress address : addresses) { + if (isHostInList(address.getHostAddress(), hosts)) { + return true; + } + } + return false; + } + + private static boolean isHostInList(String host, List hosts) { + if (hosts == null) { + return false; + } + String normalizedHost = host.toLowerCase(Locale.ROOT); + for (String pattern : hosts) { + if (StringUtils.isBlank(pattern)) { + continue; + } + if (normalizedHost.matches(toWildcardRegex(pattern.toLowerCase(Locale.ROOT)))) { + return true; + } + } + return false; + } + + /** + * Checks whether the given IP address belongs to a private, local, or otherwise + * non-public network range that should be blocked for outbound requests. + * + * This includes: + *
    + *
  • Loopback addresses (e.g., 127.0.0.1, ::1)
  • + *
  • Link-local addresses
  • + *
  • Site-local/private addresses
  • + *
  • Wildcard/any-local addresses
  • + *
  • Multicast addresses
  • + *
  • IPv6 Unique Local Addresses (fc00::/7)
  • + *
+ * + * @param address The resolved IP address to validate + * @return {@code true} if the address belongs to a blocked private or local + * network range, {@code false} otherwise + */ + private static boolean isPrivateNetworkAddress(InetAddress address) { + if (address instanceof Inet6Address) { + byte[] bytes = address.getAddress(); + if ((bytes[0] & 0xFE) == 0xFC) { + return true; // IPv6 Unique Local Address (fc00::/7) + } + } + return address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isAnyLocalAddress() + || address.isMulticastAddress(); + } + + private static APIManagementException buildURLBlockedException(String host) { + APIManagementException ex = new APIManagementException( + "Outbound request blocked by network security access control policy.", + ExceptionCodes.UNTRUSTED_URL); + log.error("Outbound request to host '" + host + "' blocked by network security access control policy.", ex); + return ex; + } + + /** + * Converts a simple wildcard host pattern into a safe Java regex. + * Uses Pattern.quote() to safely escape all literal parts so that users can + * write plain host patterns such as *.wso2.com or 169.254.* without needing + * to know regex syntax. Only '*' is treated as a wildcard. + * + * @param pattern wildcard host pattern (e.g. *.wso2.com, 169.254.*, *) + * @return equivalent anchored regex string + */ + private static String toWildcardRegex(String pattern) { + return "^" + Arrays.stream(pattern.trim().split("\\*", -1)) + .map(Pattern::quote) + .collect(Collectors.joining(".*")) + "$"; + } + } diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/AccessControlledUriResolver.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/AccessControlledUriResolver.java new file mode 100644 index 000000000000..a07e5885273c --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/AccessControlledUriResolver.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.wso2.carbon.apimgt.impl.wsdl; + +import org.apache.woden.WSDLException; +import org.apache.woden.internal.resolver.SimpleURIResolver; +import org.apache.woden.resolver.URIResolver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.wso2.carbon.apimgt.api.APIManagementException; +import org.wso2.carbon.apimgt.impl.utils.APIUtil; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +/** + * Woden {@link URIResolver} that gates every remote ({@code http}/{@code https}) nested WSDL/XSD + * reference ({@code wsdl:import}, {@code xsd:import}, {@code xsd:include}) through the network-security + * policy. Local and catalog references (e.g. Woden's bundled XML-Schema resources served by + * {@link SimpleURIResolver} as {@code jar:} URIs) are delegated unchanged. A reference whose target is + * rejected by the policy is redirected to a harmless local empty stub so the parser never opens a + * connection to the blocked host. + *

+ * Note: returning {@code null} or throwing from a Woden resolver does NOT stop the fetch — Woden falls + * back to the raw URI. Only redirecting to a different URI prevents the outbound request. + *

+ * Known limitation: only the initially-referenced host is validated. If an allow-listed host responds with + * an HTTP redirect, Woden follows it using the JDK's default behavior without re-validating the redirect + * target — an accepted residual, consistent with the WSDL 1.1 remote-import fetcher ({@link RemoteSchemaFetcher}). + */ +public class AccessControlledUriResolver implements URIResolver { + + private static final Logger log = LoggerFactory.getLogger(AccessControlledUriResolver.class); + + private static final String STUB_WSDL_RESOURCE = "/wsdl/blocked-reference.wsdl"; + private static final String STUB_XSD_RESOURCE = "/wsdl/blocked-reference.xsd"; + // Non-remote fallbacks used only if the bundled stub cannot be located: Woden opens these, so the + // worst case is a local parse failure (fail-closed), never a fetch of the blocked host. + private static final URI FALLBACK_WSDL_STUB = URI.create("file:/apim-blocked-reference.wsdl"); + private static final URI FALLBACK_XSD_STUB = URI.create("file:/apim-blocked-reference.xsd"); + + private final URIResolver delegate; + private final String tenantDomain; + private final List blockedReferences = new ArrayList<>(); + + public AccessControlledUriResolver(String tenantDomain) throws WSDLException { + this(new SimpleURIResolver(), tenantDomain); + } + + AccessControlledUriResolver(URIResolver delegate, String tenantDomain) { + this.delegate = delegate; + this.tenantDomain = tenantDomain; + } + + @Override + public URI resolveURI(URI uri) throws WSDLException, IOException { + URI resolved = delegate.resolveURI(uri); + // What Woden will actually open: the delegate's result, or the original URI if unmapped. + URI effective = (resolved != null) ? resolved : uri; + if (isRemote(effective)) { + try { + APIUtil.validateRemoteURL(effective.toString(), tenantDomain); + } catch (APIManagementException e) { + if (log.isDebugEnabled()) { + log.debug("Blocked WSDL/XSD nested reference by network security policy: " + effective, e); + } + blockedReferences.add(effective.toString()); + return stubFor(effective); + } + } + return resolved; + } + + /** @return true if at least one remote reference was blocked by the policy during resolution. */ + public boolean hasBlockedReferences() { + return !blockedReferences.isEmpty(); + } + + /** @return the remote reference URLs that were blocked by the policy (for user feedback / logging). */ + public List getBlockedReferences() { + return blockedReferences; + } + + private static boolean isRemote(URI uri) { + String scheme = uri.getScheme(); + return scheme != null && ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)); + } + + /** + * Resolves the blocked reference to a bundled, non-remote stub URI. Never throws, never returns + * {@code null}, and never returns a remote URI, so a resolver return can never cause Woden to fall + * back to fetching the blocked host. If the bundled resource cannot be located/converted, a + * hardcoded non-remote fallback is returned (fail-closed). + */ + private static URI stubFor(URI blocked) { + String path = blocked.getPath(); + boolean xsd = path != null && path.toLowerCase().endsWith(".xsd"); + String resource = xsd ? STUB_XSD_RESOURCE : STUB_WSDL_RESOURCE; + URI fallback = xsd ? FALLBACK_XSD_STUB : FALLBACK_WSDL_STUB; + try { + URL url = AccessControlledUriResolver.class.getResource(resource); + if (url != null) { + return url.toURI(); + } + log.warn("Blocked-reference stub resource not found on classpath: " + resource + + " — using non-remote fallback to keep the block fail-closed"); + } catch (URISyntaxException e) { + log.warn("Could not convert blocked-reference stub resource to a URI: " + resource + + " — using non-remote fallback", e); + } + return fallback; + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/AccessControlledWSDLLocator.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/AccessControlledWSDLLocator.java new file mode 100644 index 000000000000..8a0672b17181 --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/AccessControlledWSDLLocator.java @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.wso2.carbon.apimgt.impl.wsdl; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.wso2.carbon.apimgt.api.APIManagementException; +import org.wso2.carbon.apimgt.impl.utils.APIFileUtil; +import org.xml.sax.InputSource; + +import javax.wsdl.xml.WSDLLocator; +import java.io.IOException; +import java.io.StringReader; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +/** + * {@link WSDLLocator} that gates every nested WSDL 1.1 schema reference ({@code xsd:import}, + * {@code xsd:include}, {@code xsd:redefine} {@code schemaLocation}) discovered by WSDL4J while parsing a + * WSDL 1.1 document. + *

+ * A reference is classified as: + *

    + *
  • REMOTE — absolute {@code http}/{@code https}, or relative resolved against a remote + * {@code parentLocation} — routed through the injected {@link RemoteSchemaFetcher}, which validates the + * URL against the network-security access-control policy before fetching it.
  • + *
  • LOCAL — relative resolved against a local/archive {@code parentLocation} — contained to the + * extracted WSDL archive root via {@link APIFileUtil#resolveFilePath(String, String)}.
  • + *
  • LOCAL-ABSOLUTE — a {@code file:} URI or an absolute filesystem path — always blocked; there + * is nothing safe to anchor an absolute local path to.
  • + *
+ * A blocked reference is never surfaced as a fetch of the blocked target: it is recorded (for later + * reporting via {@link #hasBlockedReferences()} / {@link #getBlockedReferences()}) and a harmless empty + * schema stub is returned instead, so the WSDL4J parse degrades gracefully (the blocked types are simply + * omitted) rather than aborting outright — {@link #getImportInputSource(String, String)} never returns + * {@code null}. + */ +public class AccessControlledWSDLLocator implements WSDLLocator { + + private static final Logger log = LoggerFactory.getLogger(AccessControlledWSDLLocator.class); + + static final String EMPTY_SCHEMA_STUB = ""; + + private final String archiveRoot; + private final String baseUri; + private final RemoteSchemaFetcher fetcher; + private final List blockedReferences = new ArrayList<>(); + + private String latestImportURI; + + public AccessControlledWSDLLocator(String archiveRootOrNull, String baseUriOrNull, String tenantDomain) { + this(archiveRootOrNull, baseUriOrNull, new PolicyGatedSchemaFetcher(tenantDomain)); + } + + AccessControlledWSDLLocator(String archiveRootOrNull, String baseUriOrNull, RemoteSchemaFetcher fetcher) { + this.archiveRoot = archiveRootOrNull; + this.baseUri = baseUriOrNull; + this.fetcher = fetcher; + } + + @Override + public InputSource getBaseInputSource() { + // Defensive: the readWSDL(WSDLLocator, Element) overload this locator is installed through never calls it. + InputSource source = new InputSource(new StringReader(EMPTY_SCHEMA_STUB)); + source.setSystemId(baseUri); + return source; + } + + @Override + public InputSource getImportInputSource(String parentLocation, String importLocation) { + Classification classification = classify(parentLocation, importLocation); + switch (classification.type) { + case REMOTE: + return fetchRemote(classification.effective); + case LOCAL: + return readLocal(parentLocation, classification.effective); + case LOCAL_ABSOLUTE: + default: + recordBlocked(importLocation); + return stub(); + } + } + + @Override + public String getBaseURI() { + return baseUri; + } + + @Override + public String getLatestImportURI() { + return latestImportURI; + } + + @Override + public void close() { + } + + /** @return true if at least one nested reference was blocked by the policy during parsing. */ + public boolean hasBlockedReferences() { + return !blockedReferences.isEmpty(); + } + + /** @return the nested reference locations that were blocked (for user feedback / logging). */ + public List getBlockedReferences() { + return blockedReferences; + } + + /** + * Classifies {@code importLocation} relative to {@code parentLocation} to decide the routing. + */ + private Classification classify(String parentLocation, String importLocation) { + if (isRemoteAbsolute(importLocation)) { + return new Classification(Type.REMOTE, importLocation); + } + if (isLocalAbsolute(importLocation)) { + return new Classification(Type.LOCAL_ABSOLUTE, importLocation); + } + if (isRemoteAbsolute(parentLocation)) { + String effective = resolveUri(parentLocation, importLocation); + // Re-check the scheme on the FINAL effective URL: a relative ref carrying its own absolute + // non-http(s) scheme (ftp:/jar:/file:) is unchanged by URI#resolve and must not inherit REMOTE. + if (effective == null || !isRemoteAbsolute(effective)) { + return new Classification(Type.LOCAL_ABSOLUTE, importLocation); + } + return new Classification(Type.REMOTE, effective); + } + return new Classification(Type.LOCAL, importLocation); + } + + private InputSource fetchRemote(String effectiveUrl) { + try { + java.io.InputStream stream = fetcher.fetch(effectiveUrl); + latestImportURI = effectiveUrl; + InputSource source = new InputSource(stream); + source.setSystemId(effectiveUrl); + return source; + } catch (APIManagementException e) { + // Policy block (expected): record it and degrade to a harmless stub rather than aborting the parse. + recordBlocked(effectiveUrl); + return stub(); + } catch (IOException e) { + // Transport error (timeout, 404), not a policy block: re-thrown (wrapped, no checked exceptions) so a + // broken reference fails the parse instead of silently "validating" with missing types. + throw new SchemaResolutionRuntimeException(e); + } + } + + private InputSource readLocal(String parentLocation, String relativeRef) { + if (archiveRoot == null) { + // No archive root to anchor a relative local ref to (e.g. a pasted single WSDL) -> block. + recordBlocked(relativeRef); + return stub(); + } + Path root = Paths.get(archiveRoot).toAbsolutePath().normalize(); + Path candidate; + try { + // Resolve against the referring doc's dir (parentLocation); parentDirWithin clamps it to the archive root. + Path parentDir = parentDirWithin(parentLocation, root); + candidate = parentDir.resolve(relativeRef).normalize(); + } catch (InvalidPathException e) { + // relativeRef is not a valid filesystem path fragment -> treat as a (non-fetch) block. + recordBlocked(relativeRef); + return stub(); + } + try { + // Containment check via APIFileUtil.resolveFilePath: an escaping candidate is ".."-prefixed and rejected. + String rel = root.relativize(candidate).toString(); + Path resolved = APIFileUtil.resolveFilePath(archiveRoot, rel); + latestImportURI = resolved.toUri().toString(); + InputSource source = new InputSource(Files.newInputStream(resolved)); + source.setSystemId(latestImportURI); + return source; + } catch (APIManagementException | IllegalArgumentException e) { + // Containment rejection (escape / incompatible root): fail-closed (non-fetch) block, recorded + stubbed. + recordBlocked(relativeRef); + return stub(); + } catch (IOException e) { + // Unreadable in-archive file: not a policy block, so re-thrown (like fetchRemote) rather than stubbed. + throw new SchemaResolutionRuntimeException(e); + } + } + + /** + * Derives the directory to resolve a relative local reference against: the directory of the REFERRING + * document ({@code parentLocation}), contained to the archive {@code root}. {@code parentLocation} may be + * a plain filesystem path (the top-level document base set by the processor) or a {@code file:} URI (a + * previously-resolved import's systemId — see {@code latestImportURI}). If it is blank, unparseable, has + * no parent, or resolves OUTSIDE the archive root, the archive root itself is returned — a defensive + * clamp so {@code parentLocation} can never widen the resolution base beyond the archive. + */ + private static Path parentDirWithin(String parentLocation, Path root) { + if (parentLocation == null || parentLocation.trim().isEmpty()) { + return root; + } + try { + Path parentPath = (schemeOf(parentLocation) != null) + ? Paths.get(URI.create(parentLocation)) + : Paths.get(parentLocation); + Path parentDir = parentPath.getParent(); + if (parentDir == null) { + return root; + } + parentDir = parentDir.toAbsolutePath().normalize(); + if (!parentDir.startsWith(root)) { + return root; + } + return parentDir; + } catch (RuntimeException e) { + // Unparseable/non-local parentLocation: fall back to the archive root rather than trusting it. + return root; + } + } + + private void recordBlocked(String ref) { + if (log.isDebugEnabled()) { + log.debug("Blocked WSDL schema reference: " + ref); + } + blockedReferences.add(ref); + // WSDL4J looks up getLatestImportURI() in a Hashtable after every import, including blocked ones, + // and a null key throws NPE — so this must be set on every block path or the first block breaks the parse. + latestImportURI = (ref != null) ? ref : "urn:apim:blocked-reference"; + } + + private static InputSource stub() { + return new InputSource(new StringReader(EMPTY_SCHEMA_STUB)); + } + + private static boolean isRemoteAbsolute(String location) { + if (location == null) { + return false; + } + String scheme = schemeOf(location); + return "http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme); + } + + private static boolean isLocalAbsolute(String location) { + if (location == null) { + return false; + } + String scheme = schemeOf(location); + if ("file".equalsIgnoreCase(scheme)) { + return true; + } + try { + return java.nio.file.Paths.get(location).isAbsolute(); + } catch (java.nio.file.InvalidPathException e) { + return false; + } + } + + private static String schemeOf(String location) { + try { + return new URI(location).getScheme(); + } catch (URISyntaxException e) { + return null; + } + } + + /** + * Resolves {@code importLocation} against a remote {@code parentLocation}, returning the fully-resolved + * absolute URI string, or {@code null} if resolution fails. + */ + private static String resolveUri(String parentLocation, String importLocation) { + try { + return new URI(parentLocation).resolve(importLocation).toString(); + } catch (URISyntaxException | IllegalArgumentException e) { + return null; + } + } + + private enum Type { + REMOTE, LOCAL, LOCAL_ABSOLUTE + } + + private static final class Classification { + private final Type type; + private final String effective; + + private Classification(Type type, String effective) { + this.type = type; + this.effective = effective; + } + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/PolicyGatedSchemaFetcher.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/PolicyGatedSchemaFetcher.java new file mode 100644 index 000000000000..23d5c301e314 --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/PolicyGatedSchemaFetcher.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.wso2.carbon.apimgt.impl.wsdl; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.wso2.carbon.apimgt.api.APIConstants; +import org.wso2.carbon.apimgt.api.APIManagementException; +import org.wso2.carbon.apimgt.api.SizeLimitedInputStream; +import org.wso2.carbon.apimgt.impl.internal.ServiceReferenceHolder; +import org.wso2.carbon.apimgt.impl.utils.APIUtil; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; + +/** + * Policy-gated {@link RemoteSchemaFetcher} used to retrieve remote XML schema/WSDL documents (e.g. the + * WSDL 1.1 remote-import locator's nested {@code xsd:import}/{@code include} targets). The requested URL is + * validated against the network-security access-control policy ({@link APIUtil#validateRemoteURL(String, + * String)}) BEFORE a connection is opened to it, so a request that targets a blocked host is never made. + *

+ * Redirect handling is intentionally out of scope: any redirect returned by the server is followed using + * the JDK's default behavior without re-validation, matching the accepted residual of the shipped WSDL 2.0 + * gate. This class does not disable or loop over redirects itself. + *

+ * The response body is wrapped in a {@link SizeLimitedInputStream} enforcing the same + * {@code API_PUBLISHER_IMPORT_WSDL_FILE_SIZE_LIMIT} configuration limit used by + * {@link WSDL11ProcessorImpl#init(URL)} for direct WSDL fetches, so a remote schema fetched through this + * class cannot be used to exhaust memory/disk. + *

+ * The connection is opened with finite connect and read timeouts so a slow or unresponsive host cannot + * block the fetching thread indefinitely. + */ +public class PolicyGatedSchemaFetcher implements RemoteSchemaFetcher { + + private static final Logger log = LoggerFactory.getLogger(PolicyGatedSchemaFetcher.class); + + private static final int CONNECT_TIMEOUT_MILLIS = 10000; + private static final int READ_TIMEOUT_MILLIS = 30000; + + private final String tenantDomain; + private final RemoteUrlValidator validator; + private final long maxFileSize; + private final int connectTimeoutMillis; + private final int readTimeoutMillis; + + public PolicyGatedSchemaFetcher(String tenantDomain) { + this(tenantDomain, APIUtil::validateRemoteURL); + } + + PolicyGatedSchemaFetcher(String tenantDomain, RemoteUrlValidator validator) { + this(tenantDomain, validator, getMaxFileSize()); + } + + PolicyGatedSchemaFetcher(String tenantDomain, RemoteUrlValidator validator, long maxFileSize) { + this(tenantDomain, validator, maxFileSize, CONNECT_TIMEOUT_MILLIS, READ_TIMEOUT_MILLIS); + } + + PolicyGatedSchemaFetcher(String tenantDomain, RemoteUrlValidator validator, long maxFileSize, + int connectTimeoutMillis, int readTimeoutMillis) { + this.tenantDomain = tenantDomain; + this.validator = validator; + this.maxFileSize = maxFileSize; + this.connectTimeoutMillis = connectTimeoutMillis; + this.readTimeoutMillis = readTimeoutMillis; + } + + @Override + public InputStream fetch(String url) throws APIManagementException, IOException { + validator.validate(url, tenantDomain); + URLConnection connection = new URL(url).openConnection(); + connection.setConnectTimeout(connectTimeoutMillis); + connection.setReadTimeout(readTimeoutMillis); + return new SizeLimitedInputStream(connection.getInputStream(), maxFileSize); + } + + /** + * Resolves the maximum allowed remote-schema body size, reusing the same configuration key + * ({@code API_PUBLISHER_IMPORT_WSDL_FILE_SIZE_LIMIT}) that {@link WSDL11ProcessorImpl#init(URL)} reads + * for direct WSDL fetches. Falls back to the same default if the value cannot be resolved. + *

+ * The API Manager configuration service being unavailable (e.g. in unit tests, before OSGi wiring) is + * expected and falls back quietly. Any other failure (e.g. a non-numeric configured value) is + * unexpected and is logged at WARN so a misconfiguration is visible instead of silently falling back. + */ + private static long getMaxFileSize() { + try { + String maxWSDLSizeStr = ServiceReferenceHolder.getInstance().getAPIManagerConfigurationService() + .getAPIManagerConfiguration().getFirstProperty( + APIConstants.API_PUBLISHER_IMPORT_WSDL_FILE_SIZE_LIMIT); + if (maxWSDLSizeStr == null || maxWSDLSizeStr.trim().isEmpty()) { + maxWSDLSizeStr = APIConstants.API_PUBLISHER_IMPORT_WSDL_FILE_SIZE_LIMIT_DEFAULT_MB; + } + return Long.parseLong(maxWSDLSizeStr) * 1024L * 1024L; + } catch (NullPointerException e) { + // The API Manager configuration service is not available (e.g. running outside OSGi in a unit + // test) — this is expected in that context, so fall back to the default quietly. + return defaultMaxFileSize(); + } catch (NumberFormatException e) { + log.warn("Configured WSDL import file size limit ('" + APIConstants + .API_PUBLISHER_IMPORT_WSDL_FILE_SIZE_LIMIT + "') is not a valid number — falling back to " + + "the default of " + APIConstants.API_PUBLISHER_IMPORT_WSDL_FILE_SIZE_LIMIT_DEFAULT_MB + + "MB", e); + return defaultMaxFileSize(); + } + } + + private static long defaultMaxFileSize() { + return Long.parseLong(APIConstants.API_PUBLISHER_IMPORT_WSDL_FILE_SIZE_LIMIT_DEFAULT_MB) * 1024L * 1024L; + } + + /** + * Validates a URL against the network-security access-control policy before it is fetched. Extracted + * as a functional interface so tests can supply a fake without needing to mock {@link APIUtil}. + */ + @FunctionalInterface + interface RemoteUrlValidator { + void validate(String url, String tenantDomain) throws APIManagementException; + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/RemoteSchemaFetcher.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/RemoteSchemaFetcher.java new file mode 100644 index 000000000000..37f3aab63ebf --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/RemoteSchemaFetcher.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.wso2.carbon.apimgt.impl.wsdl; + +import org.wso2.carbon.apimgt.api.APIManagementException; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Abstraction for fetching the body of a remote XML schema/WSDL document by URL, AFTER the requested URL's + * host has been validated against the network-security access-control policy. Implementations are expected + * to perform that validation before opening any connection, so callers (e.g. the WSDL 1.1 remote-import + * locator) never need to reason about network access-control safety themselves for the initial request. + *

+ * Redirect targets are NOT re-validated: a redirect returned by the server is followed using the JDK's + * default behavior. This is an accepted residual, consistent with the shipped WSDL 2.0 gate — there is no + * per-hop revalidation and no "too many redirects" guarantee here. + */ +public interface RemoteSchemaFetcher { + + /** + * Fetches the body of the given URL. The URL itself is validated against the network-security policy + * before the fetch; any redirect the server returns is then followed using JDK-default behavior without + * re-validating the redirect target. + * + * @param url the URL to fetch + * @return an {@link InputStream} over the response body + * @throws APIManagementException if the URL is blocked by network-security policy + * @throws IOException if the fetch fails for a transport reason (e.g. connection failure) + */ + InputStream fetch(String url) throws APIManagementException, IOException; +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/SchemaResolutionRuntimeException.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/SchemaResolutionRuntimeException.java new file mode 100644 index 000000000000..3f10e957baa3 --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/SchemaResolutionRuntimeException.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.wso2.carbon.apimgt.impl.wsdl; + +/** + * Unchecked wrapper for a GENUINE (non-policy) failure to resolve or fetch a nested WSDL 1.1 schema + * reference from within {@link AccessControlledWSDLLocator} — e.g. a remote fetch transport error or a + * missing local file that nonetheless resolved safely inside the archive root. + *

+ * {@link javax.wsdl.xml.WSDLLocator#getImportInputSource(String, String)} declares no checked exceptions, so + * such a failure cannot be thrown as-is. WSDL4J's {@code parseSchema} rethrows a {@link RuntimeException} + * escaping the locator unwrapped (its exception table does NOT convert it to a + * {@link javax.wsdl.WSDLException}), so this dedicated type lets {@link WSDL11ProcessorImpl}'s init methods + * catch precisely this failure — and only this one — and map it to {@code CANNOT_PROCESS_WSDL_CONTENT}, + * without broadly swallowing every {@link RuntimeException}. + */ +class SchemaResolutionRuntimeException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + SchemaResolutionRuntimeException(Throwable cause) { + super(cause); + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL11ProcessorImpl.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL11ProcessorImpl.java index 398716945f73..510defbc1a8d 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL11ProcessorImpl.java +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL11ProcessorImpl.java @@ -114,19 +114,29 @@ public boolean init(byte[] wsdlContent) throws APIMgtWSDLException { // switch off the verbose mode wsdlReader.setFeature(JAVAX_WSDL_VERBOSE_MODE, false); wsdlReader.setFeature(JAVAX_WSDL_IMPORT_DOCUMENTS, false); + AccessControlledWSDLLocator locator = new AccessControlledWSDLLocator(null, null, resolveTenantDomain()); try { - wsdlDefinition = wsdlReader.readWSDL(null, getSecuredParsedDocumentFromContent(wsdlContent)); + wsdlDefinition = wsdlReader.readWSDL(locator, getSecuredParsedDocumentFromContent(wsdlContent) + .getDocumentElement()); if (log.isDebugEnabled()) { log.debug("Successfully initialized an instance of " + this.getClass().getSimpleName() + " with a single WSDL."); } } catch (WSDLException | APIManagementException e) { - //This implementation class cannot process the WSDL. log.debug("Cannot process the WSDL by " + this.getClass().getName(), e); setError(new ErrorItem(ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorMessage(), e.getMessage(), ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorCode(), ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getHttpStatusCode())); + } catch (SchemaResolutionRuntimeException e) { + // Genuine (non-policy) resolution failure (policy blocks are reported separately). The cause may carry + // a URL or local path, so log it server-side only and keep the client ErrorItem generic. + log.warn("Failed to resolve a nested WSDL schema reference", e.getCause()); + setError(new ErrorItem(ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorMessage(), + ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorDescription(), + ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorCode(), + ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getHttpStatusCode())); } + reportBlockedReferencesIfAny(locator); return !hasError; } @@ -138,6 +148,23 @@ public boolean init(URL url) throws APIMgtWSDLException { // switch off the verbose mode wsdlReader.setFeature(JAVAX_WSDL_VERBOSE_MODE, false); wsdlReader.setFeature(JAVAX_WSDL_IMPORT_DOCUMENTS, false); + // For a file: URL, contain relative refs to the WSDL's parent dir so a sibling schema resolves while + // resolveFilePath blocks traversal/absolute paths; a remote WSDL leaves it null and uses the gated fetcher. + String archiveRoot = null; + if ("file".equalsIgnoreCase(url.getProtocol())) { + try { + java.nio.file.Path parent = java.nio.file.Paths.get(url.toURI()).getParent(); + // A file: WSDL at the filesystem root has parent "/"; using it as the containment root + // would be vacuous, so leave archiveRoot null (block relative local refs). + if (parent != null && parent.normalize().getNameCount() > 0) { + archiveRoot = parent.toString(); + } + } catch (Exception e) { + // leave archiveRoot null -> relative local refs are blocked, as before this fix. + } + } + AccessControlledWSDLLocator locator = new AccessControlledWSDLLocator(archiveRoot, url.toString(), + resolveTenantDomain()); try { String maxWSDLSizeStr = ServiceReferenceHolder.getInstance().getAPIManagerConfigurationService() .getAPIManagerConfiguration().getFirstProperty( @@ -146,7 +173,8 @@ public boolean init(URL url) throws APIMgtWSDLException { maxWSDLSizeStr = org.wso2.carbon.apimgt.api.APIConstants.API_PUBLISHER_IMPORT_WSDL_FILE_SIZE_LIMIT_DEFAULT_MB; } long maxFileSize = Long.parseLong(maxWSDLSizeStr) * 1024L * 1024L; - wsdlDefinition = wsdlReader.readWSDL(url.toString(), getSecuredParsedDocumentFromURL(url, maxFileSize)); + wsdlDefinition = wsdlReader.readWSDL(locator, getSecuredParsedDocumentFromURL(url, maxFileSize) + .getDocumentElement()); if (log.isDebugEnabled()) { log.debug("Successfully initialized an instance of " + this.getClass().getSimpleName() + " with a single WSDL."); @@ -166,7 +194,19 @@ public boolean init(URL url) throws APIMgtWSDLException { ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorCode(), ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getHttpStatusCode())); } + } catch (SchemaResolutionRuntimeException e) { + // Genuine (non-policy) failure -- see the init(byte[]) catch above. The cause may carry the attempted + // remote URL, so it is logged server-side only and kept out of the client-facing ErrorItem. + if (log.isDebugEnabled()) { + log.debug("Cannot process the WSDL by " + this.getClass().getName(), e); + } + log.warn("Failed to resolve a nested WSDL schema reference", e.getCause()); + setError(new ErrorItem(ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorMessage(), + ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorDescription(), + ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorCode(), + ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getHttpStatusCode())); } + reportBlockedReferencesIfAny(locator); return !hasError; } @@ -177,6 +217,10 @@ public boolean initPath(String path) throws APIMgtWSDLException { wsdlArchiveExtractedPath = path; WSDLReader wsdlReader = getWsdlFactoryInstance().newWSDLReader(); + boolean anyBlocked = false; + // Declared outside the try/loop so the catch clause below can still inspect the in-flight file's + // locator if readWSDL blows up mid-loop. + AccessControlledWSDLLocator locator = null; try { // switch off the verbose mode wsdlReader.setFeature(JAVAX_WSDL_VERBOSE_MODE, false); @@ -186,13 +230,17 @@ public boolean initPath(String path) throws APIMgtWSDLException { if (log.isDebugEnabled()) { log.debug("Found " + foundWSDLFiles.size() + " WSDL file(s) in path " + path); } + String tenantDomain = resolveTenantDomain(); for (File file : foundWSDLFiles) { String absWSDLPath = file.getAbsolutePath(); if (log.isDebugEnabled()) { log.debug("Processing WSDL file: " + absWSDLPath); } - Definition definition = wsdlReader.readWSDL(absWSDLPath, getSecuredParsedDocumentFromPath(absWSDLPath)); + locator = new AccessControlledWSDLLocator(path, absWSDLPath, tenantDomain); + Definition definition = wsdlReader.readWSDL(locator, getSecuredParsedDocumentFromPath(absWSDLPath) + .getDocumentElement()); pathToDefinitionMap.put(absWSDLPath, definition); + anyBlocked |= locator.hasBlockedReferences(); // set the first found WSDL as wsdlDefinition variable assuming that it is the root WSDL if (wsdlDefinition == null) { @@ -211,6 +259,21 @@ public boolean initPath(String path) throws APIMgtWSDLException { setError(new ErrorItem(ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorMessage(), e.getMessage(), ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorCode(), ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getHttpStatusCode())); + } catch (SchemaResolutionRuntimeException e) { + // Genuine (non-policy) failure (see init(byte[]) above). The cause may carry a local path or URL, so + // log it server-side only; the debug path below is the caller's own archive path, not the offending ref. + log.debug(this.getClass().getName() + " was unable to process the WSDL Files for the path: " + path, e); + log.warn("Failed to resolve a nested WSDL schema reference", e.getCause()); + setError(new ErrorItem(ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorMessage(), + ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorDescription(), + ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorCode(), + ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getHttpStatusCode())); + } + if (locator != null && locator.hasBlockedReferences()) { + anyBlocked = true; + } + if (anyBlocked) { + setError(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION); } return !hasError; } @@ -539,4 +602,19 @@ private void setError(ErrorHandler error) { this.hasError = true; this.error = error; } + + private String resolveTenantDomain() { + return WsdlTenantResolver.resolveTenantDomain(); + } + + /** + * If the locator blocked any nested schema reference by the network-security policy (or by the + * archive-containment/local-absolute rules), report it to the user as {@link ExceptionCodes#UNTRUSTED_URL} + * (parity with the WSDL 2.0 / OpenAPI $ref case). + */ + private void reportBlockedReferencesIfAny(AccessControlledWSDLLocator locator) { + if (locator != null && locator.hasBlockedReferences()) { + setError(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION); + } + } } diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL11SOAPOperationExtractor.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL11SOAPOperationExtractor.java index dc590eb41f24..d72980be4508 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL11SOAPOperationExtractor.java +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL11SOAPOperationExtractor.java @@ -43,6 +43,7 @@ import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.wso2.carbon.apimgt.api.APIManagementException; +import org.wso2.carbon.apimgt.api.ExceptionCodes; import org.wso2.carbon.apimgt.impl.utils.APIFileUtil; import org.wso2.carbon.apimgt.impl.wsdl.exceptions.APIMgtWSDLException; import org.wso2.carbon.apimgt.impl.wsdl.model.WSDLInfo; @@ -54,6 +55,7 @@ import org.wso2.carbon.apimgt.impl.wsdl.util.SOAPToRESTConstants; import org.wso2.carbon.apimgt.impl.wsdl.util.SwaggerFieldsExcludeStrategy; import org.wso2.carbon.apimgt.impl.utils.APIMWSDLReader; +import org.wso2.carbon.apimgt.impl.utils.APIUtil; import javax.wsdl.extensions.schema.SchemaImport; import javax.wsdl.extensions.schema.SchemaReference; import javax.wsdl.extensions.soap12.SOAP12Operation; @@ -139,20 +141,23 @@ public WSDL11SOAPOperationExtractor(APIMWSDLReader wsdlReader) { @Override public boolean init(URL url) throws APIMgtWSDLException { - super.init(url); - return initModels(); + // super.init returns false (and records UNTRUSTED_URL_IN_DEFINITION) when a nested reference was blocked + // by the network access-control policy; short-circuit so initModels() cannot override that outcome. + return super.init(url) && initModels(); } @Override public boolean init(byte[] wsdlContent) throws APIMgtWSDLException { - super.init(wsdlContent); - return initModels(); + // super.init returns false (and records UNTRUSTED_URL_IN_DEFINITION) when a nested reference was blocked + // by the network access-control policy; short-circuit so initModels() cannot override that outcome. + return super.init(wsdlContent) && initModels(); } @Override public boolean initPath(String pathToExtractedZip) throws APIMgtWSDLException { - super.initPath(pathToExtractedZip); - return initModels(); + // super.initPath returns false (and records UNTRUSTED_URL_IN_DEFINITION) when a nested reference was blocked + // by the network access-control policy; short-circuit so initModels() cannot override that outcome. + return super.initPath(pathToExtractedZip) && initModels(); } /** @@ -306,6 +311,10 @@ private boolean initModels() throws APIMgtWSDLException { try { traverseTypeElement(node, null, model, currentProperty); } catch (APIManagementException e) { + if (e.getErrorHandler() != null) { + // preserve UNTRUSTED_URL (and any coded error) so it surfaces to the user + throw new APIMgtWSDLException(e.getMessage(), e, e.getErrorHandler()); + } throw new APIMgtWSDLException(e); } if (StringUtils.isNotBlank(model.getName())) { @@ -500,17 +509,36 @@ private Node findFirstElementByName(String name, Document doc) { } } - private Document getBasedXSDofWSDL(String ns) { + private Document getBasedXSDofWSDL(String ns) throws APIManagementException { if (basedSchemas.containsKey(ns)) { return basedSchemas.get(ns); } + String schemaUrl = ns + ".xsd"; + // Only a real remote HTTP(S) namespace is fetchable. A non-URL namespace (e.g. a urn:) is not a network + // reference, so skip the remote fetch entirely and let the local/base-XSD fallback run instead of failing. + if (schemaUrl == null || !(schemaUrl.startsWith("http://") || schemaUrl.startsWith("https://"))) { + return null; + } + // Gate this namespace-derived remote fetch through the network access-control policy before opening a + // connection; no-op when unconfigured, else a blocked/internal host throws UNTRUSTED_URL to fail the import. + String tenantDomain = WsdlTenantResolver.resolveTenantDomain(); + try { + APIUtil.validateRemoteURL(schemaUrl, tenantDomain); + } catch (APIManagementException e) { + // namespace-derived xsd fetch is an EMBEDDED reference -> definition-scoped message. + if (ExceptionCodes.UNTRUSTED_URL.equals(e.getErrorHandler())) { + throw new APIManagementException(e.getMessage(), e, ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION); + } + throw e; + } + Document doc = null; - APIMWSDLReader reader = new APIMWSDLReader(ns + ".xsd"); + APIMWSDLReader reader = new APIMWSDLReader(schemaUrl); try { - doc = reader.getSecuredParsedDocumentFromURL(ns + ".xsd"); + doc = reader.getSecuredParsedDocumentFromURL(schemaUrl); } catch (APIManagementException e) { - String error = "Error occurred reading wsdl document."; - log.error(error, e); + // Genuine fetch/parse failure (not a policy block) — best-effort, swallow as before. + log.error("Error occurred reading wsdl document: " + schemaUrl, e); } basedSchemas.put(ns, doc); return doc; diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL20ProcessorImpl.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL20ProcessorImpl.java index eb8b276844f5..b5d9ee37086f 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL20ProcessorImpl.java +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL20ProcessorImpl.java @@ -100,11 +100,12 @@ public boolean init(URL url) throws APIMgtWSDLException { WSDLReader reader; try { reader = WSDLFactory.newInstance().newWSDLReader(); + reader.setFeature(WSDLReader.FEATURE_VALIDATION, false); + reader.setURIResolver(new AccessControlledUriResolver(resolveTenantDomain())); } catch (WSDLException e) { throw new APIMgtWSDLException("Error while initializing the WSDL reader", e); } - reader.setFeature(WSDLReader.FEATURE_VALIDATION, false); try { String maxWSDLSizeStr = ServiceReferenceHolder.getInstance().getAPIManagerConfigurationService() .getAPIManagerConfiguration() @@ -123,6 +124,7 @@ public boolean init(URL url) throws APIMgtWSDLException { ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorCode(), ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getHttpStatusCode())); } + reportBlockedReferencesIfAny(reader); return !hasError; } @@ -132,11 +134,12 @@ public boolean init(byte[] wsdlContent) throws APIMgtWSDLException { WSDLReader reader; try { reader = getWsdlFactoryInstance().newWSDLReader(); + reader.setFeature(WSDLReader.FEATURE_VALIDATION, false); + reader.setURIResolver(new AccessControlledUriResolver(resolveTenantDomain())); } catch (WSDLException e) { throw new APIMgtWSDLException("Error while initializing the WSDL reader", e); } - reader.setFeature(WSDLReader.FEATURE_VALIDATION, false); Document document = getSecuredParsedDocumentFromContent(wsdlContent); WSDLSource wsdlSource = getWSDLSourceFromDocument(document, reader); try { @@ -152,6 +155,7 @@ public boolean init(byte[] wsdlContent) throws APIMgtWSDLException { ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorCode(), ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getHttpStatusCode())); } + reportBlockedReferencesIfAny(reader); return !hasError; } @@ -163,11 +167,12 @@ public boolean initPath(String path) throws APIMgtWSDLException { WSDLReader reader; try { reader = getWsdlFactoryInstance().newWSDLReader(); + reader.setFeature(WSDLReader.FEATURE_VALIDATION, false); + reader.setURIResolver(new AccessControlledUriResolver(resolveTenantDomain())); } catch (WSDLException e) { throw new APIMgtWSDLException("Error while initializing the WSDL reader", e); } - reader.setFeature(WSDLReader.FEATURE_VALIDATION, false); File folderToImport = new File(path); Collection foundWSDLFiles = APIFileUtil.searchFilesWithMatchingExtension(folderToImport, "wsdl"); if (log.isDebugEnabled()) { @@ -198,6 +203,7 @@ public boolean initPath(String path) throws APIMgtWSDLException { ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorCode(), ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getHttpStatusCode())); } + reportBlockedReferencesIfAny(reader); return !hasError; } @@ -391,6 +397,21 @@ private Map getEndpoints(Description description) throws APIMgtW return serviceEndpointMap; } + private String resolveTenantDomain() { + return WsdlTenantResolver.resolveTenantDomain(); + } + + /** + * If the resolver blocked any remote nested reference by the network-security policy, report it to + * the user as {@link ExceptionCodes#UNTRUSTED_URL_IN_DEFINITION} (parity with the OpenAPI $ref case). + */ + private void reportBlockedReferencesIfAny(WSDLReader reader) { + if (reader.getURIResolver() instanceof AccessControlledUriResolver + && ((AccessControlledUriResolver) reader.getURIResolver()).hasBlockedReferences()) { + setError(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION); + } + } + private void setError(ErrorHandler error) { this.hasError = true; this.error = error; @@ -400,6 +421,16 @@ private void setAddressUrl(EndpointElement endpoint,URI uri) { endpoint.setAddress(uri); } + /* + * Network access-control note (WSDL 2.0 nested schema-import vector): this builds Woden's WSDLSource from a raw DOM + * element and never calls wsdlSource.setBaseURI(...). With a null document base URI, Woden aborts + * inline-schema parsing (WSDL521, "missing base URI") before it ever walks into to discover + * a nested / schemaLocation -- so, unlike WSDL 1.1 (where WSDL4J DID fetch + * such nested locations, gated via AccessControlledWSDLLocator), an untrusted nested + * schemaLocation here is never dereferenced. Absolute / is a separate, + * still-reachable vector and remains gated by AccessControlledUriResolver (see + * WSDL20ProcessorImplResolverTest). Regression-locked by WSDL20SchemaImportNonReachableTest. + */ private WSDLSource getWSDLSourceFromDocument(Document document, WSDLReader reader) { Element domElement = document.getDocumentElement(); WSDLSource wsdlSource = reader.createWSDLSource(); diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/WsdlTenantResolver.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/WsdlTenantResolver.java new file mode 100644 index 000000000000..3d2decaf8c3c --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/java/org/wso2/carbon/apimgt/impl/wsdl/WsdlTenantResolver.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.wso2.carbon.apimgt.impl.wsdl; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.wso2.carbon.base.MultitenantConstants; +import org.wso2.carbon.context.PrivilegedCarbonContext; + +/** + * Resolves the current tenant domain for gating WSDL/schema references through the network access-control + * policy. Falls back to the super tenant domain when no CarbonContext is available on the current thread + * (e.g. a non-request or pooled thread), so a missing thread-local degrades gracefully instead of failing + * the parse/import. This only selects WHICH tenant's policy applies; it never bypasses the gate. + */ +final class WsdlTenantResolver { + + private static final Logger log = LoggerFactory.getLogger(WsdlTenantResolver.class); + + private WsdlTenantResolver() { + } + + static String resolveTenantDomain() { + try { + String tenantDomain = PrivilegedCarbonContext.getThreadLocalCarbonContext().getTenantDomain(); + return StringUtils.isBlank(tenantDomain) ? MultitenantConstants.SUPER_TENANT_DOMAIN_NAME : tenantDomain; + } catch (NullPointerException e) { + log.warn("CarbonContext tenant domain was unavailable while resolving the tenant for WSDL/schema " + + "reference policy evaluation; falling back to the super tenant domain (" + + MultitenantConstants.SUPER_TENANT_DOMAIN_NAME + ")."); + return MultitenantConstants.SUPER_TENANT_DOMAIN_NAME; + } + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/resources/tenant/tenant-config-schema.json b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/resources/tenant/tenant-config-schema.json index 893e128b40b4..ef08fcb84a9a 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/resources/tenant/tenant-config-schema.json +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/resources/tenant/tenant-config-schema.json @@ -1355,6 +1355,46 @@ "examples": [ true, false ] + }, + "NetworkSecurityAccessControl": { + "$id": "#/properties/NetworkSecurityAccessControl", + "type": "object", + "title": "NetworkSecurityAccessControl schema", + "description": "Tenant-level network security access control. Activated when this key is present.", + "default": {}, + "properties": { + "Mode": { + "$id": "#/properties/NetworkSecurityAccessControl/properties/Mode", + "type": "string", + "title": "Mode schema", + "description": "'allow' — only hosts matching the Hosts list are permitted; others are blocked. 'deny' — hosts matching the Hosts list are blocked; others are permitted.", + "enum": ["allow", "deny"], + "examples": ["allow", "deny"] + }, + "Hosts": { + "$id": "#/properties/NetworkSecurityAccessControl/properties/Hosts", + "type": "array", + "title": "Hosts schema", + "description": "Wildcard host patterns. In 'allow' mode these are permitted and bypass BlockPrivateNetworkAccess. In 'deny' mode these are blocked.", + "default": [], + "items": { + "type": "string" + }, + "examples": [ + ["*.mycompany.com", "trustedpartner.io"] + ] + }, + "BlockPrivateNetworkAccess": { + "$id": "#/properties/NetworkSecurityAccessControl/properties/BlockPrivateNetworkAccess", + "type": "boolean", + "title": "BlockPrivateNetworkAccess schema", + "description": "When true, blocks outbound requests resolving to private/reserved IP ranges. In allow mode, hosts matched by the Hosts list are exempt from private-network blocking.", + "default": false, + "examples": [true, false] + } + }, + "required": ["Mode"], + "additionalProperties": false } }, "additionalProperties": true diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/resources/wsdl/blocked-reference.wsdl b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/resources/wsdl/blocked-reference.wsdl new file mode 100644 index 000000000000..77d51f9e8d23 --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/resources/wsdl/blocked-reference.wsdl @@ -0,0 +1,6 @@ + + + + diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/resources/wsdl/blocked-reference.xsd b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/resources/wsdl/blocked-reference.xsd new file mode 100644 index 000000000000..6fd3ad051d2a --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/main/resources/wsdl/blocked-reference.xsd @@ -0,0 +1,2 @@ + + diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/utils/APIUtilRefOptionsTest.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/utils/APIUtilRefOptionsTest.java new file mode 100644 index 000000000000..a2fb44049080 --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/utils/APIUtilRefOptionsTest.java @@ -0,0 +1,335 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.wso2.carbon.apimgt.impl.utils; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; +import org.powermock.reflect.Whitebox; +import org.wso2.carbon.apimgt.api.APIManagementException; +import org.wso2.carbon.apimgt.api.ExceptionCodes; +import org.wso2.carbon.apimgt.api.model.OASParserOptions; +import org.wso2.carbon.apimgt.impl.APIConstants; + +import java.util.Arrays; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({APIUtil.class}) +public class APIUtilRefOptionsTest { + + private static final String TENANT = "carbon.super"; + + @Test + public void testAllowModePlatformProducesAllowList() throws Exception { + PowerMockito.spy(APIUtil.class); + PowerMockito.doReturn(null).when(APIUtil.class, "getTenantConfig", TENANT); + Whitebox.setInternalState(APIUtil.class, "networkSecurityEnabled", true); + Whitebox.setInternalState(APIUtil.class, "networkSecurityMode", "allow"); + Whitebox.setInternalState(APIUtil.class, "networkSecurityHosts", Arrays.asList("*.wso2.com")); + Whitebox.setInternalState(APIUtil.class, "networkSecurityBlockPrivateAccess", true); + + OASParserOptions out = APIUtil.buildRefResolutionOptions(new OASParserOptions(), TENANT); + + Assert.assertEquals(Arrays.asList("*.wso2.com"), out.getRemoteRefAllowList()); + // Allow-mode is a restrictive whitelist: a wildcard deny blocks every host not on the allow-list. + Assert.assertEquals(Arrays.asList(APIConstants.NetworkSecurityAccessControl.MATCH_ALL_HOSTS), + out.getRemoteRefBlockList()); + Assert.assertTrue("A configured platform policy must enable network access control", + out.isNetworkAccessControlEnabled()); + } + + @Test + public void testDenyModePlatformProducesBlockList() throws Exception { + PowerMockito.spy(APIUtil.class); + PowerMockito.doReturn(null).when(APIUtil.class, "getTenantConfig", TENANT); + Whitebox.setInternalState(APIUtil.class, "networkSecurityEnabled", true); + Whitebox.setInternalState(APIUtil.class, "networkSecurityMode", "deny"); + Whitebox.setInternalState(APIUtil.class, "networkSecurityHosts", Arrays.asList("*.internal")); + Whitebox.setInternalState(APIUtil.class, "networkSecurityBlockPrivateAccess", true); + + OASParserOptions out = APIUtil.buildRefResolutionOptions(new OASParserOptions(), TENANT); + + Assert.assertEquals(Arrays.asList("*.internal"), out.getRemoteRefBlockList()); + Assert.assertTrue(out.getRemoteRefAllowList() == null || out.getRemoteRefAllowList().isEmpty()); + Assert.assertTrue("A configured platform policy must enable network access control", + out.isNetworkAccessControlEnabled()); + } + + @Test + public void testInactivePolicyProducesEmptyLists() throws Exception { + PowerMockito.spy(APIUtil.class); + PowerMockito.doReturn(null).when(APIUtil.class, "getTenantConfig", TENANT); + Whitebox.setInternalState(APIUtil.class, "networkSecurityEnabled", false); + Whitebox.setInternalState(APIUtil.class, "networkSecurityMode", (String) null); + Whitebox.setInternalState(APIUtil.class, "networkSecurityHosts", (java.util.List) null); + Whitebox.setInternalState(APIUtil.class, "networkSecurityBlockPrivateAccess", false); + + OASParserOptions out = APIUtil.buildRefResolutionOptions(new OASParserOptions(), TENANT); + + Assert.assertTrue(out.getRemoteRefAllowList() == null || out.getRemoteRefAllowList().isEmpty()); + Assert.assertTrue(out.getRemoteRefBlockList() == null || out.getRemoteRefBlockList().isEmpty()); + // Backwards compatibility: with no policy configured, network access control must stay off so remote refs + // resolve exactly as they did before the feature existed. + Assert.assertFalse("No configured policy must leave network access control disabled", + out.isNetworkAccessControlEnabled()); + } + + @Test + public void testPlatformPolicyEnabledWithoutHostsStillEnablesNetworkAccessControl() throws Exception { + // The policy block can be present with no hosts (e.g. only block_private_network_access set). Presence of the + // block alone means the admin opted in, so network access control is on even though both lists are empty. + PowerMockito.spy(APIUtil.class); + PowerMockito.doReturn(null).when(APIUtil.class, "getTenantConfig", TENANT); + Whitebox.setInternalState(APIUtil.class, "networkSecurityEnabled", true); + Whitebox.setInternalState(APIUtil.class, "networkSecurityMode", (String) null); + Whitebox.setInternalState(APIUtil.class, "networkSecurityHosts", (java.util.List) null); + Whitebox.setInternalState(APIUtil.class, "networkSecurityBlockPrivateAccess", true); + + OASParserOptions out = APIUtil.buildRefResolutionOptions(new OASParserOptions(), TENANT); + + Assert.assertTrue(out.getRemoteRefAllowList() == null || out.getRemoteRefAllowList().isEmpty()); + Assert.assertTrue(out.getRemoteRefBlockList() == null || out.getRemoteRefBlockList().isEmpty()); + Assert.assertTrue(out.isNetworkAccessControlEnabled()); + } + + @Test + public void testYamlCodePointLimitPreserved() throws Exception { + PowerMockito.spy(APIUtil.class); + PowerMockito.doReturn(null).when(APIUtil.class, "getTenantConfig", TENANT); + Whitebox.setInternalState(APIUtil.class, "networkSecurityEnabled", false); + Whitebox.setInternalState(APIUtil.class, "networkSecurityMode", (String) null); + Whitebox.setInternalState(APIUtil.class, "networkSecurityHosts", (java.util.List) null); + Whitebox.setInternalState(APIUtil.class, "networkSecurityBlockPrivateAccess", false); + + OASParserOptions base = new OASParserOptions(); + base.setYamlCodePointLimit("10"); + + OASParserOptions out = APIUtil.buildRefResolutionOptions(base, TENANT); + + Assert.assertEquals(base.getYamlCodePointLimit(), out.getYamlCodePointLimit()); + Assert.assertNotEquals(Integer.valueOf(Integer.MAX_VALUE), out.getYamlCodePointLimit()); + } + + @Test + public void testInvalidModePlatformThrowsMisconfigured() throws Exception { + // An enabled platform policy whose mode is neither 'allow' nor 'deny' must fail fast rather than silently + // producing empty lists, matching the behaviour of the runtime access-control check. + PowerMockito.spy(APIUtil.class); + PowerMockito.doReturn(null).when(APIUtil.class, "getTenantConfig", TENANT); + Whitebox.setInternalState(APIUtil.class, "networkSecurityEnabled", true); + Whitebox.setInternalState(APIUtil.class, "networkSecurityMode", "block"); + Whitebox.setInternalState(APIUtil.class, "networkSecurityHosts", Arrays.asList("*.wso2.com")); + Whitebox.setInternalState(APIUtil.class, "networkSecurityBlockPrivateAccess", true); + + try { + APIUtil.buildRefResolutionOptions(new OASParserOptions(), TENANT); + Assert.fail("An invalid platform mode must raise a misconfiguration error"); + } catch (APIManagementException e) { + Assert.assertEquals(ExceptionCodes.NETWORK_SECURITY_ACCESS_CONTROL_MISCONFIGURED, e.getErrorHandler()); + } + } + + @Test + public void testInvalidModeTenantThrowsMisconfigured() throws Exception { + // An enabled tenant policy with an invalid mode must also propagate a misconfiguration error instead of being + // swallowed by the tenant-config read guard. + PowerMockito.spy(APIUtil.class); + JSONObject policy = new JSONObject(); + policy.put(APIConstants.NetworkSecurityAccessControl.TENANT_MODE, "block"); + JSONObject tenantConfig = new JSONObject(); + tenantConfig.put(APIConstants.NetworkSecurityAccessControl.TENANT_CONFIG_KEY, policy); + PowerMockito.doReturn(tenantConfig).when(APIUtil.class, "getTenantConfig", TENANT); + Whitebox.setInternalState(APIUtil.class, "networkSecurityEnabled", false); + Whitebox.setInternalState(APIUtil.class, "networkSecurityMode", (String) null); + Whitebox.setInternalState(APIUtil.class, "networkSecurityHosts", (java.util.List) null); + Whitebox.setInternalState(APIUtil.class, "networkSecurityBlockPrivateAccess", false); + + try { + APIUtil.buildRefResolutionOptions(new OASParserOptions(), TENANT); + Assert.fail("An invalid tenant mode must raise a misconfiguration error"); + } catch (APIManagementException e) { + Assert.assertEquals(ExceptionCodes.NETWORK_SECURITY_ACCESS_CONTROL_MISCONFIGURED, e.getErrorHandler()); + } + } + + @Test + public void testBothAllowModeIntersects() throws Exception { + // AND: a host must be allowed by both policies, so the allow-list is the intersection of the two allow lists. + PowerMockito.spy(APIUtil.class); + PowerMockito.doReturn(tenantPolicy("allow", "b.com", "c.com")).when(APIUtil.class, "getTenantConfig", TENANT); + setPlatform(true, "allow", Arrays.asList("a.com", "b.com")); + + OASParserOptions out = APIUtil.buildRefResolutionOptions(new OASParserOptions(), TENANT); + + assertSameElements(Arrays.asList("b.com"), out.getRemoteRefAllowList()); + assertSameElements(Arrays.asList("*"), out.getRemoteRefBlockList()); + } + + @Test + public void testDisjointBothAllowModeBlocksEverything() throws Exception { + // Disjoint allow lists → empty intersection → nothing is allowed; the wildcard deny blocks all hosts. + PowerMockito.spy(APIUtil.class); + PowerMockito.doReturn(tenantPolicy("allow", "b.com")).when(APIUtil.class, "getTenantConfig", TENANT); + setPlatform(true, "allow", Arrays.asList("a.com")); + + OASParserOptions out = APIUtil.buildRefResolutionOptions(new OASParserOptions(), TENANT); + + Assert.assertTrue(out.getRemoteRefAllowList() == null || out.getRemoteRefAllowList().isEmpty()); + assertSameElements(Arrays.asList("*"), out.getRemoteRefBlockList()); + } + + @Test + public void testPlatformDenyTenantAllowAddsWildcardAndKeepsAllowedHost() throws Exception { + // Platform deny + tenant allow: only the tenant-allowed hosts resolve, and the platform-denied host is blocked. + PowerMockito.spy(APIUtil.class); + PowerMockito.doReturn(tenantPolicy("allow", "corp.com")).when(APIUtil.class, "getTenantConfig", TENANT); + setPlatform(true, "deny", Arrays.asList("evil.com")); + + OASParserOptions out = APIUtil.buildRefResolutionOptions(new OASParserOptions(), TENANT); + + assertSameElements(Arrays.asList("corp.com"), out.getRemoteRefAllowList()); + assertSameElements(Arrays.asList("evil.com", "*"), out.getRemoteRefBlockList()); + } + + @Test + public void testDeniedHostIsRemovedFromAllowList() throws Exception { + // A host that appears on both a deny list and an allow list must stay blocked: the resolver's allow-list + // short-circuits to ALLOW, so a denied host has to be removed from the allow-list, not merely block-listed. + PowerMockito.spy(APIUtil.class); + PowerMockito.doReturn(tenantPolicy("allow", "shared.com", "ok.com")) + .when(APIUtil.class, "getTenantConfig", TENANT); + setPlatform(true, "deny", Arrays.asList("shared.com")); + + OASParserOptions out = APIUtil.buildRefResolutionOptions(new OASParserOptions(), TENANT); + + assertSameElements(Arrays.asList("ok.com"), out.getRemoteRefAllowList()); + assertSameElements(Arrays.asList("shared.com", "*"), out.getRemoteRefBlockList()); + } + + @Test + public void testBothDenyModeUnionsWithoutWildcard() throws Exception { + // Deny-mode is a blacklist: the block-list is the union of both deny lists and no wildcard is added, so hosts + // that are not denied still resolve. + PowerMockito.spy(APIUtil.class); + PowerMockito.doReturn(tenantPolicy("deny", "b.com")).when(APIUtil.class, "getTenantConfig", TENANT); + setPlatform(true, "deny", Arrays.asList("a.com")); + + OASParserOptions out = APIUtil.buildRefResolutionOptions(new OASParserOptions(), TENANT); + + Assert.assertTrue(out.getRemoteRefAllowList() == null || out.getRemoteRefAllowList().isEmpty()); + assertSameElements(Arrays.asList("a.com", "b.com"), out.getRemoteRefBlockList()); + } + + @Test + public void testAllowModeWithoutHostsBlocksEverything() throws Exception { + // Allow-mode with no hosts means "allow nothing": the allow-list is empty and the wildcard deny blocks all. + PowerMockito.spy(APIUtil.class); + PowerMockito.doReturn(null).when(APIUtil.class, "getTenantConfig", TENANT); + setPlatform(true, "allow", null); + + OASParserOptions out = APIUtil.buildRefResolutionOptions(new OASParserOptions(), TENANT); + + Assert.assertTrue(out.getRemoteRefAllowList() == null || out.getRemoteRefAllowList().isEmpty()); + assertSameElements(Arrays.asList("*"), out.getRemoteRefBlockList()); + } + + @Test + public void testValidateRemoteURLSkipsParameterizedEndpointWhenPolicyEnabled() throws Exception { + // A parameterized backend endpoint template (resolved to a concrete host later at the gateway) must not be + // rejected as malformed when the network access-control policy is enabled — mirroring validateEndpointURL. + PowerMockito.spy(APIUtil.class); + PowerMockito.doReturn(null).when(APIUtil.class, "getTenantConfig", TENANT); + setPlatform(true, "deny", java.util.Collections.emptyList()); + + // No APIManagementException expected for any of these. + APIUtil.validateRemoteURL("http://{uri.var.host}:{uri.var.port}/context", TENANT); + APIUtil.validateRemoteURL("https://{tenant}.example.com/api", TENANT); + APIUtil.validateRemoteURL("jms:/queue?transport.jms.ConnectionFactoryJNDIName=QueueConnectionFactory", TENANT); + APIUtil.validateRemoteURL("consul(http://127.0.0.1:8500 dc1.myService)", TENANT); + } + + @Test + public void testValidateRemoteURLBlocksConcreteHostWithParameterizedPath() throws Exception { + // A concrete, resolvable host must still be validated when only the path/query is parameterized; + // a parameterized host itself remains exempt. + PowerMockito.spy(APIUtil.class); + PowerMockito.doReturn(null).when(APIUtil.class, "getTenantConfig", TENANT); + Whitebox.setInternalState(APIUtil.class, "networkSecurityEnabled", true); + Whitebox.setInternalState(APIUtil.class, "networkSecurityMode", "deny"); + Whitebox.setInternalState(APIUtil.class, "networkSecurityHosts", java.util.Collections.emptyList()); + Whitebox.setInternalState(APIUtil.class, "networkSecurityBlockPrivateAccess", true); + + try { + APIUtil.validateRemoteURL("http://127.0.0.1/{resource}", TENANT); + Assert.fail("A private host must be blocked even when the path is parameterized"); + } catch (APIManagementException e) { + // expected: the concrete loopback host is blocked by the private-network check + } + // A parameterized host is not resolvable and stays exempt. + APIUtil.validateRemoteURL("http://{uri.var.host}/{resource}", TENANT); + } + + @Test + public void testExtractURLsFromEndpointConfigSkipsNonObjectArrayElements() throws Exception { + // A malformed endpoint config with a non-object array element must be skipped, not throw an unchecked + // JSONException that surfaces as HTTP 500. + org.json.JSONArray prod = new org.json.JSONArray(); + prod.put("http://bare-string.example.com/api"); // non-object element (must be skipped) + org.json.JSONObject valid = new org.json.JSONObject(); + valid.put(APIConstants.API_DATA_URL, "http://valid.example.com/api"); + prod.put(valid); // valid object element + org.json.JSONObject endpointConfig = new org.json.JSONObject(); + endpointConfig.put(APIConstants.API_DATA_PRODUCTION_ENDPOINTS, prod); + + java.util.ArrayList endpoints = new java.util.ArrayList<>(); + APIUtil.extractURLsFromEndpointConfig(endpointConfig, APIConstants.API_DATA_PRODUCTION_ENDPOINTS, endpoints); + + Assert.assertEquals(java.util.Collections.singletonList("http://valid.example.com/api"), endpoints); + } + + private static void setPlatform(boolean enabled, String mode, java.util.List hosts) { + Whitebox.setInternalState(APIUtil.class, "networkSecurityEnabled", enabled); + Whitebox.setInternalState(APIUtil.class, "networkSecurityMode", mode); + Whitebox.setInternalState(APIUtil.class, "networkSecurityHosts", hosts); + Whitebox.setInternalState(APIUtil.class, "networkSecurityBlockPrivateAccess", false); + } + + private static JSONObject tenantPolicy(String mode, String... hosts) { + JSONObject policy = new JSONObject(); + policy.put(APIConstants.NetworkSecurityAccessControl.TENANT_MODE, mode); + JSONArray hostArray = new JSONArray(); + hostArray.addAll(Arrays.asList(hosts)); + policy.put(APIConstants.NetworkSecurityAccessControl.TENANT_HOSTS, hostArray); + JSONObject config = new JSONObject(); + config.put(APIConstants.NetworkSecurityAccessControl.TENANT_CONFIG_KEY, policy); + return config; + } + + private static void assertSameElements(java.util.List expected, java.util.List actual) { + java.util.List actualCopy = actual == null + ? new java.util.ArrayList<>() : new java.util.ArrayList<>(actual); + java.util.List expectedCopy = new java.util.ArrayList<>(expected); + java.util.Collections.sort(actualCopy); + java.util.Collections.sort(expectedCopy); + Assert.assertEquals(expectedCopy, actualCopy); + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/AccessControlledUriResolverTest.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/AccessControlledUriResolverTest.java new file mode 100644 index 000000000000..c5602e4e846a --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/AccessControlledUriResolverTest.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.apimgt.impl.wsdl; + +import org.apache.woden.resolver.URIResolver; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; +import org.wso2.carbon.apimgt.api.APIManagementException; +import org.wso2.carbon.apimgt.api.ExceptionCodes; +import org.wso2.carbon.apimgt.impl.utils.APIUtil; + +import java.net.URI; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({APIUtil.class}) +public class AccessControlledUriResolverTest { + + /** delegate that never maps anything (simulates a URI not in Woden's catalog). */ + private URIResolver passThroughDelegate() { + return uri -> null; + } + + @Test + public void blockedRemoteUriIsRedirectedToLocalStub() throws Exception { + PowerMockito.mockStatic(APIUtil.class); + PowerMockito.doThrow(new APIManagementException("URL is not trusted", ExceptionCodes.UNTRUSTED_URL)) + .when(APIUtil.class); + APIUtil.validateRemoteURL(Mockito.anyString(), Mockito.anyString()); + + AccessControlledUriResolver resolver = + new AccessControlledUriResolver(passThroughDelegate(), "carbon.super"); + + URI out = resolver.resolveURI(URI.create("http://169.254.169.254/latest/meta.xsd")); + + // Blocked reference must resolve to a non-remote stub or Woden would fetch the raw URL; scheme is + // file:/jar: depending on packaging, so assert "not remote" rather than a specific scheme. + assertNotNull("blocked reference must resolve to a non-null stub", out); + assertFalse("blocked reference must NOT resolve to a remote URL", + "http".equalsIgnoreCase(out.getScheme()) || "https".equalsIgnoreCase(out.getScheme())); + assertNotEquals("169.254.169.254", out.getHost()); + assertTrue("blocked URL must be recorded for user feedback", + resolver.getBlockedReferences().contains("http://169.254.169.254/latest/meta.xsd")); + } + + @Test + public void allowedRemoteUriPassesThroughAfterValidation() throws Exception { + PowerMockito.mockStatic(APIUtil.class); // validateRemoteURL is a no-op (allowed) + + AccessControlledUriResolver resolver = + new AccessControlledUriResolver(passThroughDelegate(), "carbon.super"); + + // delegate returns null -> resolver returns null -> Woden opens the original (allowed) URI + URI out = resolver.resolveURI(URI.create("http://api.github.com/schema.xsd")); + assertNull(out); + + PowerMockito.verifyStatic(APIUtil.class); + APIUtil.validateRemoteURL("http://api.github.com/schema.xsd", "carbon.super"); + } + + @Test + public void localCatalogUriIsReturnedWithoutPolicyCheck() throws Exception { + PowerMockito.mockStatic(APIUtil.class); + URI local = URI.create("jar:file:/woden.jar!/org/apache/woden/resolver/XMLSchema.xsd"); + URIResolver catalogDelegate = uri -> local; // simulate a catalog hit + + AccessControlledUriResolver resolver = + new AccessControlledUriResolver(catalogDelegate, "carbon.super"); + + URI out = resolver.resolveURI(URI.create("http://www.w3.org/2001/XMLSchema.xsd")); + assertEquals(local, out); + + PowerMockito.verifyStatic(APIUtil.class, Mockito.never()); + APIUtil.validateRemoteURL(Mockito.anyString(), Mockito.anyString()); + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/AccessControlledWSDLLocatorTest.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/AccessControlledWSDLLocatorTest.java new file mode 100644 index 000000000000..adcfe388fde9 --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/AccessControlledWSDLLocatorTest.java @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.apimgt.impl.wsdl; + +import org.apache.commons.io.IOUtils; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.wso2.carbon.apimgt.api.APIManagementException; +import org.wso2.carbon.apimgt.api.ExceptionCodes; +import org.xml.sax.InputSource; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class AccessControlledWSDLLocatorTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + // local ref inside archive resolves to the real file + @Test + public void resolvesInArchiveRelativeRef() throws Exception { + File root = tmp.getRoot(); + File child = new File(root, "types.xsd"); + Files.writeString(child.toPath(), ""); + AccessControlledWSDLLocator loc = new AccessControlledWSDLLocator( + root.getAbsolutePath(), new File(root, "service.wsdl").getAbsolutePath(), + (u) -> { throw new AssertionError("no remote"); }); + InputSource is = loc.getImportInputSource(new File(root, "service.wsdl").getAbsolutePath(), "types.xsd"); + assertTrue(read(is).contains("inarchive")); + assertFalse(loc.hasBlockedReferences()); + } + + // traversal escaping the archive is blocked (recorded + stub, no exception, no file read) + @Test + public void blocksArchiveEscape() throws Exception { + AccessControlledWSDLLocator loc = new AccessControlledWSDLLocator( + tmp.getRoot().getAbsolutePath(), null, (u) -> { throw new AssertionError("no remote"); }); + InputSource is = loc.getImportInputSource(tmp.getRoot().getAbsolutePath(), "../../../../etc/hostname"); + assertTrue(read(is).contains("xsd:schema")); // harmless stub + assertTrue(loc.hasBlockedReferences()); + } + + // absolute file: ref is blocked + @Test + public void blocksAbsoluteFileRef() throws Exception { + AccessControlledWSDLLocator loc = new AccessControlledWSDLLocator( + tmp.getRoot().getAbsolutePath(), null, (u) -> { throw new AssertionError("no remote"); }); + loc.getImportInputSource(tmp.getRoot().getAbsolutePath(), "file:///etc/passwd"); + assertTrue(loc.hasBlockedReferences()); + assertTrue(loc.getBlockedReferences().contains("file:///etc/passwd")); + } + + // no archive root: any local ref blocked + @Test + public void blocksLocalRefWhenNoArchiveRoot() throws Exception { + AccessControlledWSDLLocator loc = new AccessControlledWSDLLocator(null, null, + (u) -> { throw new AssertionError("no remote"); }); + loc.getImportInputSource(null, "sibling.xsd"); + assertTrue(loc.hasBlockedReferences()); + assertTrue(loc.getBlockedReferences().contains("sibling.xsd")); + } + + // A relative import whose scheme is not http/https (e.g. ftp:) is already an absolute URI, so URI#resolve + // returns it unchanged; it must be blocked (not inherit the remote parent) and the fetcher must never run. + @Test + public void blocksNonHttpSchemeUnderRemoteParent() throws Exception { + AccessControlledWSDLLocator loc = new AccessControlledWSDLLocator(null, "http://h/dir/root.wsdl", + (u) -> { + throw new AssertionError("fetcher must not be invoked for a non-http/https scheme: " + u); + }); + InputSource is = loc.getImportInputSource("http://h/dir/root.wsdl", "ftp://internal/x.xsd"); + assertTrue(read(is).contains("xsd:schema")); // harmless stub + assertTrue(loc.hasBlockedReferences()); + assertTrue(loc.getBlockedReferences().contains("ftp://internal/x.xsd")); + } + + // Same gap, another absolute scheme: "jar:http://internal/x.jar!/y.xsd" is opaque-but-absolute, so it + // also resolves ~unchanged against an http/https parent and must be blocked rather than fetched. + @Test + public void blocksJarSchemeUnderRemoteParent() throws Exception { + AccessControlledWSDLLocator loc = new AccessControlledWSDLLocator(null, "http://h/dir/root.wsdl", + (u) -> { + throw new AssertionError("fetcher must not be invoked for a non-http/https scheme: " + u); + }); + InputSource is = loc.getImportInputSource("http://h/dir/root.wsdl", "jar:http://internal/x.jar!/y.xsd"); + assertTrue(read(is).contains("xsd:schema")); // harmless stub + assertTrue(loc.hasBlockedReferences()); + } + + // A genuine (non-policy) fetch failure must propagate out of getImportInputSource rather than + // silently degrading to a stub -- it must NOT be recorded as a blocked reference either. + @Test + public void propagatesGenuineFetchFailure() { + AccessControlledWSDLLocator loc = new AccessControlledWSDLLocator(null, null, + (u) -> { throw new IOException("boom"); }); + try { + loc.getImportInputSource(null, "http://trusted.example/a.xsd"); + fail("expected getImportInputSource to propagate the genuine fetch failure"); + } catch (RuntimeException e) { + assertTrue(e.getCause() instanceof IOException); + } + assertFalse(loc.hasBlockedReferences()); + } + + // A reference that resolves safely INSIDE the archive root but does not exist on + // disk is a genuine failure (Files.newInputStream throws), not a policy block -- it must propagate too. + @Test + public void propagatesMissingContainedLocalFile() { + File root = tmp.getRoot(); + AccessControlledWSDLLocator loc = new AccessControlledWSDLLocator( + root.getAbsolutePath(), new File(root, "service.wsdl").getAbsolutePath(), + (u) -> { throw new AssertionError("no remote"); }); + try { + loc.getImportInputSource(new File(root, "service.wsdl").getAbsolutePath(), "present.xsd"); + fail("expected getImportInputSource to propagate the missing-file failure"); + } catch (RuntimeException e) { + assertTrue(e.getCause() instanceof IOException); + } + assertFalse(loc.hasBlockedReferences()); + } + + // remote allowed: delegates to fetcher, records latestImportURI, not blocked + @Test + public void fetchesAllowedRemoteRef() throws Exception { + AccessControlledWSDLLocator loc = new AccessControlledWSDLLocator(null, null, + (u) -> new ByteArrayInputStream( + "".getBytes())); + InputSource is = loc.getImportInputSource(null, "http://trusted.example/a.xsd"); + assertTrue(read(is).contains("remote")); + assertEquals("http://trusted.example/a.xsd", loc.getLatestImportURI()); + assertFalse(loc.hasBlockedReferences()); + } + + // remote blocked: fetcher throws -> recorded + stub, no exception out of getImportInputSource + @Test + public void blocksUntrustedRemoteRef() throws Exception { + AccessControlledWSDLLocator loc = new AccessControlledWSDLLocator(null, null, + (u) -> { throw new APIManagementException(ExceptionCodes.UNTRUSTED_URL); }); + InputSource is = loc.getImportInputSource(null, "http://169.254.169.254/x.xsd"); + assertTrue(read(is).contains("xsd:schema")); // stub + assertTrue(loc.hasBlockedReferences()); + } + + // Every block path must set latestImportURI to a non-null value -- WSDL4J's parseSchema looks it + // up in a java.util.Hashtable after each import, and a null key NPEs there. + @Test + public void blockSetsNonNullLatestImportURI() throws Exception { + AccessControlledWSDLLocator remoteBlocked = new AccessControlledWSDLLocator(null, null, + (u) -> { throw new APIManagementException(ExceptionCodes.UNTRUSTED_URL); }); + assertEquals(null, remoteBlocked.getLatestImportURI()); + remoteBlocked.getImportInputSource(null, "http://169.254.169.254/x.xsd"); + assertTrue(remoteBlocked.getLatestImportURI() != null); + + AccessControlledWSDLLocator archiveEscape = new AccessControlledWSDLLocator( + tmp.getRoot().getAbsolutePath(), null, (u) -> { throw new AssertionError("no remote"); }); + archiveEscape.getImportInputSource(tmp.getRoot().getAbsolutePath(), "../x"); + assertTrue(archiveEscape.getLatestImportURI() != null); + } + + // multi-level chaining: latestImportURI feeds the next parentLocation + @Test + public void chainsNestedRelativeRefViaLatestImportURI() throws Exception { + // level-1 relative remote via remote base, then a second relative resolved against latestImportURI + AccessControlledWSDLLocator loc = new AccessControlledWSDLLocator(null, "http://h/dir/root.wsdl", + (u) -> new ByteArrayInputStream( + "".getBytes())); + loc.getImportInputSource("http://h/dir/root.wsdl", "a/one.xsd"); + assertEquals("http://h/dir/a/one.xsd", loc.getLatestImportURI()); + loc.getImportInputSource(loc.getLatestImportURI(), "../two.xsd"); + assertEquals("http://h/dir/two.xsd", loc.getLatestImportURI()); + } + + // A relative ref is resolved against the REFERRING document's directory (parentLocation's parent), + // not the fixed archive root. With a same-named decoy at the root, the SUBDIR sibling must win. + @Test + public void resolvesRefRelativeToParentDir() throws Exception { + File root = tmp.getRoot(); + File subdir = new File(root, "sub"); + assertTrue(subdir.mkdirs()); + Files.writeString(new File(subdir, "sibling.xsd").toPath(), + ""); + // decoy with the same name at the archive root -- must NOT be the one resolved + Files.writeString(new File(root, "sibling.xsd").toPath(), + ""); + AccessControlledWSDLLocator loc = new AccessControlledWSDLLocator( + root.getAbsolutePath(), new File(subdir, "a.xsd").getAbsolutePath(), + (u) -> { throw new AssertionError("no remote"); }); + InputSource is = loc.getImportInputSource(new File(subdir, "a.xsd").getAbsolutePath(), "sibling.xsd"); + String content = read(is); + assertTrue("must resolve the SUBDIR sibling, not the root decoy", content.contains("insubdir")); + assertFalse(content.contains("atroot")); + assertFalse(loc.hasBlockedReferences()); + } + + // A scheme-relative ref ("//somehost/x.xsd") under a remote parent must be blocked, not read as a local + // file: Paths.get reports it absolute, so classify() marks it LOCAL_ABSOLUTE and blocks before the fetcher. + @Test + public void blocksSchemeRelativeAuthorityRefUnderRemoteParent() throws Exception { + AccessControlledWSDLLocator loc = new AccessControlledWSDLLocator(null, "http://h/dir/root.wsdl", + (u) -> { + throw new AssertionError("fetcher must not be invoked for a scheme-relative ref: " + u); + }); + InputSource is = loc.getImportInputSource("http://h/dir/root.wsdl", "//somehost/x.xsd"); + assertTrue(read(is).contains("xsd:schema")); // harmless stub, not a local file's content + assertTrue(loc.hasBlockedReferences()); + assertTrue(loc.getBlockedReferences().contains("//somehost/x.xsd")); + } + + // After ONE reference is blocked, a second, unrelated, legitimate reference resolved via the SAME locator + // instance must still resolve correctly -- the block must not leave state that corrupts a later lookup. + @Test + public void blockedReferenceDoesNotCorruptSubsequentSiblingResolution() throws Exception { + File root = tmp.getRoot(); + Files.writeString(new File(root, "sibling.xsd").toPath(), + ""); + AccessControlledWSDLLocator loc = new AccessControlledWSDLLocator( + root.getAbsolutePath(), new File(root, "service.wsdl").getAbsolutePath(), + (u) -> { throw new AssertionError("no remote"); }); + + // First import: a traversal outside the archive root -> blocked. + InputSource blocked = loc.getImportInputSource( + new File(root, "service.wsdl").getAbsolutePath(), "../../../../etc/hostname"); + assertTrue(read(blocked).contains("xsd:schema")); // harmless stub + assertTrue(loc.hasBlockedReferences()); + + // Second import: an unrelated, legitimate in-archive sibling -> must still resolve to the real file. + InputSource legit = loc.getImportInputSource( + new File(root, "service.wsdl").getAbsolutePath(), "sibling.xsd"); + assertTrue("the blocked first import must not corrupt resolution of the unrelated second import", + read(legit).contains("id='sibling'")); + assertEquals(1, loc.getBlockedReferences().size()); + assertTrue(loc.getBlockedReferences().get(0).contains("etc/hostname")); + } + + // ---- test helpers ---- + + private static String read(InputSource is) throws IOException { + if (is.getCharacterStream() != null) { + try (Reader reader = is.getCharacterStream()) { + return IOUtils.toString(reader); + } + } + try (InputStream in = is.getByteStream()) { + return IOUtils.toString(in, StandardCharsets.UTF_8); + } + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/PolicyGatedSchemaFetcherTest.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/PolicyGatedSchemaFetcherTest.java new file mode 100644 index 000000000000..b82197dbc955 --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/PolicyGatedSchemaFetcherTest.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.apimgt.impl.wsdl; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.apache.commons.io.IOUtils; +import org.junit.Test; +import org.wso2.carbon.apimgt.api.APIManagementException; +import org.wso2.carbon.apimgt.api.ExceptionCodes; +import org.wso2.carbon.apimgt.api.FileSizeLimitExceededException; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class PolicyGatedSchemaFetcherTest { + + // allowed fetch returns body + @Test + public void fetchesAllowedUrl() throws Exception { + HttpServer s = server(ctx -> respond(ctx, 200, "HELLO")); + try { + String body = read(new PolicyGatedSchemaFetcher("carbon.super", (u, t) -> { /* allow all */ }) + .fetch(base(s) + "/x.xsd")); + assertEquals("HELLO", body); + } finally { + s.stop(0); + } + } + + // untrusted host: validator throws -> propagates, host never fetched + @Test(expected = APIManagementException.class) + public void blocksUntrustedHost() throws Exception { + new PolicyGatedSchemaFetcher("carbon.super", + (u, t) -> { throw new APIManagementException(ExceptionCodes.UNTRUSTED_URL); }) + .fetch("http://169.254.169.254/x.xsd"); + } + + // size cap actually rejects oversized bodies (inject a small cap; do not loosen prod default) + @Test + public void rejectsOversizedBody() throws Exception { + HttpServer s = server(ctx -> respond(ctx, 200, "X".repeat(10_000))); + try { + InputStream in = new PolicyGatedSchemaFetcher("carbon.super", (u, t) -> {}, 100L) + .fetch(base(s) + "/big.xsd"); + try { + read(in); + fail("expected size-limit failure"); + } catch (FileSizeLimitExceededException expected) { + // SizeLimitedInputStream aborts past the cap with this specific type + } + } finally { + s.stop(0); + } + } + + // slow host: a finite read timeout aborts the fetch quickly instead of blocking indefinitely + @Test + public void readTimeoutAbortsSlowFetch() throws Exception { + HttpServer s = server(ctx -> { + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + respond(ctx, 200, "SLOW"); + }); + try { + long start = System.nanoTime(); + try { + new PolicyGatedSchemaFetcher("carbon.super", (u, t) -> { /* allow all */ }, 1_000_000L, 1000, 200) + .fetch(base(s) + "/slow.xsd"); + fail("expected read timeout"); + } catch (IOException expected) { + long elapsedMillis = (System.nanoTime() - start) / 1_000_000L; + assertTrue("fetch should abort well before the server responds, took " + elapsedMillis + "ms", + elapsedMillis < 1500); + } + } finally { + s.stop(0); + } + } + + // ---- test helpers ---- + + private static HttpServer server(HttpHandler handler) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", handler); + server.start(); + return server; + } + + private static String base(HttpServer s) { + return "http://127.0.0.1:" + s.getAddress().getPort(); + } + + private static void respond(HttpExchange ctx, int status, String body) { + try { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + ctx.sendResponseHeaders(status, bytes.length); + try (OutputStream os = ctx.getResponseBody()) { + os.write(bytes); + } + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + ctx.close(); + } + } + + private static String read(InputStream in) throws IOException { + try { + return IOUtils.toString(in, StandardCharsets.UTF_8); + } finally { + IOUtils.closeQuietly(in); + } + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL11ProcessorAccessControlIntegrationTest.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL11ProcessorAccessControlIntegrationTest.java new file mode 100644 index 000000000000..3c13bd9ad86e --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL11ProcessorAccessControlIntegrationTest.java @@ -0,0 +1,491 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.wso2.carbon.apimgt.impl.wsdl; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.wso2.carbon.apimgt.api.ExceptionCodes; +import org.wso2.carbon.apimgt.impl.APIManagerConfiguration; +import org.wso2.carbon.apimgt.impl.APIManagerConfigurationService; +import org.wso2.carbon.apimgt.impl.APIManagerConfigurationServiceImpl; +import org.wso2.carbon.apimgt.impl.config.APIMConfigService; +import org.wso2.carbon.apimgt.impl.internal.ServiceReferenceHolder; +import org.wso2.carbon.context.PrivilegedCarbonContext; + +import java.io.File; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * End-to-end network access-control wiring tests for {@link WSDL11ProcessorImpl}: proves that every entry point + * (init(byte[]), init(URL), initPath) routes nested WSDL 1.1 schema imports through + * {@link AccessControlledWSDLLocator} rather than the raw WSDL4J default locator. + *

+ * No PowerMock, no network-security policy configuration is applied here: the block proofs rely on + * references that {@link org.wso2.carbon.apimgt.impl.utils.APIFileUtil#resolveFilePath(String, String)} / + * the locator's own classification reject unconditionally (local traversal, file: URIs, pasted-relative + * refs with no archive root) -- independent of any policy state. The remote-allowed test relies on + * {@code validateRemoteURL} being a no-op when no policy is configured (backwards-compat), proving the + * wiring still resolves legitimate remote schemas. + */ +public class WSDL11ProcessorAccessControlIntegrationTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static final String XSD_BODY = + ""; + + private APIManagerConfigurationService previousConfigurationService; + private APIMConfigService previousApimConfigService; + + // Wire the real collaborators the init paths need outside OSGi: an empty APIManagerConfiguration, a started tenant + // flow for resolveTenantDomain, and a no-op APIMConfigService so validateRemoteURL takes its no-policy no-op path. + @Before + public void wireApiManagerConfigurationService() { + // Capture the process-wide services so they can be restored after the test, avoiding leaking the no-op mocks. + previousConfigurationService = ServiceReferenceHolder.getInstance().getAPIManagerConfigurationService(); + previousApimConfigService = ServiceReferenceHolder.getInstance().getApimConfigService(); + ServiceReferenceHolder.getInstance().setAPIManagerConfigurationService( + new APIManagerConfigurationServiceImpl(new APIManagerConfiguration())); + ServiceReferenceHolder.getInstance().setAPIMConfigService(new NoOpApimConfigService()); + PrivilegedCarbonContext.startTenantFlow(); + PrivilegedCarbonContext.getThreadLocalCarbonContext().setTenantId(-1234); // super tenant, no resolution + PrivilegedCarbonContext.getThreadLocalCarbonContext().setTenantDomain("carbon.super"); + } + + /** Trivial no-op {@link APIMConfigService}: no tenant config stored for any organization. */ + private static final class NoOpApimConfigService implements APIMConfigService { + @Override + public void addExternalStoreConfig(String organization, String externalStoreConfig) { } + + @Override + public void updateExternalStoreConfig(String organization, String externalStoreConfig) { } + + @Override + public String getExternalStoreConfig(String organization) { + return null; + } + + @Override + public void addTenantConfig(String organization, String tenantConfig) { } + + @Override + public String getTenantConfig(String organization) { + return null; + } + + @Override + public void updateTenantConfig(String organization, String tenantConfig) { } + + @Override + public String getWorkFlowConfig(String organization) { + return null; + } + + @Override + public void updateWorkflowConfig(String organization, String workflowConfig) { } + + @Override + public void addWorkflowConfig(String organization, String workflowConfig) { } + + @Override + public String getGAConfig(String organization) { + return null; + } + + @Override + public void updateGAConfig(String organization, String gaConfig) { } + + @Override + public void addGAConfig(String organization, String gaConfig) { } + + @Override + public Object getSelfSighupConfig(String organization) { + return null; + } + + @Override + public void updateSelfSighupConfig(String organization, String selfSignUpConfig) { } + + @Override + public void addSelfSighupConfig(String organization, String selfSignUpConfig) { } + } + + @After + public void endTenantFlow() { + PrivilegedCarbonContext.endTenantFlow(); + ServiceReferenceHolder.getInstance().setAPIManagerConfigurationService(previousConfigurationService); + ServiceReferenceHolder.getInstance().setAPIMConfigService(previousApimConfigService); + } + + // ---- fixture helpers ------------------------------------------------- + + private static String wsdlWithXsdImport(String schemaLocation) { + return "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + "\n"; + } + + /** A minimal WSDL 1.1 document with no {@code xsd:import} at all -- nothing for the locator to gate. */ + private static final String PLAIN_WSDL_NO_IMPORTS = + "\n" + + "\n" + + " \n" + + " \n" + + "\n"; + + private static String wsdlWithTwoXsdImports(String firstSchemaLocation, String secondNamespace, + String secondSchemaLocation) { + return "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + "\n"; + } + + private File extractedArchiveWith(String wsdlXml) throws IOException { + File dir = tmp.newFolder("archive-" + System.nanoTime()); + Files.write(new File(dir, "service.wsdl").toPath(), wsdlXml.getBytes(StandardCharsets.UTF_8)); + return dir; + } + + private File extractedArchiveWithSiblingXsd() throws IOException { + File dir = extractedArchiveWith(wsdlWithXsdImport("types.xsd")); + String typesXsd = + ""; + Files.write(new File(dir, "types.xsd").toPath(), typesXsd.getBytes(StandardCharsets.UTF_8)); + return dir; + } + + /** A standalone XSD (targetNamespace urn:c) that itself imports another schema. */ + private static String schemaImporting(String importNamespace, String schemaLocation) { + return "\n" + + " \n" + + ""; + } + + /** A leaf XSD with no nested imports. */ + private static String leafSchema(String namespace) { + return ""; + } + + private static void write(File file, String content) throws IOException { + Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); + } + + private HttpServer server(HttpHandler handler) throws IOException { + HttpServer httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + httpServer.createContext("/", handler); + httpServer.start(); + return httpServer; + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } + + private static int port(HttpServer httpServer) { + return httpServer.getAddress().getPort(); + } + + // ---- tests ------------------------------------------------------- + + // initPath: local traversal xsd:import is blocked end-to-end -> UNTRUSTED_URL, file NOT read + @Test + public void archiveTraversalSchemaImportBlocked() throws Exception { + File dir = extractedArchiveWith(wsdlWithXsdImport("../../../../../../etc/hostname")); + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + boolean ok = p.initPath(dir.getAbsolutePath()); + assertFalse(ok); + assertTrue(p.hasError()); + // UNTRUSTED_URL_IN_DEFINITION surfaced via reportBlockedReferencesIfAny + assertEquals(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), p.getError().getErrorCode()); + } + + // initPath: remote xsd:import IS fetched when no policy configured (no-op) -> wiring resolves remote schemas + @Test + public void archiveRemoteSchemaImportFetchedWhenAllowed() throws Exception { + AtomicInteger hits = new AtomicInteger(); + HttpServer canary = server(ctx -> { hits.incrementAndGet(); respond(ctx, 200, XSD_BODY); }); + File dir = extractedArchiveWith(wsdlWithXsdImport("http://127.0.0.1:" + port(canary) + "/c.xsd")); + try { + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + p.initPath(dir.getAbsolutePath()); + assertTrue("no policy -> remote schema fetched (backwards-compat)", hits.get() >= 1); + assertFalse(p.hasError()); + } finally { + canary.stop(0); + } + } + + // initPath: legitimate in-archive relative schema still resolves (no error) + @Test + public void archiveLocalSchemaImportStillWorks() throws Exception { + File dir = extractedArchiveWithSiblingXsd(); // service.wsdl + types.xsd, schemaLocation="types.xsd" + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + assertTrue(p.initPath(dir.getAbsolutePath())); + assertFalse(p.hasError()); + } + + // init(byte[]): pasted WSDL with a local (relative) xsd:import has no archive root -> blocked -> UNTRUSTED_URL + @Test + public void pastedLocalSchemaImportBlocked() throws Exception { + byte[] wsdl = wsdlWithXsdImport("types.xsd").getBytes(StandardCharsets.UTF_8); + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + p.init(wsdl); + assertTrue(p.hasError()); + assertEquals(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), p.getError().getErrorCode()); + } + + // A bare Thread does not inherit the @Before tenant flow (non-inheritable ThreadLocal); resolveTenantDomain's + // NPE-fallback must let init(byte[]) still succeed when there is no xsd:import to gate. + @Test + public void initSucceedsOnThreadWithoutTenantFlow() throws Exception { + byte[] wsdl = PLAIN_WSDL_NO_IMPORTS.getBytes(StandardCharsets.UTF_8); + AtomicReference result = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + result.set(p.init(wsdl)); + if (p.hasError()) { + failure.set(new AssertionError("expected no error; got errorCode=" + + p.getError().getErrorCode())); + } + } catch (Throwable t) { + failure.set(t); + } + }); + worker.start(); + worker.join(); + assertNull("init() must not throw / error out when there is no CarbonContext on the current thread", + failure.get()); + assertTrue("no xsd:import to block -> init should succeed via the tenant-domain fallback", + Boolean.TRUE.equals(result.get())); + } + + // Within one WSDL, a blocked first xsd:import must not corrupt a legitimate second sibling import: + // the block is recorded (UNTRUSTED_URL) but the import degrades to a stub, so the WSDL still parses. + @Test + public void siblingImportIndependentOfEarlierBlockedImport() throws Exception { + File dir = tmp.newFolder("archive-" + System.nanoTime()); + write(new File(dir, "service.wsdl"), + wsdlWithTwoXsdImports("../../../../../../etc/hostname", "urn:d", "legit.xsd")); + write(new File(dir, "legit.xsd"), leafSchema("urn:d")); + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + boolean ok = p.initPath(dir.getAbsolutePath()); + assertFalse(ok); + assertTrue(p.hasError()); + // UNTRUSTED_URL_IN_DEFINITION surfaced from the first import + assertEquals(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), p.getError().getErrorCode()); + // the WSDL still parsed: the blocked FIRST import did not abort the whole parse, so the SECOND, + // unrelated sibling import was not corrupted by it. + assertNotNull(p.getWSDLDefinition()); + } + + // init(URL): remote WSDL whose xsd:import is a file: ref -> LOCAL_ABSOLUTE -> blocked -> UNTRUSTED_URL, file NOT read + @Test + public void remoteWsdlWithFileSchemaImportBlocked() throws Exception { + HttpServer wsdlHost = server(ctx -> respond(ctx, 200, wsdlWithXsdImport("file:///etc/hostname"))); + try { + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + p.init(new URL("http://127.0.0.1:" + port(wsdlHost) + "/service.wsdl")); + assertTrue(p.hasError()); + assertEquals(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), p.getError().getErrorCode()); + } finally { + wsdlHost.stop(0); + } + } + + // SOAP-to-REST extractor: legit in-archive schema still extracts types (schema content NOT disabled) + @Test + public void soapToRestUnaffectedForLocalSchema() throws Exception { + File dir = extractedArchiveWithSiblingXsd(); + WSDL11SOAPOperationExtractor ex = new WSDL11SOAPOperationExtractor(); + assertTrue(ex.initPath(dir.getAbsolutePath())); + assertFalse(ex.hasError()); + } + + // init(URL) with a file: URL has relative refs to the WSDL's OWN directory: a sibling schema + // (schemaLocation="types.xsd") must resolve because the file: URL's parent dir is the containment root, not null. + @Test + public void fileUrlWsdlWithSiblingSchemaResolves() throws Exception { + File dir = extractedArchiveWithSiblingXsd(); // writes service.wsdl + types.xsd (schemaLocation="types.xsd") + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + boolean ok = p.init(new File(dir, "service.wsdl").toURI().toURL()); + assertTrue("sibling schema should resolve via document-dir containment, not be blocked", ok); + assertFalse(p.hasError()); + } + + // A schema in a subdirectory (xsd/a.xsd) importing a SIBLING (b.xsd) must resolve relative to its + // OWN directory (xsd/), not the fixed archive root. Before the fix this looked for root/b.xsd and failed. + @Test + public void nestedSubdirCrossImportResolves() throws Exception { + File dir = tmp.newFolder("archive-" + System.nanoTime()); + write(new File(dir, "service.wsdl"), wsdlWithXsdImport("xsd/a.xsd")); + File xsd = new File(dir, "xsd"); + assertTrue(xsd.mkdirs()); + write(new File(xsd, "a.xsd"), schemaImporting("urn:d", "b.xsd")); + write(new File(xsd, "b.xsd"), leafSchema("urn:d")); + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + assertTrue(p.initPath(dir.getAbsolutePath())); + assertFalse(p.hasError()); + } + + // A "../"-ref from a subdirectory schema that lands back INSIDE the archive (xsd/a.xsd -> ../common/c.xsd) + // must resolve. Before the fix it was resolved against the archive root, escaped it, and was wrongly blocked. + @Test + public void dotDotRefWithinArchiveResolves() throws Exception { + File dir = tmp.newFolder("archive-" + System.nanoTime()); + write(new File(dir, "service.wsdl"), wsdlWithXsdImport("xsd/a.xsd")); + File xsd = new File(dir, "xsd"); + File common = new File(dir, "common"); + assertTrue(xsd.mkdirs()); + assertTrue(common.mkdirs()); + write(new File(xsd, "a.xsd"), schemaImporting("urn:d", "../common/c.xsd")); + write(new File(common, "c.xsd"), leafSchema("urn:d")); + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + assertTrue(p.initPath(dir.getAbsolutePath())); + assertFalse(p.hasError()); + } + + // A WSDL located in a subdirectory (wsdl/service.wsdl) importing a sibling schema (types.xsd) must + // resolve relative to the WSDL's own directory. Before the fix this looked for root/types.xsd and failed. + @Test + public void wsdlInSubdirSiblingResolves() throws Exception { + File dir = tmp.newFolder("archive-" + System.nanoTime()); + File wsdlDir = new File(dir, "wsdl"); + assertTrue(wsdlDir.mkdirs()); + write(new File(wsdlDir, "service.wsdl"), wsdlWithXsdImport("types.xsd")); + write(new File(wsdlDir, "types.xsd"), leafSchema("urn:c")); + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + assertTrue(p.initPath(dir.getAbsolutePath())); + assertFalse(p.hasError()); + } + + // A nested-subdir schema whose import escapes the archive root + // (xsd/a.xsd -> ../../../../etc/hostname) must STILL be blocked -> UNTRUSTED_URL, nothing outside read. + @Test + public void archiveEscapeStillBlocked() throws Exception { + File dir = tmp.newFolder("archive-" + System.nanoTime()); + write(new File(dir, "service.wsdl"), wsdlWithXsdImport("xsd/a.xsd")); + File xsd = new File(dir, "xsd"); + assertTrue(xsd.mkdirs()); + write(new File(xsd, "a.xsd"), schemaImporting("urn:d", "../../../../etc/hostname")); + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + boolean ok = p.initPath(dir.getAbsolutePath()); + assertFalse(ok); + assertTrue(p.hasError()); + assertEquals(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), p.getError().getErrorCode()); + } + + // A schema reference that resolves INSIDE the archive but is absent on disk is a GENUINE failure, not a + // policy block: it must map to CANNOT_PROCESS_WSDL_CONTENT (900676) and NOT escape initPath as a RuntimeException. + @Test + public void missingContainedSchemaMapsToCannotProcess() throws Exception { + File dir = extractedArchiveWith(wsdlWithXsdImport("missing.xsd")); + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + boolean ok = p.initPath(dir.getAbsolutePath()); // must NOT throw + assertFalse(ok); + assertTrue(p.hasError()); + assertEquals(ExceptionCodes.CANNOT_PROCESS_WSDL_CONTENT.getErrorCode(), p.getError().getErrorCode()); + } + + // A multi-WSDL archive where ONE file has a blocked (traversal) import and another is clean: the whole + // import is reported UNTRUSTED_URL, yet the clean file still parses (the blocked ref degrades to a stub). + @Test + public void multiFileArchiveOneFileBlocked() throws Exception { + File dir = tmp.newFolder("archive-" + System.nanoTime()); + write(new File(dir, "clean.wsdl"), wsdlWithXsdImport("types.xsd")); + write(new File(dir, "types.xsd"), leafSchema("urn:c")); + write(new File(dir, "evil.wsdl"), wsdlWithXsdImport("../../../../../../etc/hostname")); + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + boolean ok = p.initPath(dir.getAbsolutePath()); + assertFalse(ok); + assertTrue(p.hasError()); + assertEquals(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), p.getError().getErrorCode()); + // getWSDLDefinition() is protected but this test is in-package: proves at least one WSDL parsed. + assertNotNull(p.getWSDLDefinition()); + } + + // init(URL) on a file: WSDL whose xsd:import is a traversal ("../../..") must be blocked. + @Test + public void fileUrlTraversalBlocked() throws Exception { + File dir = tmp.newFolder("archive-" + System.nanoTime()); + write(new File(dir, "service.wsdl"), wsdlWithXsdImport("../../../../../../etc/hostname")); + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + p.init(new File(dir, "service.wsdl").toURI().toURL()); + assertTrue(p.hasError()); + assertEquals(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), p.getError().getErrorCode()); + } + + // init(URL) on a file: WSDL whose xsd:import is an ABSOLUTE local path ("/etc/hostname") must be + // blocked (LOCAL_ABSOLUTE classification). + @Test + public void fileUrlAbsoluteBlocked() throws Exception { + File dir = tmp.newFolder("archive-" + System.nanoTime()); + write(new File(dir, "service.wsdl"), wsdlWithXsdImport("/etc/hostname")); + WSDL11ProcessorImpl p = new WSDL11ProcessorImpl(); + p.init(new File(dir, "service.wsdl").toURI().toURL()); + assertTrue(p.hasError()); + assertEquals(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), p.getError().getErrorCode()); + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL11SOAPOperationExtractorAccessControlTest.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL11SOAPOperationExtractorAccessControlTest.java new file mode 100644 index 000000000000..c9267a5f602e --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL11SOAPOperationExtractorAccessControlTest.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.apimgt.impl.wsdl; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; +import org.wso2.carbon.apimgt.api.APIManagementException; +import org.wso2.carbon.apimgt.api.ExceptionCodes; +import org.wso2.carbon.apimgt.impl.utils.APIUtil; +import org.wso2.carbon.context.PrivilegedCarbonContext; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({APIUtil.class, PrivilegedCarbonContext.class}) +public class WSDL11SOAPOperationExtractorAccessControlTest { + + @Test + public void blockedNamespaceUrlIsValidatedAndPropagatesUntrustedUrl() throws Exception { + PowerMockito.mockStatic(PrivilegedCarbonContext.class); + PrivilegedCarbonContext ctx = Mockito.mock(PrivilegedCarbonContext.class); + PowerMockito.when(PrivilegedCarbonContext.getThreadLocalCarbonContext()).thenReturn(ctx); + Mockito.when(ctx.getTenantDomain()).thenReturn("carbon.super"); + + PowerMockito.mockStatic(APIUtil.class); + PowerMockito.doThrow(new APIManagementException("URL is not trusted", ExceptionCodes.UNTRUSTED_URL)) + .when(APIUtil.class); + APIUtil.validateRemoteURL("http://169.254.169.254/latest/meta-data/.xsd", "carbon.super"); + + WSDL11SOAPOperationExtractor extractor = new WSDL11SOAPOperationExtractor(); + Method getBasedXSDofWSDL = + WSDL11SOAPOperationExtractor.class.getDeclaredMethod("getBasedXSDofWSDL", String.class); + getBasedXSDofWSDL.setAccessible(true); + + try { + getBasedXSDofWSDL.invoke(extractor, "http://169.254.169.254/latest/meta-data/"); + fail("a blocked namespace URL must propagate UNTRUSTED_URL_IN_DEFINITION, not fetch"); + } catch (InvocationTargetException ite) { + assertTrue(ite.getCause() instanceof APIManagementException); + APIManagementException cause = (APIManagementException) ite.getCause(); + assertEquals("must carry UNTRUSTED_URL_IN_DEFINITION (900407)", + ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), cause.getErrorHandler().getErrorCode()); + } + + PowerMockito.verifyStatic(APIUtil.class); + APIUtil.validateRemoteURL("http://169.254.169.254/latest/meta-data/.xsd", "carbon.super"); + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL20ProcessorImplResolverTest.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL20ProcessorImplResolverTest.java new file mode 100644 index 000000000000..5299f3b76334 --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL20ProcessorImplResolverTest.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.wso2.carbon.apimgt.impl.wsdl; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; +import org.wso2.carbon.apimgt.api.APIManagementException; +import org.wso2.carbon.apimgt.api.ExceptionCodes; +import org.wso2.carbon.apimgt.impl.utils.APIUtil; +import org.wso2.carbon.context.PrivilegedCarbonContext; + +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({APIUtil.class, PrivilegedCarbonContext.class}) +public class WSDL20ProcessorImplResolverTest { + + private static final String WSDL20_WITH_BLOCKED_IMPORT = + "" + + "" + + " " + + " " + + " " + + " " + + " " + + ""; + + @Test + public void blockedNestedImportSurfacesUntrustedUrlError() throws Exception { + PowerMockito.mockStatic(PrivilegedCarbonContext.class); + PrivilegedCarbonContext ctx = Mockito.mock(PrivilegedCarbonContext.class); + PowerMockito.when(PrivilegedCarbonContext.getThreadLocalCarbonContext()).thenReturn(ctx); + Mockito.when(ctx.getTenantDomain()).thenReturn("carbon.super"); + + PowerMockito.mockStatic(APIUtil.class); + PowerMockito.doThrow(new APIManagementException("URL is not trusted", ExceptionCodes.UNTRUSTED_URL)) + .when(APIUtil.class); + APIUtil.validateRemoteURL(Mockito.anyString(), Mockito.anyString()); + + WSDL20ProcessorImpl processor = new WSDL20ProcessorImpl(); + processor.init(WSDL20_WITH_BLOCKED_IMPORT.getBytes(StandardCharsets.UTF_8)); + + // blocked import redirected to a local stub (no outbound fetch) AND reported to the user + assertTrue("a blocked nested import must be reported as an error", processor.hasError()); + assertEquals("must report UNTRUSTED_URL_IN_DEFINITION (900407)", + ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), processor.getError().getErrorCode()); + + // the policy gate was consulted for the nested import URL + PowerMockito.verifyStatic(APIUtil.class); + APIUtil.validateRemoteURL("http://169.254.169.254/meta/import.wsdl", "carbon.super"); + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL20SchemaImportNonReachableTest.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL20SchemaImportNonReachableTest.java new file mode 100644 index 000000000000..aa5991f4b3d7 --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDL20SchemaImportNonReachableTest.java @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.wso2.carbon.apimgt.impl.wsdl; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.wso2.carbon.apimgt.impl.APIManagerConfiguration; +import org.wso2.carbon.apimgt.impl.APIManagerConfigurationService; +import org.wso2.carbon.apimgt.impl.APIManagerConfigurationServiceImpl; +import org.wso2.carbon.apimgt.impl.config.APIMConfigService; +import org.wso2.carbon.apimgt.impl.internal.ServiceReferenceHolder; +import org.wso2.carbon.context.PrivilegedCarbonContext; + +import java.io.File; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; + +/** + * Regression + documentation test: proves that WSDL 2.0's nested {@code } / + * {@code } inside {@code } is NOT reachable through + * {@link WSDL20ProcessorImpl}, unlike the WSDL 1.1 path (covered/gated in + * {@link WSDL11ProcessorAccessControlIntegrationTest}). + *

+ * Why: {@link WSDL20ProcessorImpl#initPath(String)} (and the other entry points) parse the WSDL + * file into a DOM {@code Document} and then build Woden's {@code WSDLSource} from the raw DOM + * element via {@code wsdlSource.setSource(domElement)} -- {@code setBaseURI(...)} is never called + * (see {@code WSDL20ProcessorImpl#getWSDLSourceFromDocument}). With a {@code null} document base + * URI, Apache Woden aborts inline-schema parsing (WSDL521, "Missing base URI") before it ever + * walks the {@code } content to discover a nested {@code xsd:import}/{@code xsd:include}. + * Consequently, an untrusted nested {@code schemaLocation} is never dereferenced -- no + * outbound fetch happens, and no {@code AccessControlledUriResolver} gating is even needed for + * this vector. + *

+ * This is a genuinely different situation to WSDL 1.1: WSDL4J's WSDL 1.1 reader DID walk into + * inline schemas and fetch nested {@code xsd:import} locations, which is exactly the vector + * {@link AccessControlledWSDLLocator} was built to gate. For WSDL 2.0 there is nothing to gate + * for the nested-schema vector because Woden never reaches it; this test locks that in as a + * regression guard (if a future Woden/library upgrade starts resolving base URIs and reaching the + * nested import, this test will fail with a nonzero canary-hit count and must be re-evaluated). + *

+ * Absolute {@code wsdl:import}/{@code wsdl:include} elements (a different vector, resolved by + * Woden's own {@code URIResolver} before any DOM/base-URI concern) ARE reachable and remain gated + * by {@link AccessControlledUriResolver} -- see {@link WSDL20ProcessorImplResolverTest} -- and are + * unchanged/out of scope here. + */ +public class WSDL20SchemaImportNonReachableTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static final String XSD_BODY = + ""; + + // initPath() reads size-limit config via ServiceReferenceHolder and calls resolveTenantDomain(), which reads + // PrivilegedCarbonContext and throws outside a tenant flow -- needed even though the resolver is never reached. + private APIManagerConfigurationService previousConfigurationService; + private APIMConfigService previousApimConfigService; + + @Before + public void wireApiManagerConfigurationService() { + // Capture the process-wide services so they can be restored after the test, avoiding leaking the no-op mocks. + previousConfigurationService = ServiceReferenceHolder.getInstance().getAPIManagerConfigurationService(); + previousApimConfigService = ServiceReferenceHolder.getInstance().getApimConfigService(); + ServiceReferenceHolder.getInstance().setAPIManagerConfigurationService( + new APIManagerConfigurationServiceImpl(new APIManagerConfiguration())); + ServiceReferenceHolder.getInstance().setAPIMConfigService(new NoOpApimConfigService()); + PrivilegedCarbonContext.startTenantFlow(); + PrivilegedCarbonContext.getThreadLocalCarbonContext().setTenantId(-1234); // super tenant, no resolution + PrivilegedCarbonContext.getThreadLocalCarbonContext().setTenantDomain("carbon.super"); + } + + @After + public void endTenantFlow() { + PrivilegedCarbonContext.endTenantFlow(); + ServiceReferenceHolder.getInstance().setAPIManagerConfigurationService(previousConfigurationService); + ServiceReferenceHolder.getInstance().setAPIMConfigService(previousApimConfigService); + } + + /** Trivial no-op {@link APIMConfigService}: no tenant config stored for any organization. */ + private static final class NoOpApimConfigService implements APIMConfigService { + @Override + public void addExternalStoreConfig(String organization, String externalStoreConfig) { } + + @Override + public void updateExternalStoreConfig(String organization, String externalStoreConfig) { } + + @Override + public String getExternalStoreConfig(String organization) { + return null; + } + + @Override + public void addTenantConfig(String organization, String tenantConfig) { } + + @Override + public String getTenantConfig(String organization) { + return null; + } + + @Override + public void updateTenantConfig(String organization, String tenantConfig) { } + + @Override + public String getWorkFlowConfig(String organization) { + return null; + } + + @Override + public void updateWorkflowConfig(String organization, String workflowConfig) { } + + @Override + public void addWorkflowConfig(String organization, String workflowConfig) { } + + @Override + public String getGAConfig(String organization) { + return null; + } + + @Override + public void updateGAConfig(String organization, String gaConfig) { } + + @Override + public void addGAConfig(String organization, String gaConfig) { } + + @Override + public Object getSelfSighupConfig(String organization) { + return null; + } + + @Override + public void updateSelfSighupConfig(String organization, String selfSignUpConfig) { } + + @Override + public void addSelfSighupConfig(String organization, String selfSignUpConfig) { } + } + + // ---- fixture helpers ------------------------------------------------- + + /** + * A valid WSDL 2.0 document (namespace {@code http://www.w3.org/ns/wsdl}) whose {@code } + * contains an inline schema with a nested {@code xsd:import} at the given schemaLocation -- + * modelled on the earlier manual PoC fixture (scratch-wsdl-poc/arch/w20_http-import/service.wsdl). + */ + private static String wsdl20WithXsdImport(String schemaLocation) { + return "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + "\n"; + } + + private File extractedArchiveWith(String wsdlXml) throws IOException { + File dir = tmp.newFolder("archive-" + System.nanoTime()); + Files.write(new File(dir, "service.wsdl").toPath(), wsdlXml.getBytes(StandardCharsets.UTF_8)); + return dir; + } + + private HttpServer server(HttpHandler handler) throws IOException { + HttpServer httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + httpServer.createContext("/", handler); + httpServer.start(); + return httpServer; + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } + + private static int port(HttpServer httpServer) { + return httpServer.getAddress().getPort(); + } + + // ---- test ------------------------------------------------------- + + /** + * initPath: a WSDL 2.0 archive whose inline-schema {@code xsd:import} points at a canary HTTP + * server must NEVER cause that canary to be hit -- Woden aborts inline-schema parsing + * (WSDL521, missing base URI) before it discovers the nested import. It is fine (expected) for + * {@code initPath} to return {@code false} / set an error here; the only thing under test is + * that the nested schemaLocation is never dereferenced. + */ + @Test + public void wsdl20NestedSchemaImportNotReachable() throws Exception { + AtomicInteger hits = new AtomicInteger(); + HttpServer canary = server(ctx -> { + hits.incrementAndGet(); + respond(ctx, 200, XSD_BODY); + }); + File dir = extractedArchiveWith(wsdl20WithXsdImport("http://127.0.0.1:" + port(canary) + "/c.xsd")); + try { + WSDL20ProcessorImpl p = new WSDL20ProcessorImpl(); + p.initPath(dir.getAbsolutePath()); // may return false / set an error (WSDL521) -- that's fine + assertEquals("WSDL 2.0 nested xsd:import must not be fetched (null base URI, " + + "Woden aborts inline-schema parsing before discovering it)", 0, hits.get()); + } finally { + canary.stop(0); + } + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDLSOAPOperationExtractorImplTestCase.java b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDLSOAPOperationExtractorImplTestCase.java index 81bd97b6be2c..a95799f78398 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDLSOAPOperationExtractorImplTestCase.java +++ b/components/apimgt/org.wso2.carbon.apimgt.impl/src/test/java/org/wso2/carbon/apimgt/impl/wsdl/WSDLSOAPOperationExtractorImplTestCase.java @@ -21,19 +21,27 @@ import io.swagger.models.properties.ArrayProperty; import io.swagger.models.properties.ObjectProperty; import io.swagger.models.properties.Property; +import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.Mockito; import org.powermock.core.classloader.annotations.PrepareForTest; import org.powermock.modules.junit4.PowerMockRunner; import org.wso2.carbon.apimgt.api.model.API; import org.wso2.carbon.apimgt.api.model.APIIdentifier; +import org.wso2.carbon.apimgt.impl.APIManagerConfiguration; +import org.wso2.carbon.apimgt.impl.APIManagerConfigurationService; +import org.wso2.carbon.apimgt.impl.APIManagerConfigurationServiceImpl; +import org.wso2.carbon.apimgt.impl.config.APIMConfigService; import org.wso2.carbon.apimgt.impl.internal.ServiceReferenceHolder; import org.wso2.carbon.apimgt.impl.utils.APIUtilTest; import org.wso2.carbon.apimgt.impl.wsdl.model.WSDLSOAPOperation; import org.wso2.carbon.apimgt.impl.utils.APIMWSDLReader; import org.wso2.carbon.apimgt.impl.wsdl.util.SOAPOperationBindingUtils; +import org.wso2.carbon.base.MultitenantConstants; +import org.wso2.carbon.context.PrivilegedCarbonContext; import java.util.List; import java.util.Map; @@ -46,8 +54,26 @@ public class WSDLSOAPOperationExtractorImplTestCase { private static Set operations; + private APIManagerConfigurationService previousConfigurationService; + private APIMConfigService previousApimConfigService; + @Before public void setup() throws Exception { + System.setProperty("carbon.home", WSDLSOAPOperationExtractorImplTestCase.class.getResource("/").getFile()); + // Building the Swagger model resolves namespace-derived schemas through APIUtil.validateRemoteURL, which + // consults the tenant configuration. Establish a super-tenant CarbonContext and empty configuration so that + // lookup resolves to a no-op (no policy configured) instead of failing when the test runs outside a tenant flow. + // Capture the process-wide services so they can be restored after the test, avoiding leaking the mocks. + previousConfigurationService = ServiceReferenceHolder.getInstance().getAPIManagerConfigurationService(); + previousApimConfigService = ServiceReferenceHolder.getInstance().getApimConfigService(); + ServiceReferenceHolder.getInstance().setAPIManagerConfigurationService( + new APIManagerConfigurationServiceImpl(new APIManagerConfiguration())); + ServiceReferenceHolder.getInstance().setAPIMConfigService(Mockito.mock(APIMConfigService.class)); + PrivilegedCarbonContext.startTenantFlow(); + PrivilegedCarbonContext.getThreadLocalCarbonContext().setTenantId(MultitenantConstants.SUPER_TENANT_ID); + PrivilegedCarbonContext.getThreadLocalCarbonContext() + .setTenantDomain(MultitenantConstants.SUPER_TENANT_DOMAIN_NAME); + APIMWSDLReader wsdlReader = new APIMWSDLReader(Thread.currentThread().getContextClassLoader() .getResource("wsdls/phoneverify.wsdl").toExternalForm()); byte[] wsdlContent = wsdlReader.getWSDL(); @@ -55,7 +81,13 @@ public void setup() throws Exception { wsdlReader); operations = processor.getWsdlInfo().getSoapBindingOperations(); - System.setProperty("carbon.home", WSDLSOAPOperationExtractorImplTestCase.class.getResource("/").getFile()); + } + + @After + public void endTenantFlow() { + PrivilegedCarbonContext.endTenantFlow(); + ServiceReferenceHolder.getInstance().setAPIManagerConfigurationService(previousConfigurationService); + ServiceReferenceHolder.getInstance().setAPIMConfigService(previousApimConfigService); } @Test public void testGetWsdlDefinition() throws Exception { diff --git a/components/apimgt/org.wso2.carbon.apimgt.rest.api.admin.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/admin/v1/impl/KeyManagersApiServiceImpl.java b/components/apimgt/org.wso2.carbon.apimgt.rest.api.admin.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/admin/v1/impl/KeyManagersApiServiceImpl.java index 0f1bc6634b5e..8c16a1948ec5 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.rest.api.admin.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/admin/v1/impl/KeyManagersApiServiceImpl.java +++ b/components/apimgt/org.wso2.carbon.apimgt.rest.api.admin.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/admin/v1/impl/KeyManagersApiServiceImpl.java @@ -26,7 +26,9 @@ import org.wso2.carbon.apimgt.impl.utils.APIUtil; import org.wso2.carbon.apimgt.persistence.dto.AdminContentSearchResult; import org.wso2.carbon.apimgt.rest.api.admin.v1.KeyManagersApiService; +import org.wso2.carbon.apimgt.rest.api.admin.v1.dto.KeyManagerCertificatesDTO; import org.wso2.carbon.apimgt.rest.api.admin.v1.dto.KeyManagerDTO; +import org.wso2.carbon.apimgt.rest.api.admin.v1.dto.KeyManagerEndpointDTO; import org.wso2.carbon.apimgt.rest.api.admin.v1.dto.KeyManagerListDTO; import org.wso2.carbon.apimgt.rest.api.admin.v1.dto.KeyManagerWellKnownResponseDTO; import org.wso2.carbon.apimgt.rest.api.admin.v1.utils.RestApiAdminUtils; @@ -39,6 +41,7 @@ import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import javax.ws.rs.core.Response; @@ -51,6 +54,7 @@ public class KeyManagersApiServiceImpl implements KeyManagersApiService { public Response keyManagersDiscoverPost(String url, String type, MessageContext messageContext) throws APIManagementException { if (StringUtils.isNotEmpty(url)) { + APIUtil.validateRemoteURL(url, RestApiCommonUtil.getLoggedInUserTenantDomain()); Gson gson = new GsonBuilder().serializeNulls().create(); OpenIDConnectDiscoveryClient openIDConnectDiscoveryClient = Feign.builder().client(new ApacheFeignHttpClient(APIUtil.getHttpClient(url))) @@ -140,6 +144,7 @@ public Response keyManagersKeyManagerIdPut(String keyManagerId, KeyManagerDTO bo body.setAllowedOrganizations(allowedOrgs); } } + validateKeyManagerURLs(body); try { KeyManagerConfigurationDTO keyManagerConfigurationDTO = KeyManagerMappingUtil.toKeyManagerConfigurationDTO(organization, body); @@ -223,6 +228,7 @@ public Response keyManagersPost(KeyManagerDTO body, MessageContext messageContex String organization = RestApiUtil.getOrganization(messageContext); APIAdmin apiAdmin = new APIAdminImpl(); try { + validateKeyManagerURLs(body); KeyManagerConfigurationDTO keyManagerConfigurationDTO = KeyManagerMappingUtil.toKeyManagerConfigurationDTO(organization, body); KeyManagerPermissionConfigurationDTO keyManagerPermissionConfigurationDTO = @@ -265,4 +271,114 @@ public void validatePermissions(KeyManagerPermissionConfigurationDTO permissionD } } + /** + * Validates all outbound URLs defined in the given Key Manager configuration against + * network security access control policies. Blank and non-URL values are silently skipped. + * If a URL fails validation with a client error (HTTP 400), a field-specific bad request + * is returned. Internal errors are propagated unchanged. + * + * @param body Key Manager configuration containing URLs to validate + * @throws APIManagementException if URL validation fails + */ + private void validateKeyManagerURLs(KeyManagerDTO body) throws APIManagementException { + Map urlFields = new LinkedHashMap<>(); + urlFields.put("well-known endpoint", body.getWellKnownEndpoint()); + urlFields.put("token endpoint", body.getTokenEndpoint()); + urlFields.put("introspection endpoint", body.getIntrospectionEndpoint()); + urlFields.put("client registration endpoint", body.getClientRegistrationEndpoint()); + urlFields.put("revoke endpoint", body.getRevokeEndpoint()); + urlFields.put("user info endpoint", body.getUserInfoEndpoint()); + urlFields.put("authorize endpoint", body.getAuthorizeEndpoint()); + urlFields.put("scope management endpoint", body.getScopeManagementEndpoint()); + + for (Map.Entry entry : urlFields.entrySet()) { + validateKeyManagerURLOrBadRequest(entry.getValue(), entry.getKey()); + } + if (body.getEndpoints() != null) { + for (KeyManagerEndpointDTO endpoint : body.getEndpoints()) { + if (endpoint != null) { + validateKeyManagerURLOrBadRequest(endpoint.getValue(), + "custom endpoint '" + endpoint.getName() + "'"); + } + } + } + if (body.getCertificates() != null + && KeyManagerCertificatesDTO.TypeEnum.JWKS.equals(body.getCertificates().getType())) { + validateKeyManagerURLOrBadRequest(body.getCertificates().getValue(), "JWKS endpoint"); + } + } + + /** + * Validates a single Key Manager URL and translates a client error (HTTP 400) into a bad request response. + * Failures that are not client errors are propagated unchanged. + * + * @param url URL to validate; blank and non-URL values are silently skipped + * @param fieldName descriptive name of the Key Manager URL field being validated + * @throws APIManagementException if URL validation fails with a non-client error + */ + private void validateKeyManagerURLOrBadRequest(String url, String fieldName) throws APIManagementException { + try { + validateKeyManagerURL(url, fieldName); + } catch (APIManagementException e) { + if (e.getErrorHandler() != null && e.getErrorHandler().getHttpStatusCode() == 400) { + log.warn(e.getMessage(), e); + RestApiUtil.handleBadRequest(e.getMessage()); + } else { + throw e; + } + } + } + + /** + * Validates a single Key Manager endpoint URL against network security access control policies. + * Blank and non-URL values (e.g. "none") are silently skipped for backward compatibility. + * If validation fails with a client error (HTTP 400), the exception is re-thrown with a + * field-specific message. Other failures are propagated unchanged. + * + * @param url URL to validate; blank and non-URL values are silently skipped + * @param fieldName descriptive name of the Key Manager URL field being validated + * @throws APIManagementException if the URL is blocked by a host validation policy + */ + private void validateKeyManagerURL(String url, String fieldName) + throws APIManagementException { + if (StringUtils.isBlank(url)) { + return; + } + URI parsedUrl; + try { + parsedUrl = new URI(url); + } catch (URISyntaxException e) { + return; // not a URI, skip validation + } + // Only an absolute URL (scheme + host) is outbound-fetchable. Non-URL sentinels such as "none" and relative + // values are not, so skip them for backward compatibility instead of failing them as malformed. + if (parsedUrl.getScheme() == null || StringUtils.isBlank(parsedUrl.getHost())) { + return; + } + try { + APIUtil.validateRemoteURL(url, RestApiCommonUtil.getLoggedInUserTenantDomain()); + } catch (APIManagementException e) { + throw toKeyManagerUrlError(e, fieldName); + } + } + + /** + * Maps a Key Manager URL validation failure to the exception to surface. Only a policy block (UNTRUSTED_URL) means + * the URL is untrusted, so that is re-thrown with a field-specific message; any other error (e.g. a malformed URL) + * is propagated unchanged so its message stays accurate. + * + * @param e the validation failure raised by {@code validateRemoteURL} + * @param fieldName descriptive name of the Key Manager URL field being validated + * @return the exception to throw + */ + private APIManagementException toKeyManagerUrlError(APIManagementException e, String fieldName) { + if (e.getErrorHandler() != null + && e.getErrorHandler().getErrorCode() == ExceptionCodes.UNTRUSTED_URL.getErrorCode()) { + return new APIManagementException( + "Invalid Key Manager URL configuration. The " + fieldName + + " URL could not be resolved.", + e.getErrorHandler()); + } + return e; + } } diff --git a/components/apimgt/org.wso2.carbon.apimgt.rest.api.admin.v1/src/test/java/org/wso2/carbon/apimgt/rest/api/admin/v1/impl/KeyManagersApiServiceImplUrlValidationTest.java b/components/apimgt/org.wso2.carbon.apimgt.rest.api.admin.v1/src/test/java/org/wso2/carbon/apimgt/rest/api/admin/v1/impl/KeyManagersApiServiceImplUrlValidationTest.java new file mode 100644 index 000000000000..579061285ca6 --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.rest.api.admin.v1/src/test/java/org/wso2/carbon/apimgt/rest/api/admin/v1/impl/KeyManagersApiServiceImplUrlValidationTest.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.apimgt.rest.api.admin.v1.impl; + +import org.junit.Assert; +import org.junit.Test; +import org.wso2.carbon.apimgt.api.APIManagementException; +import org.wso2.carbon.apimgt.api.ExceptionCodes; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * Unit tests for {@link KeyManagersApiServiceImpl}'s Key Manager URL validation. Tests exercise the pre-check (which + * skips non-URL values before any outbound validation) and the error mapping directly, so no outbound-validation + * infrastructure is loaded. + */ +public class KeyManagersApiServiceImplUrlValidationTest { + + private final KeyManagersApiServiceImpl keyManagersApiService = new KeyManagersApiServiceImpl(); + + private void validateKeyManagerURL(String url, String fieldName) throws Throwable { + Method method = KeyManagersApiServiceImpl.class.getDeclaredMethod( + "validateKeyManagerURL", String.class, String.class); + method.setAccessible(true); + try { + method.invoke(keyManagersApiService, url, fieldName); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + private APIManagementException toKeyManagerUrlError(APIManagementException e, String fieldName) throws Exception { + Method method = KeyManagersApiServiceImpl.class.getDeclaredMethod( + "toKeyManagerUrlError", APIManagementException.class, String.class); + method.setAccessible(true); + return (APIManagementException) method.invoke(keyManagersApiService, e, fieldName); + } + + @Test + public void testNonUrlAndBlankValuesAreSkipped() throws Throwable { + // Non-absolute values (sentinels like "none", relative paths) and blanks must be skipped by the pre-check + // before any outbound validation - they never reach validateRemoteURL. If the pre-check let them through they + // would be rejected as malformed, breaking backward-compatible Key Manager configurations. + validateKeyManagerURL("none", "token endpoint"); + validateKeyManagerURL("disabled", "revoke endpoint"); + validateKeyManagerURL("some/relative/path", "userinfo endpoint"); + validateKeyManagerURL("", "introspection endpoint"); + validateKeyManagerURL(null, "JWKS endpoint"); + } + + @Test + public void testUntrustedErrorIsMappedToFieldSpecificMessage() throws Exception { + APIManagementException blocked = new APIManagementException("Outbound request blocked", + ExceptionCodes.UNTRUSTED_URL); + + APIManagementException mapped = toKeyManagerUrlError(blocked, "token endpoint"); + + Assert.assertEquals("An untrusted URL must keep the UNTRUSTED_URL code", + ExceptionCodes.UNTRUSTED_URL.getErrorCode(), mapped.getErrorHandler().getErrorCode()); + Assert.assertTrue("A blocked URL must surface the field-specific 'could not be resolved' message", + mapped.getMessage().contains("token endpoint") + && mapped.getMessage().contains("URL could not be resolved")); + } + + @Test + public void testMalformedErrorIsPropagatedUnchanged() throws Exception { + // A non-untrusted 400 (e.g. MALFORMED_URL) must not be re-labelled as "could not be resolved". + APIManagementException malformed = new APIManagementException("Malformed URL", ExceptionCodes.MALFORMED_URL); + + APIManagementException mapped = toKeyManagerUrlError(malformed, "token endpoint"); + + Assert.assertSame("A non-untrusted error must be propagated unchanged", malformed, mapped); + Assert.assertFalse("A malformed URL must not be re-labelled as 'could not be resolved'", + mapped.getMessage().contains("could not be resolved")); + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1.common/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/common/mappings/ImportUtils.java b/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1.common/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/common/mappings/ImportUtils.java index 7739a456a615..00af01c441ed 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1.common/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/common/mappings/ImportUtils.java +++ b/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1.common/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/common/mappings/ImportUtils.java @@ -61,6 +61,7 @@ import org.wso2.carbon.apimgt.api.model.Documentation; import org.wso2.carbon.apimgt.api.model.Environment; import org.wso2.carbon.apimgt.api.model.Identifier; +import org.wso2.carbon.apimgt.api.model.OASParserOptions; import org.wso2.carbon.apimgt.api.model.OperationPolicy; import org.wso2.carbon.apimgt.api.model.OperationPolicyData; import org.wso2.carbon.apimgt.api.model.OperationPolicyDefinition; @@ -341,6 +342,25 @@ public static ImportedAPIDTO importApi(String extractedFolderPath, APIDTO import // Get the endpoint config object updated APIUtil.validateAPIEndpointConfig(importedApiDTO.getEndpointConfig(), importedApiDTO.getType().toString(), importedApiDTO.getName()); + if (importedApiDTO.getEndpointConfig() instanceof Map) { + org.json.JSONObject endpointConfigObj = + new org.json.JSONObject((Map) importedApiDTO.getEndpointConfig()); + if (!APIConstants.ENDPOINT_TYPE_DEFAULT.equalsIgnoreCase( + endpointConfigObj.optString(APIConstants.API_ENDPOINT_CONFIG_PROTOCOL_TYPE))) { + ArrayList endpointURLs = new ArrayList<>(); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, + APIConstants.API_DATA_PRODUCTION_ENDPOINTS, endpointURLs); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, + APIConstants.API_DATA_SANDBOX_ENDPOINTS, endpointURLs); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, + APIConstants.ENDPOINT_PRODUCTION_FAILOVERS, endpointURLs); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, + APIConstants.ENDPOINT_SANDBOX_FAILOVERS, endpointURLs); + for (String endpointURL : endpointURLs) { + APIUtil.validateRemoteURL(endpointURL, tenantDomain); + } + } + } API targetApi = retrieveApiToOverwrite(importedApiDTO.getName(), importedApiDTO.getVersion(), currentTenantDomain, apiProvider, Boolean.TRUE, organization); @@ -650,7 +670,7 @@ public static ImportedAPIDTO importApi(String extractedFolderPath, APIDTO import throw new APIManagementException("Error while importing API: " + e.getMessage(), ExceptionCodes.from(ExceptionCodes.API_CONTEXT_MALFORMED_EXCEPTION, e.getMessage())); } - throw new APIManagementException(errorMessage + StringUtils.SPACE + e.getMessage(), e); + throw new APIManagementException(errorMessage + StringUtils.SPACE + e.getMessage(), e, e.getErrorHandler()); } } @@ -825,6 +845,23 @@ public static ImportedAPIDTO importMCPServer(String extractedFolderPath, MCPServ } Backend oldBackend = existingBackends.get(0); Backend importedBackend = importedBackends.get(0); + org.json.JSONObject importedConfig = + new org.json.JSONObject(importedBackend.getEndpointConfig()); + if (!APIConstants.ENDPOINT_TYPE_DEFAULT.equalsIgnoreCase( + importedConfig.optString(APIConstants.API_ENDPOINT_CONFIG_PROTOCOL_TYPE))) { + ArrayList endpointURLs = new ArrayList<>(); + APIUtil.extractURLsFromEndpointConfig(importedConfig, + APIConstants.API_DATA_PRODUCTION_ENDPOINTS, endpointURLs); + APIUtil.extractURLsFromEndpointConfig(importedConfig, + APIConstants.API_DATA_SANDBOX_ENDPOINTS, endpointURLs); + APIUtil.extractURLsFromEndpointConfig(importedConfig, + APIConstants.ENDPOINT_PRODUCTION_FAILOVERS, endpointURLs); + APIUtil.extractURLsFromEndpointConfig(importedConfig, + APIConstants.ENDPOINT_SANDBOX_FAILOVERS, endpointURLs); + for (String endpointURL : endpointURLs) { + APIUtil.validateRemoteURL(endpointURL, tenantDomain); + } + } Backend backend = new Backend(oldBackend); backend.setEndpointConfig(importedBackend.getEndpointConfig()); String importedDefinition = importedBackend.getDefinition(); @@ -885,6 +922,22 @@ public static ImportedAPIDTO importMCPServer(String extractedFolderPath, MCPServ final JSONObject endpointObject = (JSONObject) new JSONParser().parse(backend.getEndpointConfig()); + org.json.JSONObject endpointConfigObj = new org.json.JSONObject((Map) endpointObject); + if (!APIConstants.ENDPOINT_TYPE_DEFAULT.equalsIgnoreCase( + endpointConfigObj.optString(APIConstants.API_ENDPOINT_CONFIG_PROTOCOL_TYPE))) { + ArrayList endpointURLs = new ArrayList<>(); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, + APIConstants.API_DATA_PRODUCTION_ENDPOINTS, endpointURLs); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, + APIConstants.API_DATA_SANDBOX_ENDPOINTS, endpointURLs); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, + APIConstants.ENDPOINT_PRODUCTION_FAILOVERS, endpointURLs); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, + APIConstants.ENDPOINT_SANDBOX_FAILOVERS, endpointURLs); + for (String endpointURL : endpointURLs) { + APIUtil.validateRemoteURL(endpointURL, tenantDomain); + } + } final Map endpointConfigMap = (Map) endpointObject; @@ -2772,9 +2825,12 @@ public static APIDefinitionValidationResponse retrieveValidatedSwaggerDefinition public static APIDefinitionValidationResponse retrieveValidatedSwaggerDefinition(String swaggerContent) throws APIManagementException { + OASParserOptions baseParserOptions = ServiceReferenceHolder.getInstance() + .getAPIMDependencyConfigurationService().getAPIMDependencyConfigurations().getOasParserOptions(); + OASParserOptions parserOptions = APIUtil.buildRefResolutionOptions(baseParserOptions, + RestApiCommonUtil.getLoggedInUserTenantDomain()); APIDefinitionValidationResponse validationResponse = OASParserUtil.validateAPIDefinition(swaggerContent, - Boolean.TRUE, ServiceReferenceHolder.getInstance().getAPIMDependencyConfigurationService() - .getAPIMDependencyConfigurations().getOasParserOptions()); + Boolean.TRUE, parserOptions); if (!validationResponse.isValid()) { String errorDescription = ""; if (validationResponse.getErrorItems().size() > 0) { diff --git a/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1.common/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/common/mappings/PublisherCommonUtils.java b/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1.common/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/common/mappings/PublisherCommonUtils.java index c275e00c7a1a..e9fb9750d9b0 100755 --- a/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1.common/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/common/mappings/PublisherCommonUtils.java +++ b/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1.common/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/common/mappings/PublisherCommonUtils.java @@ -2775,6 +2775,15 @@ private static boolean validateEndpoints(Map endpointConfigMap, if (externalExtractor != null) { externalExtractor.accept(endpoints); } + extractURLsFromEndpointConfig(endpointConfigObj, APIConstants.ENDPOINT_PRODUCTION_FAILOVERS, endpoints); + extractURLsFromEndpointConfig(endpointConfigObj, APIConstants.ENDPOINT_SANDBOX_FAILOVERS, endpoints); + String tenantDomain = RestApiCommonUtil.getLoggedInUserTenantDomain(); + for (String endpoint : endpoints) { + if (!endpoint.startsWith("jms:") && !endpoint.startsWith("consul(") + && !endpoint.contains("{") && !endpoint.contains("}")) { + APIUtil.validateRemoteURL(endpoint, tenantDomain); + } + } return APIUtil.validateEndpointURLs(endpoints); } @@ -2802,9 +2811,30 @@ private static void extractURLsFromEndpointConfig(org.json.JSONObject endpointCo errorHandler); } } else { - org.json.JSONArray endpointArray = endpointConfigObj.getJSONArray(endpointType); - for (int i = 0; i < endpointArray.length(); i++) { - endpoints.add((String) endpointArray.getJSONObject(i).get(APIConstants.API_DATA_URL)); + org.json.JSONArray endpointArray = endpointConfigObj.optJSONArray(endpointType); + if (endpointArray != null && endpointArray.length() > 0) { + boolean urlFound = false; + for (int i = 0; i < endpointArray.length(); i++) { + // Skip malformed (non-object) entries instead of failing the request. + org.json.JSONObject endpointEntry = endpointArray.optJSONObject(i); + if (endpointEntry == null) { + continue; + } + String url = endpointEntry.optString(APIConstants.API_DATA_URL, null); + if (StringUtils.isNotBlank(url)) { + endpoints.add(url); + urlFound = true; + } + } + if (!urlFound) { + // A populated endpoint array with no usable URL is a client error for this endpoint type. + ErrorHandler errorHandler = ExceptionCodes.from(ExceptionCodes.ENDPOINT_URL_NOT_PROVIDED, + endpointType); + throw new APIManagementException( + "Url is not provided for the endpoint type: " + endpointType + " in the endpoint " + + "config", + errorHandler); + } } } } @@ -4613,6 +4643,10 @@ public static APIEndpointDTO updateAPIEndpoint(String apiId, String endpointId, throw new APIManagementException("Invalid/Malformed endpoint URL detected", ExceptionCodes.API_ENDPOINT_URL_INVALID); } + if (!endpointURL.startsWith("jms:") && !endpointURL.startsWith("consul(") + && !endpointURL.contains("{") && !endpointURL.contains("}")) { + APIUtil.validateRemoteURL(endpointURL, RestApiCommonUtil.getLoggedInUserTenantDomain()); + } APIEndpointInfo apiEndpointUpdated = apiProvider.updateAPIEndpoint(apiId, apiEndpoint, organization); if (apiEndpointUpdated == null) { @@ -4662,6 +4696,10 @@ public static String addAPIEndpoint(String apiId, APIEndpointDTO apiEndpointDTO, throw new APIManagementException("Invalid/Malformed endpoint URL detected", ExceptionCodes.API_ENDPOINT_URL_INVALID); } + if (!endpointURL.startsWith("jms:") && !endpointURL.startsWith("consul(") + && !endpointURL.contains("{") && !endpointURL.contains("}")) { + APIUtil.validateRemoteURL(endpointURL, RestApiCommonUtil.getLoggedInUserTenantDomain()); + } // validate endpoint name if (StringUtils.isBlank(apiEndpoint.getName())) { @@ -5194,6 +5232,7 @@ public static MCPServerValidationResponseDTO validateMCPServer(String serverUrl, final String authHeader = securityInfo != null ? securityInfo.getHeader() : null; final String authValue = securityInfo != null ? securityInfo.getValue() : null; + APIUtil.validateRemoteURL(serverUrl, RestApiCommonUtil.getLoggedInUserTenantDomain()); MCPInitializerAndToolFetcher fetcher = new MCPInitializerAndToolFetcher(serverUrl, authHeader, authValue, secureRequested); diff --git a/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1.common/src/test/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/common/mappings/PublisherCommonUtilsTest.java b/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1.common/src/test/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/common/mappings/PublisherCommonUtilsTest.java index 526ac61c1967..020589ef7836 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1.common/src/test/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/common/mappings/PublisherCommonUtilsTest.java +++ b/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1.common/src/test/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/common/mappings/PublisherCommonUtilsTest.java @@ -30,6 +30,7 @@ import org.powermock.reflect.Whitebox; import org.wso2.carbon.apimgt.api.APIManagementException; import org.wso2.carbon.apimgt.api.APIProvider; +import org.wso2.carbon.apimgt.api.ExceptionCodes; import org.wso2.carbon.apimgt.api.FaultGatewaysException; import org.wso2.carbon.apimgt.api.doc.model.APIResource; import org.wso2.carbon.apimgt.api.model.API; @@ -185,6 +186,8 @@ public void testValidateValidEndpoints() throws APIManagementException { Mockito.when(apiDto.getEndpointConfig()).thenReturn(endpointConfig); Mockito.when(advertiseInfoDto.isAdvertised()).thenReturn(false); + PowerMockito.mockStatic(RestApiCommonUtil.class); + PowerMockito.when(RestApiCommonUtil.getLoggedInUserTenantDomain()).thenReturn(ORGANIZATION); PowerMockito.mockStatic(APIUtil.class); PowerMockito.when(APIUtil.validateEndpointURLs(endpoints)).thenReturn(true); Assert.assertTrue(PublisherCommonUtils.validateEndpoints(new APIDTOTypeWrapper(apiDto))); @@ -216,6 +219,8 @@ public void testValidateInvalidProductionEndpoint() throws APIManagementExceptio Mockito.when(apiDto.getEndpointConfig()).thenReturn(endpointConfig); Mockito.when(advertiseInfoDto.isAdvertised()).thenReturn(false); + PowerMockito.mockStatic(RestApiCommonUtil.class); + PowerMockito.when(RestApiCommonUtil.getLoggedInUserTenantDomain()).thenReturn(ORGANIZATION); PowerMockito.mockStatic(APIUtil.class); PowerMockito.when(APIUtil.validateEndpointURLs(endpoints)).thenReturn(false); Assert.assertFalse(PublisherCommonUtils.validateEndpoints(new APIDTOTypeWrapper(apiDto))); @@ -247,6 +252,8 @@ public void testValidateInvalidSandboxEndpoint() throws APIManagementException { Mockito.when(apiDto.getEndpointConfig()).thenReturn(endpointConfig); Mockito.when(advertiseInfoDto.isAdvertised()).thenReturn(false); + PowerMockito.mockStatic(RestApiCommonUtil.class); + PowerMockito.when(RestApiCommonUtil.getLoggedInUserTenantDomain()).thenReturn(ORGANIZATION); PowerMockito.mockStatic(APIUtil.class); PowerMockito.when(APIUtil.validateEndpointURLs(endpoints)).thenReturn(false); Assert.assertFalse(PublisherCommonUtils.validateEndpoints(new APIDTOTypeWrapper(apiDto))); @@ -288,6 +295,8 @@ public void testValidateValidExternalEndpoints() throws APIManagementException { Mockito.when(advertiseInfoDto.getApiExternalProductionEndpoint()).thenReturn(externalProductionEndpointString); Mockito.when(advertiseInfoDto.getApiExternalSandboxEndpoint()).thenReturn(externalSandboxEndpointString); Mockito.when(advertiseInfoDto.getOriginalDevPortalUrl()).thenReturn(originalDevPortalUrl); + PowerMockito.mockStatic(RestApiCommonUtil.class); + PowerMockito.when(RestApiCommonUtil.getLoggedInUserTenantDomain()).thenReturn(ORGANIZATION); PowerMockito.mockStatic(APIUtil.class); PowerMockito.when(APIUtil.validateEndpointURLs(endpoints)).thenReturn(true); Assert.assertTrue(PublisherCommonUtils.validateEndpoints(new APIDTOTypeWrapper(apiDto))); @@ -329,6 +338,8 @@ public void testValidateInvalidExternalEndpoints() throws APIManagementException Mockito.when(advertiseInfoDto.getApiExternalProductionEndpoint()).thenReturn(externalProductionEndpointString); Mockito.when(advertiseInfoDto.getApiExternalSandboxEndpoint()).thenReturn(externalSandboxEndpointString); Mockito.when(advertiseInfoDto.getOriginalDevPortalUrl()).thenReturn(originalDevPortalUrl); + PowerMockito.mockStatic(RestApiCommonUtil.class); + PowerMockito.when(RestApiCommonUtil.getLoggedInUserTenantDomain()).thenReturn(ORGANIZATION); PowerMockito.mockStatic(APIUtil.class); PowerMockito.when(APIUtil.validateEndpointURLs(endpoints)).thenReturn(false); Assert.assertFalse(PublisherCommonUtils.validateEndpoints(new APIDTOTypeWrapper(apiDto))); @@ -379,6 +390,8 @@ public void testValidateEndpointsNullAdvertiseInfo() throws APIManagementExcepti Mockito.when(apiDto.getEndpointConfig()).thenReturn(endpointConfig); Mockito.when(apiDto.getAdvertiseInfo()).thenReturn(null); + PowerMockito.mockStatic(RestApiCommonUtil.class); + PowerMockito.when(RestApiCommonUtil.getLoggedInUserTenantDomain()).thenReturn(ORGANIZATION); PowerMockito.mockStatic(APIUtil.class); PowerMockito.when(APIUtil.validateEndpointURLs(endpoints)).thenReturn(true); Assert.assertTrue(PublisherCommonUtils.validateEndpoints(new APIDTOTypeWrapper(apiDto))); @@ -418,6 +431,8 @@ public void testValidateEndpointsNullExternalEndpoint() throws APIManagementExce Mockito.when(advertiseInfoDto.getApiExternalProductionEndpoint()).thenReturn(null); Mockito.when(advertiseInfoDto.getApiExternalSandboxEndpoint()).thenReturn(externalSandboxEndpointString); Mockito.when(advertiseInfoDto.getOriginalDevPortalUrl()).thenReturn(originalDevPortalUrl); + PowerMockito.mockStatic(RestApiCommonUtil.class); + PowerMockito.when(RestApiCommonUtil.getLoggedInUserTenantDomain()).thenReturn(ORGANIZATION); PowerMockito.mockStatic(APIUtil.class); PowerMockito.when(APIUtil.validateEndpointURLs(endpoints)).thenReturn(true); Assert.assertTrue(PublisherCommonUtils.validateEndpoints(new APIDTOTypeWrapper(apiDto))); @@ -787,4 +802,48 @@ private APIDTO createAPIDTOWithOperations(String... verbAndPaths) { apiDto.setOperations(operations); return apiDto; } + + @Test + public void testValidateEndpointsRejectsArrayWithNoUsableUrl() throws APIManagementException { + // A populated endpoint array whose entries carry no usable URL must raise the endpoint-specific error, + // not silently produce an empty endpoint list. + APIDTO apiDto = Mockito.mock(APIDTO.class); + HashMap endpointConfig = new HashMap<>(); + endpointConfig.put(API_ENDPOINT_CONFIG_PROTOCOL_TYPE, "http"); + List productionEndpoints = new ArrayList<>(); + productionEndpoints.add("not-an-object"); // non-object entry + productionEndpoints.add(new HashMap()); // object without a url + endpointConfig.put(API_DATA_PRODUCTION_ENDPOINTS, productionEndpoints); + Mockito.when(apiDto.getEndpointConfig()).thenReturn(endpointConfig); + + try { + PublisherCommonUtils.validateEndpoints(new APIDTOTypeWrapper(apiDto)); + fail("Expected ENDPOINT_URL_NOT_PROVIDED for an endpoint array with no usable URL"); + } catch (APIManagementException e) { + Assert.assertEquals(ExceptionCodes.ENDPOINT_URL_NOT_PROVIDED.getErrorCode(), + e.getErrorHandler().getErrorCode()); + } + } + + @Test + public void testValidateEndpointsAcceptsMixedArrayWithOneUsableUrl() throws APIManagementException { + // A mixed array must still validate: the usable URL is kept and the malformed entry is skipped. + APIDTO apiDto = Mockito.mock(APIDTO.class); + HashMap endpointConfig = new HashMap<>(); + endpointConfig.put(API_ENDPOINT_CONFIG_PROTOCOL_TYPE, "http"); + List productionEndpoints = new ArrayList<>(); + productionEndpoints.add("not-an-object"); // skipped + HashMap valid = new HashMap<>(); + valid.put("url", "https://valid.test"); + productionEndpoints.add(valid); // usable + endpointConfig.put(API_DATA_PRODUCTION_ENDPOINTS, productionEndpoints); + Mockito.when(apiDto.getEndpointConfig()).thenReturn(endpointConfig); + + PowerMockito.mockStatic(RestApiCommonUtil.class); + PowerMockito.when(RestApiCommonUtil.getLoggedInUserTenantDomain()).thenReturn(ORGANIZATION); + PowerMockito.mockStatic(APIUtil.class); + PowerMockito.when(APIUtil.validateEndpointURLs(Mockito.any())).thenReturn(true); + + Assert.assertTrue(PublisherCommonUtils.validateEndpoints(new APIDTOTypeWrapper(apiDto))); + } } diff --git a/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/impl/ApisApiServiceImpl.java b/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/impl/ApisApiServiceImpl.java index 28058ceed50c..df2d3a5129cc 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/impl/ApisApiServiceImpl.java +++ b/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/impl/ApisApiServiceImpl.java @@ -3054,7 +3054,8 @@ public Response updateAPISwagger(String apiId, String ifMatch, String apiDefinit */ private String updateSwagger(String apiId, String apiDefinition, String organization) throws APIManagementException, FaultGatewaysException { - OASParserOptions oasParserOptions = CommonUtil.getOasParserOptions(); + OASParserOptions oasParserOptions = APIUtil.buildRefResolutionOptions( + CommonUtil.getOasParserOptions(), RestApiCommonUtil.getLoggedInUserTenantDomain()); APIDefinitionValidationResponse response = OASParserUtil.validateAPIDefinition(apiDefinition, true, oasParserOptions); if (!response.isValid()) { @@ -3221,9 +3222,15 @@ public Response validateEndpoint(String endpointUrl, String apiId, MessageContex ApiEndpointValidationResponseDTO apiEndpointValidationResponseDTO = new ApiEndpointValidationResponseDTO(); apiEndpointValidationResponseDTO.setError(""); try { + APIUtil.validateRemoteURL(endpointUrl, RestApiCommonUtil.getLoggedInUserTenantDomain()); APIEndpointValidationDTO apiEndpointValidationDTO = ApisApiServiceImplUtils.sendHttpHEADRequest(endpointUrl); apiEndpointValidationResponseDTO = APIMappingUtil.fromEndpointValidationToDTO(apiEndpointValidationDTO); return Response.status(Response.Status.OK).entity(apiEndpointValidationResponseDTO).build(); + } catch (APIManagementException e) { + if (e.getErrorHandler() == null || e.getErrorHandler().getHttpStatusCode() != 400) { + throw RestApiUtil.buildInternalServerErrorException(e.getMessage()); + } + apiEndpointValidationResponseDTO.setError(e.getErrorHandler().getErrorDescription()); } catch (MalformedURLException e) { log.error("Malformed Url error occurred while sending the HEAD request to the given endpoint url:", e); apiEndpointValidationResponseDTO.setError(e.getMessage()); @@ -3286,7 +3293,11 @@ public Response validateOpenAPIDefinition(Boolean returnContent, String url, Inp inlineApiDefinition, returnContent, false); } catch (APIManagementException e) { - RestApiUtil.handleInternalServerError("Error occurred while validating API Definition", e, log); + if (e.getErrorHandler() != null && e.getErrorHandler().getHttpStatusCode() == 400) { + RestApiUtil.handleBadRequest(e.getErrorHandler().getErrorDescription(), log); + } else { + RestApiUtil.handleInternalServerError("Error occurred while validating API Definition", e, log); + } } OpenAPIDefinitionValidationResponseDTO validationResponseDTO = (OpenAPIDefinitionValidationResponseDTO) validationResponseMap @@ -3427,6 +3438,7 @@ private Map validateWSDL(String url, InputStream fileInputStream WSDLValidationResponse validationResponse = new WSDLValidationResponse(); if (url != null) { + APIUtil.validateRemoteURL(url, RestApiCommonUtil.getLoggedInUserTenantDomain()); try { URL wsdlUrl = new URL(url); validationResponse = APIMWSDLReader.validateWSDLUrl(wsdlUrl); @@ -3510,6 +3522,22 @@ public Response importWSDLDefinition(InputStream fileInputStream, Attachment fil additionalPropertiesAPI.setProvider(username); additionalPropertiesAPI.setType(APIDTO.TypeEnum.fromValue(implementationType)); String organization = RestApiUtil.getValidatedOrganization(messageContext); + Object wsdlEndpointConfig = additionalPropertiesAPI.getEndpointConfig(); + if (wsdlEndpointConfig instanceof Map) { + String tenantDomain = RestApiCommonUtil.getLoggedInUserTenantDomain(); + org.json.JSONObject endpointConfigObj = new org.json.JSONObject((Map) wsdlEndpointConfig); + if (!APIConstants.ENDPOINT_TYPE_DEFAULT.equalsIgnoreCase( + endpointConfigObj.optString(APIConstants.API_ENDPOINT_CONFIG_PROTOCOL_TYPE))) { + ArrayList endpoints = new ArrayList<>(); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, APIConstants.API_DATA_PRODUCTION_ENDPOINTS, endpoints); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, APIConstants.API_DATA_SANDBOX_ENDPOINTS, endpoints); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, APIConstants.ENDPOINT_PRODUCTION_FAILOVERS, endpoints); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, APIConstants.ENDPOINT_SANDBOX_FAILOVERS, endpoints); + for (String endpoint : endpoints) { + APIUtil.validateRemoteURL(endpoint, tenantDomain); + } + } + } API apiToAdd = PublisherCommonUtils .prepareToCreateAPIByDTO(new APIDTOTypeWrapper(additionalPropertiesAPI), RestApiCommonUtil.getLoggedInUserProvider(), username, organization); @@ -3999,6 +4027,25 @@ public Response importGraphQLSchema(String ifMatch, String type, InputStream fil RestApiUtil.handleBadRequest(errorMessage, log); } else { additionalPropertiesAPI = new ObjectMapper().readValue(additionalProperties, APIDTO.class); + Object rawEndpointConfig = additionalPropertiesAPI.getEndpointConfig(); + if (rawEndpointConfig instanceof Map) { + org.json.JSONObject endpointConfigObj = new org.json.JSONObject((Map) rawEndpointConfig); + if (!APIConstants.ENDPOINT_TYPE_DEFAULT.equalsIgnoreCase( + endpointConfigObj.optString(APIConstants.API_ENDPOINT_CONFIG_PROTOCOL_TYPE))) { + ArrayList endpointURLs = new ArrayList<>(); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, + APIConstants.API_DATA_PRODUCTION_ENDPOINTS, endpointURLs); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, + APIConstants.API_DATA_SANDBOX_ENDPOINTS, endpointURLs); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, + APIConstants.ENDPOINT_PRODUCTION_FAILOVERS, endpointURLs); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, + APIConstants.ENDPOINT_SANDBOX_FAILOVERS, endpointURLs); + for (String endpointURL : endpointURLs) { + APIUtil.validateRemoteURL(endpointURL, RestApiCommonUtil.getLoggedInUserTenantDomain()); + } + } + } } if (schema != null && StringUtils.isNotEmpty(schema)) { @@ -4006,6 +4053,14 @@ public Response importGraphQLSchema(String ifMatch, String type, InputStream fil } else if (fileInputStream != null && !StringUtils.isBlank(additionalProperties)) { graphQLSchema = IOUtils.toString(fileInputStream, RestApiConstants.CHARSET); } else if (url != null) { + try { + APIUtil.validateRemoteURL(url, RestApiCommonUtil.getLoggedInUserTenantDomain()); + } catch (APIManagementException e) { + if (e.getErrorHandler() != null && e.getErrorHandler().getHttpStatusCode() == 400) { + throw RestApiUtil.buildBadRequestException(e.getErrorHandler().getErrorDescription()); + } + throw RestApiUtil.buildInternalServerErrorException(e.getMessage()); + } graphQLSchema = PublisherCommonUtils.retrieveGraphQLSchemaFromURL(url); } else { Map endpointConfigurationMap = @@ -4016,6 +4071,14 @@ public Response importGraphQLSchema(String ifMatch, String type, InputStream fil "production_endpoints"); endpointURL = productionEndpoints.get("url"); } + try { + APIUtil.validateRemoteURL(endpointURL, RestApiCommonUtil.getLoggedInUserTenantDomain()); + } catch (APIManagementException e) { + if (e.getErrorHandler() != null && e.getErrorHandler().getHttpStatusCode() == 400) { + throw RestApiUtil.buildBadRequestException(e.getErrorHandler().getErrorDescription()); + } + throw RestApiUtil.buildInternalServerErrorException(e.getMessage()); + } graphQLSchema = PublisherCommonUtils.generateGraphQLSchemaFromIntrospection(endpointURL); } @@ -4060,6 +4123,9 @@ public Response importGraphQLSchema(String ifMatch, String type, InputStream fil if (e.getMessage().contains(ExceptionCodes.API_CONTEXT_MALFORMED_EXCEPTION.getErrorMessage())) { RestApiUtil.handleBadRequest(e.getMessage(), e, log); } + if (e.getErrorHandler() != null && e.getErrorHandler().getHttpStatusCode() == 400) { + RestApiUtil.handleBadRequest(e.getErrorHandler().getErrorDescription(), e, log); + } String errorMessage = "Error while adding new API : " + additionalPropertiesAPI.getProvider() + "-" + additionalPropertiesAPI.getName() + "-" + additionalPropertiesAPI.getVersion() + " - " + e.getMessage(); @@ -4143,6 +4209,18 @@ public Response validateGraphQLSchema(Boolean useIntrospection, InputStream file filename = fileDetail.getDataHandler().getName(); schema = IOUtils.toString(fileInputStream, RestApiConstants.CHARSET); } + if (url != null) { + try { + APIUtil.validateRemoteURL(url, RestApiCommonUtil.getLoggedInUserTenantDomain()); + } catch (APIManagementException e) { + if (e.getErrorHandler() == null || e.getErrorHandler().getHttpStatusCode() != 400) { + throw RestApiUtil.buildInternalServerErrorException(e.getMessage()); + } + validationResponse.setIsValid(false); + validationResponse.setErrorMessage(e.getErrorHandler().getErrorDescription()); + return Response.ok().entity(validationResponse).build(); + } + } validationResponse = PublisherCommonUtils.validateGraphQLSchema(filename, schema, url, useIntrospection); } catch (IOException | APIManagementException e) { validationResponse.setIsValid(false); @@ -4705,6 +4783,14 @@ private Map validateAsyncAPISpecification(String url, InputStrea APIDefinitionValidationResponse validationResponse = new APIDefinitionValidationResponse(); if (url != null) { + try { + APIUtil.validateRemoteURL(url, RestApiCommonUtil.getLoggedInUserTenantDomain()); + } catch (APIManagementException e) { + if (e.getErrorHandler() != null && e.getErrorHandler().getHttpStatusCode() == 400) { + throw RestApiUtil.buildBadRequestException(e.getErrorHandler().getErrorDescription()); + } + throw e; + } try { URL urlObj = new URL(url); HttpClient httpClient = APIUtil.getHttpClient(urlObj.getPort(), urlObj.getProtocol()); @@ -4792,6 +4878,23 @@ public Response importAsyncAPISpecification(InputStream fileInputStream, Attachm apiDTOFromProperties.setTransport(websocketTransports); } + Object asyncEndpointConfig = apiDTOFromProperties.getEndpointConfig(); + if (asyncEndpointConfig instanceof Map) { + String tenantDomain = RestApiCommonUtil.getLoggedInUserTenantDomain(); + org.json.JSONObject endpointConfigObj = new org.json.JSONObject((Map) asyncEndpointConfig); + if (!APIConstants.ENDPOINT_TYPE_DEFAULT.equalsIgnoreCase( + endpointConfigObj.optString(APIConstants.API_ENDPOINT_CONFIG_PROTOCOL_TYPE))) { + ArrayList endpoints = new ArrayList<>(); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, APIConstants.API_DATA_PRODUCTION_ENDPOINTS, endpoints); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, APIConstants.API_DATA_SANDBOX_ENDPOINTS, endpoints); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, APIConstants.ENDPOINT_PRODUCTION_FAILOVERS, endpoints); + APIUtil.extractURLsFromEndpointConfig(endpointConfigObj, APIConstants.ENDPOINT_SANDBOX_FAILOVERS, endpoints); + for (String endpoint : endpoints) { + APIUtil.validateRemoteURL(endpoint, tenantDomain); + } + } + } + try { String organization = RestApiUtil.getValidatedOrganization(messageContext); APIDTO createdAPIDTO = importAsyncAPISpecification(fileInputStream, url, apiDTOFromProperties, fileDetail, @@ -5045,8 +5148,12 @@ public Response reimportServiceFromCatalog(String apiId, MessageContext messageC RestApiUtil.handleBadRequest("Unsupported protocol specified in the Service Definition. Protocol " + "should be either sse or websub or ws", log); } - RestApiUtil.handleInternalServerError("Error while retrieving the service key of the service " + - "associated with API with id " + apiId, log); + if (e.getErrorHandler() != null && e.getErrorHandler().getHttpStatusCode() == 400) { + RestApiUtil.handleBadRequest(e.getErrorHandler().getErrorDescription(), log); + } else { + RestApiUtil.handleInternalServerError("Error while retrieving the service key of the service " + + "associated with API with id " + apiId, log); + } } catch (FaultGatewaysException e) { String errorMessage = "Error while updating API : " + apiId; RestApiUtil.handleInternalServerError(errorMessage, e, log); diff --git a/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/impl/McpServersApiServiceImpl.java b/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/impl/McpServersApiServiceImpl.java index 5068c0d87f5b..f7a56431fd12 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/impl/McpServersApiServiceImpl.java +++ b/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/impl/McpServersApiServiceImpl.java @@ -1014,6 +1014,7 @@ public Response createMCPServerProxy(MCPServerProxyRequestDTO mcPServerProxyRequ ExceptionCodes.MCP_REQUEST_BODY_CANNOT_BE_NULL); } String url = StringUtils.trimToEmpty(mcPServerProxyRequest.getUrl()); + APIUtil.validateRemoteURL(url, RestApiCommonUtil.getLoggedInUserTenantDomain()); MCPServerDTO mcpServerDTO = mcPServerProxyRequest.getAdditionalProperties(); SecurityInfoDTO securityInfoDTO = mcPServerProxyRequest.getSecurityInfo(); @@ -2434,14 +2435,41 @@ public Response updateMCPServerBackend(String mcpServerId, String backendApiId, } else { RestApiUtil.handleBadRequest("Endpoint config is not in correct format", log); } + + org.json.JSONObject endpointConfig = null; + if (endpointConfigObj instanceof Map) { + endpointConfig = new org.json.JSONObject((Map) endpointConfigObj); + } else if (endpointConfigObj instanceof String) { + endpointConfig = new org.json.JSONObject(endpointConfigObj.toString()); + } + if (endpointConfig != null) { + String tenantDomain = RestApiCommonUtil.getLoggedInUserTenantDomain(); + if (!APIConstants.ENDPOINT_TYPE_DEFAULT.equalsIgnoreCase( + endpointConfig.optString(APIConstants.API_ENDPOINT_CONFIG_PROTOCOL_TYPE))) { + ArrayList endpoints = new ArrayList<>(); + APIUtil.extractURLsFromEndpointConfig(endpointConfig, + APIConstants.API_DATA_PRODUCTION_ENDPOINTS, endpoints); + APIUtil.extractURLsFromEndpointConfig(endpointConfig, + APIConstants.API_DATA_SANDBOX_ENDPOINTS, endpoints); + APIUtil.extractURLsFromEndpointConfig(endpointConfig, + APIConstants.ENDPOINT_PRODUCTION_FAILOVERS, endpoints); + APIUtil.extractURLsFromEndpointConfig(endpointConfig, + APIConstants.ENDPOINT_SANDBOX_FAILOVERS, endpoints); + for (String endpoint : endpoints) { + APIUtil.validateRemoteURL(endpoint, tenantDomain); + } + } + } + String definition = backendAPIDTO.getDefinition(); if (StringUtils.isNotBlank(definition)) { if (APIConstants.API_SUBTYPE_DIRECT_BACKEND.equals(subtype)) { APIDefinitionValidationResponse validationResponse = OASParserUtil.validateAPIDefinition(definition, Boolean.TRUE, - ServiceReferenceHolder.getInstance() + APIUtil.buildRefResolutionOptions(ServiceReferenceHolder.getInstance() .getAPIMDependencyConfigurationService() - .getAPIMDependencyConfigurations().getOasParserOptions()); + .getAPIMDependencyConfigurations().getOasParserOptions(), + RestApiCommonUtil.getLoggedInUserTenantDomain())); if (!validationResponse.isValid()) { List errorListItemDTOs = APIMappingUtil.getErrorListItemsDTOsFromErrorHandlers( @@ -2593,10 +2621,16 @@ public Response validateMCPServerEndpoint(String endpointUrl, String mcpServerId ApiEndpointValidationResponseDTO apiEndpointValidationResponseDTO = new ApiEndpointValidationResponseDTO(); apiEndpointValidationResponseDTO.setError(""); try { + APIUtil.validateRemoteURL(endpointUrl, RestApiCommonUtil.getLoggedInUserTenantDomain()); APIEndpointValidationDTO apiEndpointValidationDTO = ApisApiServiceImplUtils.sendHttpHEADRequest(endpointUrl); apiEndpointValidationResponseDTO = APIMappingUtil.fromEndpointValidationToDTO(apiEndpointValidationDTO); return Response.status(Response.Status.OK).entity(apiEndpointValidationResponseDTO).build(); + } catch (APIManagementException e) { + if (e.getErrorHandler() == null || e.getErrorHandler().getHttpStatusCode() != 400) { + throw e; + } + apiEndpointValidationResponseDTO.setError(e.getErrorHandler().getErrorDescription()); } catch (MalformedURLException e) { log.error("Malformed Url error occurred while sending the HEAD request to the given endpoint url:", e); apiEndpointValidationResponseDTO.setError(e.getMessage()); @@ -2632,7 +2666,11 @@ public Response validateOpenAPIDefinitionOfMCPServer(Boolean returnContent, Stri inlineAPIDefinition, returnContent, false); } catch (APIManagementException e) { - RestApiUtil.handleInternalServerError("Error occurred while validating API Definition", e, log); + if (e.getErrorHandler() != null && e.getErrorHandler().getHttpStatusCode() == 400) { + RestApiUtil.handleBadRequest(e.getErrorHandler().getErrorDescription(), log); + } else { + RestApiUtil.handleInternalServerError("Error occurred while validating API Definition", e, log); + } } OpenAPIDefinitionValidationResponseDTO validationResponseDTO = @@ -2669,6 +2707,7 @@ public Response validateThirdPartyMCPServer(MCPServerValidationRequestDTO dto, M dto.setUrl(serverUrl); final String organization = RestApiUtil.getValidatedOrganization(messageContext); + APIUtil.validateRemoteURL(serverUrl, RestApiCommonUtil.getLoggedInUserTenantDomain()); SecurityInfoDTO securityInfo = dto.getSecurityInfo(); String mcpServerId = StringUtils.trimToNull(dto.getMcpServerId()); diff --git a/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/utils/RestApiPublisherUtils.java b/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/utils/RestApiPublisherUtils.java index a51a4a24b285..38d8acd99279 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/utils/RestApiPublisherUtils.java +++ b/components/apimgt/org.wso2.carbon.apimgt.rest.api.publisher.v1/src/main/java/org/wso2/carbon/apimgt/rest/api/publisher/v1/utils/RestApiPublisherUtils.java @@ -657,6 +657,9 @@ public static API createAPIFromDefinition(InputStream definition, String definit validateOpenAPIDefinition(definitionUrl, definition, fileDetail, inlineDefinition, true, isServiceAPI); } catch (APIManagementException e) { + if (e.getErrorHandler() != null && e.getErrorHandler().getHttpStatusCode() == 400) { + throw RestApiUtil.buildBadRequestException(e.getErrorHandler().getErrorDescription()); + } RestApiUtil.handleInternalServerError("Error occurred while validating API Definition", e, log); return null; } @@ -749,6 +752,16 @@ public static Map validateOpenAPIDefinition(String url, InputStream fileInputStr throws APIManagementException { //validate inputs handleInvalidParams(fileInputStream, fileDetail, url, apiDefinition, isServiceAPI); + if (url != null) { + try { + APIUtil.validateRemoteURL(url, RestApiCommonUtil.getLoggedInUserTenantDomain()); + } catch (APIManagementException e) { + if (e.getErrorHandler() != null && e.getErrorHandler().getHttpStatusCode() == 400) { + throw RestApiUtil.buildBadRequestException(e.getErrorHandler().getErrorDescription()); + } + throw e; + } + } String fileName = null; OpenAPIDefinitionValidationResponseDTO responseDTO; diff --git a/components/apimgt/org.wso2.carbon.apimgt.rest.api.service.catalog/src/main/java/org/wso2/carbon/apimgt/rest/api/service/catalog/impl/ServicesApiServiceImpl.java b/components/apimgt/org.wso2.carbon.apimgt.rest.api.service.catalog/src/main/java/org/wso2/carbon/apimgt/rest/api/service/catalog/impl/ServicesApiServiceImpl.java index 225cc103fe25..36e8a4e68bbc 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.rest.api.service.catalog/src/main/java/org/wso2/carbon/apimgt/rest/api/service/catalog/impl/ServicesApiServiceImpl.java +++ b/components/apimgt/org.wso2.carbon.apimgt.rest.api.service.catalog/src/main/java/org/wso2/carbon/apimgt/rest/api/service/catalog/impl/ServicesApiServiceImpl.java @@ -109,7 +109,11 @@ public Response addService(ServiceDTO serviceDTO, InputStream definitionFileInpu ServiceEntry createdService = serviceCatalog.getServiceByUUID(serviceId, tenantId); return Response.ok().entity(ServiceEntryMappingUtil.fromServiceToDTO(createdService, false)).build(); } catch (APIManagementException e) { - RestApiUtil.handleInternalServerError("Error when validating the service definition", log); + if (e.getErrorHandler() != null && e.getErrorHandler().getHttpStatusCode() == 400) { + RestApiUtil.handleBadRequest(e.getErrorHandler().getErrorDescription(), log); + } else { + RestApiUtil.handleInternalServerError("Error when validating the service definition", log); + } } catch (IOException e) { RestApiUtil.handleInternalServerError("Error when reading the file content", log); } @@ -428,7 +432,11 @@ public Response updateService(String serviceId, ServiceDTO serviceDTO, InputStre if (RestApiUtil.isDueToResourceNotFound(e)) { RestApiUtil.handleResourceNotFoundError("Service", serviceId, e, log); } - RestApiUtil.handleInternalServerError("Error when validating the service definition", log); + if (e.getErrorHandler() != null && e.getErrorHandler().getHttpStatusCode() == 400) { + RestApiUtil.handleBadRequest(e.getErrorHandler().getErrorDescription(), log); + } else { + RestApiUtil.handleInternalServerError("Error when validating the service definition", log); + } } catch (IOException e) { RestApiUtil.handleInternalServerError("Error when reading the file content", log); } @@ -504,7 +512,8 @@ private APIDefinitionValidationResponse validateAsyncAPISpecification(String url private APIDefinitionValidationResponse validateOpenAPIDefinition(String url, String definitionContent) throws APIManagementException { APIDefinitionValidationResponse validationResponse = new APIDefinitionValidationResponse(); - OASParserOptions parserOptions = CommonUtil.getOasParserOptions(); + OASParserOptions parserOptions = APIUtil.buildRefResolutionOptions(CommonUtil.getOasParserOptions(), + RestApiCommonUtil.getLoggedInUserTenantDomain()); if (definitionContent != null) { validationResponse = OASParserUtil.validateAPIDefinition(definitionContent, true, parserOptions); } else if (url != null) { diff --git a/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/main/java/org/wso2/carbon/apimgt/spec/parser/definitions/OAS2Parser.java b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/main/java/org/wso2/carbon/apimgt/spec/parser/definitions/OAS2Parser.java index 3464affe1ad7..8d6bd5b5157e 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/main/java/org/wso2/carbon/apimgt/spec/parser/definitions/OAS2Parser.java +++ b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/main/java/org/wso2/carbon/apimgt/spec/parser/definitions/OAS2Parser.java @@ -61,7 +61,9 @@ import io.swagger.models.properties.Property; import io.swagger.models.properties.RefProperty; import io.swagger.parser.SwaggerParser; +import io.swagger.parser.SwaggerResolver; import io.swagger.parser.util.DeserializationUtils; +import io.swagger.parser.util.ParseOptions; import io.swagger.parser.util.SwaggerDeserializationResult; import io.swagger.util.Json; import org.apache.commons.collections.CollectionUtils; @@ -84,6 +86,7 @@ import org.wso2.carbon.apimgt.api.model.BackendOperation; import org.wso2.carbon.apimgt.api.model.BackendOperationMapping; import org.wso2.carbon.apimgt.api.model.CORSConfiguration; +import org.wso2.carbon.apimgt.api.model.OASParserOptions; import org.wso2.carbon.apimgt.api.model.Scope; import org.wso2.carbon.apimgt.api.model.SwaggerData; import org.wso2.carbon.apimgt.api.model.URITemplate; @@ -714,10 +717,29 @@ private void preserveResourcePathOrderFromAPI(SwaggerData swaggerData, Swagger s public APIDefinitionValidationResponse validateAPIDefinition(String apiDefinition, boolean returnJsonContent) throws APIManagementException { + return validateAPIDefinition(apiDefinition, returnJsonContent, null); + } + + /** + * Validate the given Swagger 2.0 definition, honoring the supplied network access-control options. When a + * policy is configured, remote {@code $ref} resolution is routed through swagger-parser's built-in Safe URL + * Resolver ({@link #readWithInfoSafely}) so every ref - including refs nested inside a fetched remote document - + * is host-validated before it is fetched; a blocked host surfaces as + * {@link ExceptionCodes#UNTRUSTED_URL_IN_DEFINITION}. When no policy is configured, behaviour is identical to + * {@link #validateAPIDefinition(String, boolean)}. + * + * @param apiDefinition OpenAPI 2.0 definition content + * @param returnJsonContent whether to return the converted json form of the definition + * @param oasParserOptions network access-control options; {@code null} preserves the legacy unrestricted behaviour + * @return APIDefinitionValidationResponse object with validation information + */ + @Override + public APIDefinitionValidationResponse validateAPIDefinition(String apiDefinition, boolean returnJsonContent, + OASParserOptions oasParserOptions) throws APIManagementException { + APIDefinitionValidationResponse validationResponse = new APIDefinitionValidationResponse(); - SwaggerParser parser = new SwaggerParser(); Set uriTemplates = null; - SwaggerDeserializationResult parseAttemptForV2 = parser.readWithInfo(apiDefinition); + SwaggerDeserializationResult parseAttemptForV2 = readWithInfoSafely(apiDefinition, oasParserOptions); if (CollectionUtils.isNotEmpty(parseAttemptForV2.getMessages())) { for (String message : parseAttemptForV2.getMessages()) { OASParserUtil.addErrorToValidationResponse(validationResponse, message); @@ -1500,6 +1522,91 @@ private void updateOperations(Swagger swagger) { } } + /** + * Build the swagger-parser (v1) {@link ParseOptions} that enable the built-in Safe URL Resolver for embedded + * remote {@code $ref}s, mirroring {@link OAS3Parser#convertOptionsToParseOptions}. The resolver is enabled only + * when a network access-control policy is configured; otherwise resolution stays unrestricted to preserve the + * historical (backwards-compatible) behaviour for deployments that have not opted into the policy. + * + * @param options network access-control options + * @return v1 {@link ParseOptions} carrying the safe-resolve flag and the allow/block lists + */ + private ParseOptions convertToV1ParseOptions(OASParserOptions options) { + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setSafelyResolveURL(options.isNetworkAccessControlEnabled()); + parseOptions.setRemoteRefAllowList(options.getRemoteRefAllowList()); + parseOptions.setRemoteRefBlockList(options.getRemoteRefBlockList()); + return parseOptions; + } + + /** + * Deserialize a Swagger 2.0 definition and resolve its remote {@code $ref}s, routing resolution through + * swagger-parser's built-in Safe URL Resolver when a network access-control policy is configured. The single + * argument {@link SwaggerParser#readWithInfo(String)} resolves every remote {@code $ref} - including refs nested + * inside a fetched remote document - with no host validation; this helper instead deserializes without fetching + * and then resolves with {@code safelyResolveURL} enabled, so every ref (top-level and transitively nested) is + * host-checked before it is fetched. When no policy is configured, it falls back to the legacy unrestricted + * {@link SwaggerParser#readWithInfo(String)} for backwards compatibility. + * + * @param oasDefinition OpenAPI 2.0 definition content + * @param oasParserOptions network access-control options; {@code null}/non-policy value keeps legacy behaviour + * @return the deserialization result (its swagger resolved when a policy is configured) + * @throws APIManagementException with {@link ExceptionCodes#UNTRUSTED_URL_IN_DEFINITION} if a remote ref targets a + * host blocked by the policy + */ + private SwaggerDeserializationResult readWithInfoSafely(String oasDefinition, OASParserOptions oasParserOptions) + throws APIManagementException { + + SwaggerParser parser = new SwaggerParser(); + if (oasParserOptions == null || !oasParserOptions.isNetworkAccessControlEnabled()) { + // No policy configured: resolve as the legacy parser always has (unrestricted, backwards compatible). + return parser.readWithInfo(oasDefinition); + } + // Deserialize WITHOUT resolving so the parse messages are preserved and no remote ref is fetched yet. + SwaggerDeserializationResult parseResult = parser.readWithInfo(oasDefinition, false); + if (parseResult.getSwagger() == null) { + return parseResult; + } + // Resolve through the Safe URL Resolver: ResolverCache validates every ref (nested included) before fetch. + ParseOptions parseOptions = convertToV1ParseOptions(oasParserOptions); + parseOptions.setResolve(true); + try { + Swagger resolved = new SwaggerResolver(parseResult.getSwagger(), new ArrayList<>(), null, null, + parseOptions).resolve(); + parseResult.setSwagger(resolved); + } catch (RuntimeException e) { + // The v1 resolver rethrows a blocked host as a RuntimeException carrying the HostDeniedException message. + // Map only a genuine policy block to the definition-scoped 400; other failures fall to lenient handling. + if (OASParserUtil.isUntrustedUrlInDefinition(e.getMessage())) { + throw new APIManagementException(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorMessage(), + ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION); + } + if (log.isDebugEnabled()) { + log.debug("Error while resolving remote references in the OpenAPI 2.0 definition", e); + } + } + return parseResult; + } + + /** + * Get parsed Swagger object, honoring the supplied network access-control options for remote {@code $ref} + * resolution (see {@link #readWithInfoSafely}). When {@code oasParserOptions} is {@code null} or carries no + * policy, behaviour is identical to {@link #getSwagger(String)}. + * + * @param oasDefinition OAS definition + * @param oasParserOptions network access-control options; may be {@code null} + * @return Swagger + * @throws APIManagementException if a remote ref targets a host blocked by the policy + */ + Swagger getSwagger(String oasDefinition, OASParserOptions oasParserOptions) throws APIManagementException { + + SwaggerDeserializationResult parseAttemptForV2 = readWithInfoSafely(oasDefinition, oasParserOptions); + if (CollectionUtils.isNotEmpty(parseAttemptForV2.getMessages())) { + log.debug("Errors found when parsing OAS definition"); + } + return parseAttemptForV2.getSwagger(); + } + /** * Get parsed Swagger object * @@ -1517,6 +1624,93 @@ Swagger getSwagger(String oasDefinition) { return parseAttemptForV2.getSwagger(); } + // Network access-control aware overloads: with a policy configured, each re-parses through the Safe URL Resolver, + // rejecting a remote $ref to a blocked host with UNTRUSTED_URL_IN_DEFINITION; otherwise they delegate unchanged. + + @Override + public String generateAPIDefinition(SwaggerData swaggerData, String swagger, OASParserOptions options) + throws APIManagementException { + if (options != null && options.isNetworkAccessControlEnabled()) { + getSwagger(swagger, options); + } + return generateAPIDefinition(swaggerData, swagger); + } + + @Override + public String populateCustomManagementInfo(String oasDefinition, SwaggerData swaggerData, OASParserOptions options) + throws APIManagementException { + if (options != null && options.isNetworkAccessControlEnabled()) { + getSwagger(oasDefinition, options); + } + return populateCustomManagementInfo(oasDefinition, swaggerData); + } + + @Override + public String getOASDefinitionForStore(API api, String oasDefinition, Map hostsWithSchemes, + KeyManagerConfigurationDTO keyManagerConfigurationDTO, OASParserOptions options) + throws APIManagementException { + if (options != null && options.isNetworkAccessControlEnabled()) { + getSwagger(oasDefinition, options); + } + return getOASDefinitionForStore(api, oasDefinition, hostsWithSchemes, keyManagerConfigurationDTO); + } + + @Override + public String getOASDefinitionForStore(APIProduct product, String oasDefinition, + Map hostsWithSchemes, KeyManagerConfigurationDTO keyManagerConfigurationDTO, + OASParserOptions options) throws APIManagementException { + if (options != null && options.isNetworkAccessControlEnabled()) { + getSwagger(oasDefinition, options); + } + return getOASDefinitionForStore(product, oasDefinition, hostsWithSchemes, keyManagerConfigurationDTO); + } + + @Override + public String getOASDefinitionForPublisher(API api, String oasDefinition, OASParserOptions options) + throws APIManagementException { + if (options != null && options.isNetworkAccessControlEnabled()) { + getSwagger(oasDefinition, options); + } + return getOASDefinitionForPublisher(api, oasDefinition); + } + + @Override + public String processOtherSchemeScopes(String resourceConfigsJSON, OASParserOptions options) + throws APIManagementException { + if (options != null && options.isNetworkAccessControlEnabled()) { + getSwagger(resourceConfigsJSON, options); + } + return processOtherSchemeScopes(resourceConfigsJSON); + } + + @Override + public String injectMgwThrottlingExtensionsToDefault(String swaggerContent, OASParserOptions options) + throws APIManagementException { + if (options != null && options.isNetworkAccessControlEnabled()) { + getSwagger(swaggerContent, options); + } + return injectMgwThrottlingExtensionsToDefault(swaggerContent); + } + + @Override + public String copyVendorExtensions(String existingOASContent, String updatedOASContent, OASParserOptions options) + throws APIManagementException { + if (options != null && options.isNetworkAccessControlEnabled()) { + getSwagger(existingOASContent, options); + getSwagger(updatedOASContent, options); + } + return copyVendorExtensions(existingOASContent, updatedOASContent); + } + + @Override + public String processDisableSecurityExtension(String swaggerContent, OASParserOptions options) + throws APIManagementException { + if (options != null && options.isNetworkAccessControlEnabled()) { + getSwagger(swaggerContent, options); + } + return processDisableSecurityExtension(swaggerContent); + } + /** * Remove responsesObject from the swagger string * This is to address a bug in swagger parser diff --git a/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/main/java/org/wso2/carbon/apimgt/spec/parser/definitions/OAS3Parser.java b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/main/java/org/wso2/carbon/apimgt/spec/parser/definitions/OAS3Parser.java index a29961f95546..e4c73eea37e5 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/main/java/org/wso2/carbon/apimgt/spec/parser/definitions/OAS3Parser.java +++ b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/main/java/org/wso2/carbon/apimgt/spec/parser/definitions/OAS3Parser.java @@ -962,12 +962,18 @@ public APIDefinitionValidationResponse validateAPIDefinition(String apiDefinitio APIDefinitionValidationResponse validationResponse = new APIDefinitionValidationResponse(); String processedDefinition = OASParserUtil.preprocessYamlWithLimit(apiDefinition, parserOptions); OpenAPIV3Parser openAPIV3Parser = new OpenAPIV3Parser(); - ParseOptions options = new ParseOptions(); + ParseOptions options = parserOptions != null ? convertOptionsToParseOptions(parserOptions) + : new ParseOptions(); options.setResolve(true); SwaggerParseResult parseAttemptForV3 = openAPIV3Parser.readContents(processedDefinition, null, options); if (CollectionUtils.isNotEmpty(parseAttemptForV3.getMessages())) { validationResponse.setValid(false); for (String message : parseAttemptForV3.getMessages()) { + if (parserOptions != null && parserOptions.isNetworkAccessControlEnabled() + && OASParserUtil.isUntrustedUrlInDefinition(message)) { + throw new APIManagementException(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorMessage(), + ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION); + } OASParserUtil.addErrorToValidationResponse(validationResponse, message); if (message.contains(APISpecParserConstants.OPENAPI_IS_MISSING_MSG)) { ErrorItem errorItem = new ErrorItem(); @@ -2033,9 +2039,14 @@ OpenAPI getOpenAPI(String oasDefinition, OASParserOptions options) { return parseAttemptForV3.getOpenAPI(); } - private ParseOptions convertOptionsToParseOptions(OASParserOptions options) { + ParseOptions convertOptionsToParseOptions(OASParserOptions options) { ParseOptions parserOptions = new ParseOptions(); parserOptions.setExplicitStyleAndExplode(options.isExplicitStyleAndExplode()); + // Enable swagger-parser's built-in Safe URL Resolver for embedded remote $refs only when a network + // access-control policy is configured; otherwise resolution stays unrestricted for backwards compatibility. + parserOptions.setSafelyResolveURL(options.isNetworkAccessControlEnabled()); + parserOptions.setRemoteRefAllowList(options.getRemoteRefAllowList()); + parserOptions.setRemoteRefBlockList(options.getRemoteRefBlockList()); return parserOptions; } diff --git a/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/main/java/org/wso2/carbon/apimgt/spec/parser/definitions/OASParserUtil.java b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/main/java/org/wso2/carbon/apimgt/spec/parser/definitions/OASParserUtil.java index 1044ff69a281..7f30527f71c3 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/main/java/org/wso2/carbon/apimgt/spec/parser/definitions/OASParserUtil.java +++ b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/main/java/org/wso2/carbon/apimgt/spec/parser/definitions/OASParserUtil.java @@ -62,6 +62,9 @@ import io.swagger.v3.parser.OpenAPIV3Parser; import io.swagger.v3.parser.converter.SwaggerConverter; import io.swagger.v3.parser.core.models.ParseOptions; +import io.swagger.v3.parser.urlresolver.PermittedUrlsChecker; +import io.swagger.v3.parser.urlresolver.exceptions.HostDeniedException; +import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.logging.Log; @@ -107,9 +110,11 @@ import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -968,6 +973,22 @@ public static APIDefinitionValidationResponse extractAndValidateOpenAPIArchive(I */ public static APIDefinitionValidationResponse extractAndValidateOpenAPIArchive(InputStream inputStream, boolean returnContent, OASParserOptions oasParserOptions) throws APIManagementException { + return extractAndValidateOpenAPIArchive(inputStream, returnContent, oasParserOptions, null); + } + + /** + * Extract the archive file and validates the openAPI definition. + * + * @param inputStream file as input stream + * @param returnContent whether to return the content of the definition in the response DTO + * @param oasParserOptions optional OpenAPI parser options; may be {@code null} to use defaults + * @param maxContentSizeStr per-file size limit (in MB); may be {@code null} to use the default + * @return APIDefinitionValidationResponse + * @throws APIManagementException if error occurred while parsing definition + */ + public static APIDefinitionValidationResponse extractAndValidateOpenAPIArchive(InputStream inputStream, + boolean returnContent, OASParserOptions oasParserOptions, String maxContentSizeStr) + throws APIManagementException { String path = System.getProperty(APISpecParserConstants.JAVA_IO_TMPDIR) + File.separator + APISpecParserConstants.OPENAPI_ARCHIVES_TEMP_FOLDER + File.separator + UUID.randomUUID().toString(); String archivePath = path + File.separator + APISpecParserConstants.OPENAPI_ARCHIVE_ZIP_FILE; @@ -1003,6 +1024,9 @@ public static APIDefinitionValidationResponse extractAndValidateOpenAPIArchive(I SwaggerVersion version; version = getSwaggerVersion(content); String filePath = masterSwagger.getAbsolutePath(); + // Gate remote (http/https) $refs in the archive through the policy gate before parsing with resolution + // enabled, so the parser can't fetch them without host validation; local/relative sibling refs are untouched. + gateArchiveRemoteRefs(archiveDirectory, oasParserOptions, maxContentSizeStr); if (SwaggerVersion.OPEN_API.equals(version)) { OpenAPIV3Parser openAPIV3Parser = new OpenAPIV3Parser(); ParseOptions options = new ParseOptions(); @@ -1109,10 +1133,20 @@ public static APIDefinitionValidationResponse validateAPIDefinition(String apiDe if (!validationResponse.isValid()) { for (ErrorHandler handler : validationResponse.getErrorItems()) { if (ExceptionCodes.INVALID_OAS3_FOUND.getErrorCode() == handler.getErrorCode()) { - return tryOAS2Validation(apiDefinition, returnJsonContent); + return tryOAS2Validation(apiDefinition, returnJsonContent, oasParserOptions); } } } + } catch (APIManagementException e) { + // A policy-gate block on a remote ref must surface as its own 400 error, not be folded into a generic + // parse error, so re-throw it; other APIManagementExceptions stay recorded as a validation error item. + if (e.getErrorHandler() != null + && ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode() == e.getErrorHandler() + .getErrorCode()) { + throw e; + } + //catching a generic exception as there can be runtime exceptions when parsing happens + addErrorToValidationResponse(validationResponse, e); } catch (Exception e) { //catching a generic exception as there can be runtime exceptions when parsing happens addErrorToValidationResponse(validationResponse, e); @@ -1138,6 +1172,27 @@ public static ErrorItem addErrorToValidationResponse(APIDefinitionValidationResp return errorItem; } + /** + * Determine whether the given parser message signals that a URL embedded in the definition was blocked by the + * network access control policy (the safe URL resolver's host-denied signal). The message fragments below are + * the ones emitted by the swagger-parser safe URL resolver when it refuses to resolve a remote {@code $ref}. + * + * @param message a parser message + * @return {@code true} if the message indicates an embedded URL was blocked by the policy + */ + public static boolean isUntrustedUrlInDefinition(String message) { + if (message == null) { + return false; + } + return message.contains("is restricted. URL [") + || message.contains("is part of the explicit denylist") + || message.contains("does not use a supported protocol. URL [") + || message.contains("Failed to resolve IP from hostname. Hostname [") + || message.contains("Failed to get hostname from URL. URL [") + || message.contains("Failed to create new URL with IP.") + || message.contains("Failed to parse URL. URL ["); + } + /** * Try to validate a give openAPI definition using OpenAPI 3 parser @@ -1186,24 +1241,30 @@ public static APIDefinitionValidationResponse validateAPIDefinition(String apiDe if (!validationResponse.isValid()) { for (ErrorHandler handler : validationResponse.getErrorItems()) { if (ExceptionCodes.INVALID_OAS3_FOUND.getErrorCode() == handler.getErrorCode()) { - return tryOAS2Validation(apiDefinition, returnJsonContent); + return tryOAS2Validation(apiDefinition, returnJsonContent, oasParserOptions); } } } return validationResponse; } /** - * Try to validate a give openAPI definition using swagger parser + * Try to validate a give openAPI definition using swagger parser, gating any remote {@code $ref} URLs present + * in the definition through the network access control policy (allow/block list + restricted-IP-range checks) + * before handing the definition to the legacy Swagger 2.0 parser, which otherwise fetches remote refs without + * any host validation. * * @param apiDefinition definition * @param returnJsonContent whether to return definition as a json content + * @param oasParserOptions optional OpenAPI parser options; may be {@code null} to use defaults * @return APIDefinitionValidationResponse * @throws APIManagementException if error occurred while parsing definition */ - private static APIDefinitionValidationResponse tryOAS2Validation(String apiDefinition, boolean returnJsonContent) - throws APIManagementException { + private static APIDefinitionValidationResponse tryOAS2Validation(String apiDefinition, boolean returnJsonContent, + OASParserOptions oasParserOptions) throws APIManagementException { + // Remote refs are gated inside OAS2Parser via swagger-parser's built-in Safe URL Resolver (enabled only + // when a policy is configured; no-op otherwise), which also validates refs nested in a fetched remote doc. APIDefinitionValidationResponse validationResponse = - oas2Parser.validateAPIDefinition(apiDefinition, returnJsonContent); + oas2Parser.validateAPIDefinition(apiDefinition, returnJsonContent, oasParserOptions); if (!validationResponse.isValid()) { for (ErrorHandler handler : validationResponse.getErrorItems()) { if (ExceptionCodes.INVALID_OAS2_FOUND.getErrorCode() == handler.getErrorCode()) { @@ -1215,6 +1276,237 @@ private static APIDefinitionValidationResponse tryOAS2Validation(String apiDefin return validationResponse; } + /** + * Validate the {@code $ref}s in an extracted OpenAPI archive before it is parsed with resolution enabled. The + * archive parser inlines sibling files and resolves references transitively; without this gate it would fetch + * remote refs without host validation and follow local references that escape the archive. + *
    + *
  • Remote (http/https) refs are checked against the network access-control policy when one is configured + * (when none is, they resolve as before, preserving backwards compatibility).
  • + *
  • Local references are always confined to the archive: a {@code file:} reference, an absolute path, or a + * relative path that escapes the extracted directory (e.g. {@code ../../../etc/passwd}) is rejected so + * resolution cannot read arbitrary local files. References that stay within the archive are left untouched + * so multi-file archives still resolve.
  • + *
+ * Only the refs present in the archive's own files are covered; refs nested inside a remote document that is itself + * fetched are not visible here. + * + * @param archiveDirectory the root directory of the extracted archive + * @param oasParserOptions parser options carrying the network access control policy; may be {@code null} + * @throws APIManagementException with {@link ExceptionCodes#UNTRUSTED_URL_IN_DEFINITION} if a remote ref is not + * permitted by the policy or a local ref escapes the archive + */ + private static void gateArchiveRemoteRefs(File archiveDirectory, + OASParserOptions oasParserOptions, String maxContentSizeStr) throws APIManagementException { + // A network access-control policy gates remote (http/https) refs; when none is configured those resolve as + // before (backwards compatibility). Local-reference containment is enforced regardless, since a ref that + // escapes the archive (via ../ or file:) could read arbitrary local files during resolution. + boolean policyConfigured = oasParserOptions != null && oasParserOptions.isNetworkAccessControlEnabled(); + PermittedUrlsChecker checker = policyConfigured + ? new PermittedUrlsChecker(oasParserOptions.getRemoteRefAllowList(), + oasParserOptions.getRemoteRefBlockList()) + : null; + String archiveRoot; + try { + archiveRoot = archiveDirectory.getCanonicalPath(); + } catch (IOException e) { + throw new APIManagementException("Could not resolve the OpenAPI archive directory for reference " + + "containment validation.", e); + } + // Reject archive entries larger than the configured size cap; they cannot be safely scanned. + String effectiveLimit = (maxContentSizeStr != null && !maxContentSizeStr.trim().isEmpty()) + ? maxContentSizeStr.trim() + : APIConstants.API_PUBLISHER_IMPORT_OAS_FILE_SIZE_LIMIT_DEFAULT_MB; + long maxFileSize = Long.parseLong(effectiveLimit) * 1024L * 1024L; + for (File file : FileUtils.listFiles(archiveDirectory, null, true)) { + if (file.length() > maxFileSize) { + if (log.isDebugEnabled()) { + log.debug("Rejecting OpenAPI archive: file '" + file.getName() + + "' exceeds the maximum size that can be validated."); + } + throw new APIManagementException("OpenAPI archive contains a file too large to validate: " + + file.getName(), ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION); + } + String fileContent; + try { + fileContent = FileUtils.readFileToString(file, APISpecParserConstants.DigestAuthConstants.CHARSET); + } catch (IOException e) { + // Unreadable/binary file: nothing to gate here; the parser will surface any real problem later. + continue; + } + for (String ref : extractRefValues(fileContent)) { + if (ref.startsWith("http://") || ref.startsWith("https://")) { + if (policyConfigured) { + try { + checker.verify(ref); + } catch (HostDeniedException e) { + if (log.isDebugEnabled()) { + log.debug("Rejected OpenAPI archive referencing a disallowed remote URL: " + ref, e); + } + throw new APIManagementException(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION); + } + } + } else { + enforceLocalRefWithinArchive(ref, file, archiveRoot); + } + } + } + } + + /** + * Rejects a non-HTTP(S) archive reference that would resolve outside the extracted archive directory. A {@code + * file:} reference, an absolute path, or a relative path that escapes the archive root (e.g. {@code + * ../../../etc/passwd}) is treated as an attempt to read a local file during resolution and fails the import. + * Same-document fragments (e.g. {@code #/components/...}) and references that stay within the archive are left + * untouched so legitimate multi-file archives still resolve. + * + * @param ref the raw {@code $ref} value + * @param referringFile the archive file the reference was found in + * @param archiveRoot the canonical path of the extracted archive root + * @throws APIManagementException with {@link ExceptionCodes#UNTRUSTED_URL_IN_DEFINITION} if the reference escapes + * the archive + */ + private static void enforceLocalRefWithinArchive(String ref, File referringFile, String archiveRoot) + throws APIManagementException { + // The file target is everything before the JSON-pointer fragment. + int hashIndex = ref.indexOf('#'); + String pathPart = hashIndex >= 0 ? ref.substring(0, hashIndex) : ref; + // A same-document fragment reference has no file target to contain. + if (pathPart.isEmpty()) { + return; + } + // A file: reference or an absolute path is never a legitimate archive-relative reference; it points the + // resolver at an absolute local path. + if (pathPart.toLowerCase(Locale.ROOT).startsWith("file:") || pathPart.startsWith("/") || pathPart.startsWith("\\") + || pathPart.matches("^[a-zA-Z]:[\\\\/].*")) { + if (log.isDebugEnabled()) { + log.debug("Rejecting OpenAPI archive with an absolute/file: reference: " + ref); + } + throw new APIManagementException(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION); + } + try { + String target = new File(referringFile.getParentFile(), pathPart).getCanonicalPath(); + if (!target.equals(archiveRoot) && !target.startsWith(archiveRoot + File.separator)) { + if (log.isDebugEnabled()) { + log.debug("Rejecting OpenAPI archive with a reference that escapes the archive root: " + ref); + } + throw new APIManagementException(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION); + } + } catch (IOException e) { + // Could not canonicalize the target; fail closed rather than let an unvalidated path reach the resolver. + throw new APIManagementException("Could not validate an OpenAPI archive reference for containment.", e); + } + } + + /** + * Collect the value of every {@code $ref} field in the given archive file content, regardless of scheme, so both + * remote (host-validated) and local (containment-checked) references can be inspected before resolution. + * + * @param jsonDefinition the OpenAPI/Swagger content, as JSON or YAML text + * @return a set of distinct {@code $ref} values; empty if none are found or the content cannot be parsed + */ + private static Set extractRefValues(String jsonDefinition) { + Set refs = new HashSet<>(); + try { + JsonNode root = new ObjectMapper(new YAMLFactory()).readTree(jsonDefinition); + collectRefValues(root, refs); + } catch (IOException | RuntimeException e) { + // Malformed content here is not this method's concern - the parser invoked afterwards surfaces a proper + // parse error. Fail safe with no refs. + if (log.isDebugEnabled()) { + log.debug("Could not parse OpenAPI archive file while scanning for $ref values", e); + } + } + return refs; + } + + private static void collectRefValues(JsonNode node, Set refs) { + if (node == null) { + return; + } + if (node.isObject()) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + JsonNode value = field.getValue(); + if (REF_FIELD_NAME.equals(field.getKey()) && value != null && value.isTextual()) { + String refValue = value.textValue(); + if (refValue != null && !refValue.isEmpty()) { + refs.add(refValue); + } + } else { + collectRefValues(value, refs); + } + } + } else if (node.isArray()) { + for (JsonNode element : node) { + collectRefValues(element, refs); + } + } + } + + /** + * Walk the given JSON definition tree and collect every remote (http/https) {@code $ref} URL present in it. + * Used to gate the legacy OpenAPI 2.0 parser (which otherwise fetches remote refs without host validation) + * through the network access control policy before parsing. Only top-level (non-transitively-fetched) refs + * present in the original document are covered; refs nested inside a remote document that is itself fetched + * are not visible here. + * + * @param jsonDefinition the OpenAPI/Swagger definition, as JSON or YAML text + * @return a set of distinct remote {@code $ref} URL values found in the definition; empty if none are found + * or if the definition cannot be parsed + */ + private static Set extractRemoteRefUrls(String jsonDefinition) { + Set refUrls = new HashSet<>(); + try { + // The definition may still be YAML here, so a YAML-backed mapper is used: it parses YAML and JSON alike + // (JSON is a subset of YAML), so remote refs are found regardless of format. + JsonNode root = new ObjectMapper(new YAMLFactory()).readTree(jsonDefinition); + collectRemoteRefUrls(root, refUrls); + } catch (IOException | RuntimeException e) { + // Malformed JSON (or any unexpected parsing issue) here is not this method's concern - the legacy + // parser invoked afterwards will surface a proper parse error to the caller. Fail safe with no refs. + if (log.isDebugEnabled()) { + log.debug("Could not parse OpenAPI definition while scanning for remote $ref URLs", e); + } + } + return refUrls; + } + + private static final String REF_FIELD_NAME = "$ref"; + + /** + * Recursively walk a JSON tree collecting the string value of every {@code $ref} field whose value starts + * with {@code http://} or {@code https://}. + * + * @param node the current JSON node + * @param refUrls the set to accumulate discovered remote ref URLs into + */ + private static void collectRemoteRefUrls(JsonNode node, Set refUrls) { + if (node == null) { + return; + } + if (node.isObject()) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + JsonNode value = field.getValue(); + if (REF_FIELD_NAME.equals(field.getKey()) && value != null && value.isTextual()) { + String refValue = value.textValue(); + if (refValue != null && (refValue.startsWith("http://") || refValue.startsWith("https://"))) { + refUrls.add(refValue); + } + } else { + collectRemoteRefUrls(value, refUrls); + } + } + } else if (node.isArray()) { + for (JsonNode element : node) { + collectRemoteRefUrls(element, refUrls); + } + } + } + /** * Update the APIDefinitionValidationResponse object with success state using the values given * diff --git a/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/java/org/wso2/carbon/apimgt/spec/parser/definitions/OAS3ParserTest.java b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/java/org/wso2/carbon/apimgt/spec/parser/definitions/OAS3ParserTest.java index a25dc00a1a9d..d7d6de36226a 100644 --- a/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/java/org/wso2/carbon/apimgt/spec/parser/definitions/OAS3ParserTest.java +++ b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/java/org/wso2/carbon/apimgt/spec/parser/definitions/OAS3ParserTest.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.models.security.OAuthFlow; import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.ParseOptions; import io.swagger.v3.parser.core.models.SwaggerParseResult; import org.apache.commons.io.IOUtils; import org.junit.Assert; @@ -15,6 +16,7 @@ import org.mockito.Mockito; import org.wso2.carbon.apimgt.api.APIDefinition; import org.wso2.carbon.apimgt.api.APIDefinitionValidationResponse; +import org.wso2.carbon.apimgt.api.APIManagementException; import org.wso2.carbon.apimgt.api.ExceptionCodes; import org.wso2.carbon.apimgt.api.model.API; import org.wso2.carbon.apimgt.api.model.APIIdentifier; @@ -25,6 +27,7 @@ import java.io.File; import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -551,4 +554,157 @@ private Set getAPITestScopes() { apiScopes.add(petLocalScope); return apiScopes; } + + @Test + public void testConvertOptionsToParseOptionsEnablesSafeResolution() { + OASParserOptions opts = new OASParserOptions(); + opts.setNetworkAccessControlEnabled(true); + opts.setRemoteRefAllowList(Arrays.asList("*.wso2.com")); + opts.setRemoteRefBlockList(Arrays.asList("*.internal")); + ParseOptions po = new OAS3Parser().convertOptionsToParseOptions(opts); + Assert.assertTrue(po.isSafelyResolveURL()); + Assert.assertEquals(Arrays.asList("*.wso2.com"), po.getRemoteRefAllowList()); + Assert.assertEquals(Arrays.asList("*.internal"), po.getRemoteRefBlockList()); + } + + @Test + public void testConvertOptionsLeavesSafeResolutionOffWhenPolicyNotConfigured() { + // Backwards compatibility: with no network access-control policy configured, safe URL resolution must stay + // off so remote refs resolve exactly as they did before the feature was introduced. + OASParserOptions opts = new OASParserOptions(); + ParseOptions po = new OAS3Parser().convertOptionsToParseOptions(opts); + Assert.assertFalse("Safe URL resolution must remain off when the policy is not configured", + po.isSafelyResolveURL()); + } + + @Test + public void testBlockedRemoteRefIsRejected() throws Exception { + String def = IOUtils.toString(getClass().getClassLoader().getResourceAsStream( + "definitions/oas3/ref_blocked_loopback.json"), "UTF-8"); + OASParserOptions opts = new OASParserOptions(); + opts.setNetworkAccessControlEnabled(true); + opts.setRemoteRefBlockList(Arrays.asList("169.254.169.254")); + try { + OASParserUtil.validateAPIDefinition(def, true, opts); + Assert.fail("A blocked remote $ref must be rejected"); + } catch (APIManagementException e) { + Assert.assertEquals("A blocked remote $ref must surface as UNTRUSTED_URL_IN_DEFINITION", + ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), e.getErrorHandler().getErrorCode()); + } + } + + @Test + public void testOAS2BlockedRemoteRefIsRejected() throws Exception { + // OAS2 definitions fall back to the legacy SwaggerParser, which would otherwise fetch remote $refs + // unchecked; assert the gate in tryOAS2Validation rejects a blocked ref before that fetch. + String def = IOUtils.toString(getClass().getClassLoader().getResourceAsStream( + "definitions/oas2/ref_blocked_loopback.json"), "UTF-8"); + OASParserOptions opts = new OASParserOptions(); + opts.setNetworkAccessControlEnabled(true); + opts.setRemoteRefBlockList(Arrays.asList("169.254.169.254")); + + long start = System.nanoTime(); + try { + OASParserUtil.validateAPIDefinition(def, true, opts); + Assert.fail("A blocked remote $ref in an OAS2 definition must be rejected"); + } catch (APIManagementException e) { + Assert.assertEquals("A blocked remote $ref must surface as UNTRUSTED_URL_IN_DEFINITION", + ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), e.getErrorHandler().getErrorCode()); + } + long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + + // The safe resolver validates the host BEFORE any fetch, so a blocked direct ref is rejected without a + // network round trip. A fast (well under a second) rejection confirms no network fetch was attempted. + Assert.assertTrue("Expected the OAS2 remote-ref gate to reject quickly without attempting a network fetch, " + + "but validation took " + elapsedMillis + "ms", + elapsedMillis < 5000); + } + + @Test + public void testOAS2CleanIsValid() throws Exception { + String def = IOUtils.toString(getClass().getClassLoader().getResourceAsStream( + "definitions/oas2/ref_clean_no_remote.json"), "UTF-8"); + OASParserOptions opts = new OASParserOptions(); + opts.setNetworkAccessControlEnabled(true); + opts.setRemoteRefBlockList(Arrays.asList("169.254.169.254")); + APIDefinitionValidationResponse resp = OASParserUtil.validateAPIDefinition(def, true, opts); + Assert.assertTrue("A clean OAS2 definition with no remote $ref must validate successfully", resp.isValid()); + } + + @Test + public void testOAS2BlockedRemoteRefInYamlIsRejected() throws Exception { + // YAML variant of the OAS2 gate test: the gate scans the raw definition, so remote-ref extraction must + // handle YAML as well as JSON - otherwise a YAML body slips past and the legacy parser fetches unchecked. + String def = IOUtils.toString(getClass().getClassLoader().getResourceAsStream( + "definitions/oas2/ref_blocked_loopback.yaml"), "UTF-8"); + OASParserOptions opts = new OASParserOptions(); + opts.setNetworkAccessControlEnabled(true); + opts.setRemoteRefBlockList(Arrays.asList("169.254.169.254")); + + long start = System.nanoTime(); + try { + OASParserUtil.validateAPIDefinition(def, true, opts); + Assert.fail("A blocked remote $ref in a YAML OAS2 definition must be rejected"); + } catch (APIManagementException e) { + Assert.assertEquals("A blocked remote $ref must surface as UNTRUSTED_URL_IN_DEFINITION", + ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), e.getErrorHandler().getErrorCode()); + } + long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + + Assert.assertTrue("Expected the OAS2 remote-ref gate to reject the YAML definition quickly without a network " + + "fetch, but validation took " + elapsedMillis + "ms", + elapsedMillis < 5000); + } + + @Test + public void testOAS2RemoteRefNotGatedWhenPolicyNotConfigured() throws Exception { + // Backwards compatibility: with the policy disabled, the OAS2 gate must not engage even with a block list + // present, so the legacy parser resolves refs as before; the .invalid fixture host makes the fetch fail fast. + String def = IOUtils.toString(getClass().getClassLoader().getResourceAsStream( + "definitions/oas2/ref_backcompat_invalidhost.json"), "UTF-8"); + OASParserOptions opts = new OASParserOptions(); + opts.setRemoteRefBlockList(Arrays.asList("blocked.invalid")); + // networkAccessControlEnabled deliberately left false (its default). + + APIDefinitionValidationResponse resp = OASParserUtil.validateAPIDefinition(def, true, opts); + + boolean rejectedByPolicy = resp.getErrorItems().stream().anyMatch(e -> e.getErrorDescription() != null + && e.getErrorDescription().contains("not permitted by the network access control policy")); + Assert.assertFalse("The remote-ref gate must not engage when no policy is configured", rejectedByPolicy); + } + + @Test + public void testOAS2NestedRemoteRefIsRejected() throws Exception { + // Nested-ref case: an OAS2 top-level $ref to an allowed host whose document carries a nested $ref to a + // blocked host; the safe resolver validates every ref it crawls, so the nested block must be rejected. + com.sun.net.httpserver.HttpServer server = + com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1", 0), 0); + String outerBody = "{\"definitions\":{\"Nested\":{\"$ref\":\"http://169.254.169.254/latest/meta-data\"}}}"; + server.createContext("/outer.json", exchange -> { + byte[] body = outerBody.getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + int port = server.getAddress().getPort(); + String def = "{\"swagger\":\"2.0\",\"info\":{\"title\":\"t\",\"version\":\"1.0.0\"}," + + "\"paths\":{\"/x\":{\"get\":{\"responses\":{\"200\":{\"description\":\"ok\"," + + "\"schema\":{\"$ref\":\"http://127.0.0.1:" + port + "/outer.json#/definitions/Nested\"}}}}}}}"; + OASParserOptions opts = new OASParserOptions(); + opts.setNetworkAccessControlEnabled(true); + opts.setRemoteRefAllowList(Arrays.asList("127.0.0.1")); + opts.setRemoteRefBlockList(Arrays.asList("169.254.169.254")); + try { + OASParserUtil.validateAPIDefinition(def, true, opts); + Assert.fail("A nested remote $ref to a blocked host must be rejected"); + } catch (APIManagementException e) { + Assert.assertEquals("A nested blocked remote $ref must surface as UNTRUSTED_URL_IN_DEFINITION", + ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), e.getErrorHandler().getErrorCode()); + } + } finally { + server.stop(0); + } + } } diff --git a/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/java/org/wso2/carbon/apimgt/spec/parser/definitions/OASParserUtilArchiveRefTest.java b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/java/org/wso2/carbon/apimgt/spec/parser/definitions/OASParserUtilArchiveRefTest.java new file mode 100644 index 000000000000..57408086b186 --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/java/org/wso2/carbon/apimgt/spec/parser/definitions/OASParserUtilArchiveRefTest.java @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + */ + +package org.wso2.carbon.apimgt.spec.parser.definitions; + +import com.sun.net.httpserver.HttpServer; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.wso2.carbon.apimgt.api.APIDefinitionValidationResponse; +import org.wso2.carbon.apimgt.api.APIManagementException; +import org.wso2.carbon.apimgt.api.ExceptionCodes; +import org.wso2.carbon.apimgt.api.model.OASParserOptions; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Tests that {@link OASParserUtil#extractAndValidateOpenAPIArchive} routes embedded remote {@code $ref}s in the + * archive master document through the network access-control safe URL resolver for OAS 3 archives, so a blocked + * remote reference is never fetched, while local sibling references inside the archive still resolve. + */ +public class OASParserUtilArchiveRefTest { + + private HttpServer server; + private int serverPort; + private final AtomicInteger requestCount = new AtomicInteger(0); + + private static final String FRAGMENT_SCHEMA = + "type: object\n" + + "properties:\n" + + " name:\n" + + " type: string\n"; + + @Before + public void startServer() throws IOException { + requestCount.set(0); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + // Bind the root context so ANY request path to the loopback server is counted. + server.createContext("/", exchange -> { + requestCount.incrementAndGet(); + byte[] body = FRAGMENT_SCHEMA.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + server.start(); + serverPort = server.getAddress().getPort(); + } + + @After + public void stopServer() { + if (server != null) { + server.stop(0); + } + } + + /** + * A network access-control policy that mirrors the safe-resolver setup used by the inline/URL OAS3 tests: + * access control enabled and the loopback host explicitly blocked. + */ + private OASParserOptions blockLoopbackOptions() { + OASParserOptions options = new OASParserOptions(); + options.setNetworkAccessControlEnabled(true); + options.setRemoteRefBlockList(Arrays.asList("127.0.0.1")); + return options; + } + + private byte[] buildZip(Map entries) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (Map.Entry entry : entries.entrySet()) { + zos.putNextEntry(new ZipEntry(entry.getKey())); + zos.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } + return baos.toByteArray(); + } + + @Test + public void testArchiveRemoteRefIsBlockedAndNotFetched() throws Exception { + String master = + "openapi: 3.0.0\n" + + "info:\n" + + " title: Archive Remote Ref API\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " summary: list\n" + + " responses:\n" + + " '200':\n" + + " description: OK\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $ref: 'http://127.0.0.1:" + serverPort + "/fragment.yaml'\n"; + + Map entries = new LinkedHashMap<>(); + // Single root folder as required by the extractor. + entries.put("archive/swagger.yaml", master); + byte[] zipBytes = buildZip(entries); + + try { + OASParserUtil.extractAndValidateOpenAPIArchive( + new ByteArrayInputStream(zipBytes), false, blockLoopbackOptions()); + Assert.fail("An archive whose master carries a blocked remote $ref must be rejected"); + } catch (APIManagementException e) { + Assert.assertEquals("A blocked remote $ref must surface as UNTRUSTED_URL_IN_DEFINITION", + ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), e.getErrorHandler().getErrorCode()); + } + + Assert.assertEquals("A blocked remote $ref in an archived OpenAPI master must NOT be fetched " + + "(zero HTTP requests expected against the loopback server)", 0, requestCount.get()); + } + + @Test + public void testArchiveLocalSiblingRefStillResolves() throws Exception { + String master = + "openapi: 3.0.0\n" + + "info:\n" + + " title: Archive Local Ref API\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " summary: list\n" + + " responses:\n" + + " '200':\n" + + " description: OK\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $ref: './definitions.yaml#/Pet'\n"; + String definitions = + "Pet:\n" + + " type: object\n" + + " properties:\n" + + " id:\n" + + " type: integer\n" + + " name:\n" + + " type: string\n"; + + Map entries = new LinkedHashMap<>(); + entries.put("archive/swagger.yaml", master); + entries.put("archive/definitions.yaml", definitions); + byte[] zipBytes = buildZip(entries); + + APIDefinitionValidationResponse response = OASParserUtil.extractAndValidateOpenAPIArchive( + new ByteArrayInputStream(zipBytes), false, blockLoopbackOptions()); + + Assert.assertEquals("A local sibling $ref inside the archive must resolve from disk without any HTTP fetch", + 0, requestCount.get()); + Assert.assertTrue("A multi-file archive whose master references a local sibling file must still validate", + response.isValid()); + } + + private static String masterWithSchemaRef(String title, String ref) { + return "openapi: 3.0.0\n" + + "info:\n" + + " title: " + title + "\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " summary: list\n" + + " responses:\n" + + " '200':\n" + + " description: OK\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $ref: '" + ref + "'\n"; + } + + @Test + public void testArchivePathTraversalRefIsRejected() throws Exception { + Map entries = new LinkedHashMap<>(); + entries.put("archive/swagger.yaml", + masterWithSchemaRef("Archive Traversal API", "../../../../../../etc/passwd#/root")); + byte[] zipBytes = buildZip(entries); + + try { + OASParserUtil.extractAndValidateOpenAPIArchive( + new ByteArrayInputStream(zipBytes), false, blockLoopbackOptions()); + Assert.fail("An archive whose master carries a path-traversal $ref must be rejected"); + } catch (APIManagementException e) { + Assert.assertEquals("A traversal $ref must surface as UNTRUSTED_URL_IN_DEFINITION", + ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), e.getErrorHandler().getErrorCode()); + } + } + + @Test + public void testArchiveFileSchemeRefIsRejected() throws Exception { + Map entries = new LinkedHashMap<>(); + entries.put("archive/swagger.yaml", + masterWithSchemaRef("Archive File Scheme API", "file:///etc/passwd#/root")); + byte[] zipBytes = buildZip(entries); + + try { + OASParserUtil.extractAndValidateOpenAPIArchive( + new ByteArrayInputStream(zipBytes), false, blockLoopbackOptions()); + Assert.fail("An archive whose master carries a file: $ref must be rejected"); + } catch (APIManagementException e) { + Assert.assertEquals("A file: $ref must surface as UNTRUSTED_URL_IN_DEFINITION", + ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), e.getErrorHandler().getErrorCode()); + } + } + + @Test + public void testArchivePathTraversalRejectedWithoutPolicy() throws Exception { + // Local-reference containment is enforced even when no network access-control policy is configured. + Map entries = new LinkedHashMap<>(); + entries.put("archive/swagger.yaml", + masterWithSchemaRef("Archive Traversal No Policy API", "../../../../../../etc/passwd#/root")); + byte[] zipBytes = buildZip(entries); + + try { + OASParserUtil.extractAndValidateOpenAPIArchive(new ByteArrayInputStream(zipBytes), false, null); + Assert.fail("Local-reference containment must apply even without a network access-control policy"); + } catch (APIManagementException e) { + Assert.assertEquals(ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), + e.getErrorHandler().getErrorCode()); + } + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/java/org/wso2/carbon/apimgt/spec/parser/definitions/OASParserUtilUntrustedUrlTest.java b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/java/org/wso2/carbon/apimgt/spec/parser/definitions/OASParserUtilUntrustedUrlTest.java new file mode 100644 index 000000000000..6f2426992071 --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/java/org/wso2/carbon/apimgt/spec/parser/definitions/OASParserUtilUntrustedUrlTest.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + */ + +package org.wso2.carbon.apimgt.spec.parser.definitions; + +import com.sun.net.httpserver.HttpServer; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.wso2.carbon.apimgt.api.APIManagementException; +import org.wso2.carbon.apimgt.api.ExceptionCodes; +import org.wso2.carbon.apimgt.api.model.OASParserOptions; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Locks the exact swagger-parser message substrings that {@link OASParserUtil#isUntrustedUrlInDefinition} relies on + * to recognise a remote {@code $ref} that the safe URL resolver refused under the network access control policy. A + * swagger-parser bump that reworded any of these messages would silently break the mapping (a blocked ref would no + * longer be classified as untrusted-in-definition); these tests fail loudly if that happens. + */ +public class OASParserUtilUntrustedUrlTest { + + /** + * The seven message fragments emitted by the swagger-parser safe URL resolver when it refuses a remote ref. + * Kept in lock-step with {@link OASParserUtil#isUntrustedUrlInDefinition}. + */ + private static final String[] UNTRUSTED_URL_FRAGMENTS = { + "is restricted. URL [", + "is part of the explicit denylist", + "does not use a supported protocol. URL [", + "Failed to resolve IP from hostname. Hostname [", + "Failed to get hostname from URL. URL [", + "Failed to create new URL with IP.", + "Failed to parse URL. URL [" + }; + + private HttpServer server; + private int serverPort; + private final AtomicInteger requestCount = new AtomicInteger(0); + + @Before + public void startServer() throws IOException { + requestCount.set(0); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + // Serve a valid schema fragment for ANY path so that, if the ref were NOT blocked, resolution would succeed + // (and the block-detection test would fail clearly rather than through an unrelated connection error). + server.createContext("/", exchange -> { + requestCount.incrementAndGet(); + byte[] body = ("{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}}}") + .getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + server.start(); + serverPort = server.getAddress().getPort(); + } + + @After + public void stopServer() { + if (server != null) { + server.stop(0); + } + } + + /** + * Each of the seven locked fragments, wrapped in realistic surrounding text, must be classified as an + * untrusted-URL-in-definition signal. + */ + @Test + public void testEachFragmentIsClassifiedAsUntrusted() { + for (String fragment : UNTRUSTED_URL_FRAGMENTS) { + String message = "Resolution failed: the referenced host " + fragment + " http://example.com/ref.yaml]"; + Assert.assertTrue("Fragment must be recognised as an untrusted-URL-in-definition signal: " + fragment, + OASParserUtil.isUntrustedUrlInDefinition(message)); + } + } + + /** + * A null message and an unrelated parser message must NOT be classified as untrusted-URL-in-definition signals. + */ + @Test + public void testUnrelatedMessagesAreNotClassifiedAsUntrusted() { + Assert.assertFalse("A null message must not be classified as untrusted", + OASParserUtil.isUntrustedUrlInDefinition(null)); + Assert.assertFalse("An unrelated parser message must not be classified as untrusted", + OASParserUtil.isUntrustedUrlInDefinition("attribute swagger or openapi should present")); + } + + /** + * Drives the real pinned swagger-parser against an OpenAPI 2.0 definition whose remote {@code $ref} targets a + * blocked loopback host. The only way this surfaces as {@link ExceptionCodes#UNTRUSTED_URL_IN_DEFINITION} is if + * the library's block message still matches one of the locked fragments - so a parser bump that reworded the + * message breaks this test, and no HTTP request must reach the loopback server. + */ + @Test + public void testRealParserBlockedRemoteRefMapsToUntrusted() throws Exception { + String swagger20 = + "swagger: \"2.0\"\n" + + "info:\n" + + " title: Blocked Remote Ref API\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: OK\n" + + " schema:\n" + + " $ref: 'http://127.0.0.1:" + serverPort + "/fragment.yaml'\n"; + + OASParserOptions options = new OASParserOptions(); + options.setNetworkAccessControlEnabled(true); + options.setRemoteRefBlockList(Arrays.asList("127.0.0.1")); + + try { + new OAS2Parser().validateAPIDefinition(swagger20, false, options); + Assert.fail("An OAS 2.0 definition carrying a blocked remote $ref must be rejected"); + } catch (APIManagementException e) { + Assert.assertNotNull("The rejection must carry an error handler", e.getErrorHandler()); + Assert.assertEquals("A blocked remote $ref must surface as UNTRUSTED_URL_IN_DEFINITION", + ExceptionCodes.UNTRUSTED_URL_IN_DEFINITION.getErrorCode(), e.getErrorHandler().getErrorCode()); + } + + Assert.assertEquals("A blocked remote $ref must NOT be fetched (zero HTTP requests expected)", + 0, requestCount.get()); + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/resources/definitions/oas2/ref_backcompat_invalidhost.json b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/resources/definitions/oas2/ref_backcompat_invalidhost.json new file mode 100644 index 000000000000..b53c0cd19de6 --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/resources/definitions/oas2/ref_backcompat_invalidhost.json @@ -0,0 +1,17 @@ +{ + "swagger": "2.0", + "info": { "title": "backcompat-ref", "version": "1.0.0" }, + "basePath": "/backcompat-ref/1.0.0", + "paths": { + "/x": { + "get": { + "responses": { + "200": { + "description": "ok", + "schema": { "$ref": "http://blocked.invalid/x.json#/X" } + } + } + } + } + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/resources/definitions/oas2/ref_blocked_loopback.json b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/resources/definitions/oas2/ref_blocked_loopback.json new file mode 100644 index 000000000000..269e60a5217c --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/resources/definitions/oas2/ref_blocked_loopback.json @@ -0,0 +1,17 @@ +{ + "swagger": "2.0", + "info": { "title": "blocked-ref", "version": "1.0.0" }, + "basePath": "/blocked-ref/1.0.0", + "paths": { + "/x": { + "get": { + "responses": { + "200": { + "description": "ok", + "schema": { "$ref": "http://169.254.169.254/x.json#/X" } + } + } + } + } + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/resources/definitions/oas2/ref_blocked_loopback.yaml b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/resources/definitions/oas2/ref_blocked_loopback.yaml new file mode 100644 index 000000000000..e19b652c3b7a --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/resources/definitions/oas2/ref_blocked_loopback.yaml @@ -0,0 +1,15 @@ +# Swagger 2.0 definition (YAML) with a disallowed remote $ref. Regression fixture: a YAML OAS2 body must be +# scanned for remote $refs by the network access control gate, not only a JSON one. +swagger: "2.0" +info: + title: blocked-ref-yaml + version: 1.0.0 +basePath: /blocked-ref-yaml/1.0.0 +paths: + /x: + get: + responses: + '200': + description: ok + schema: + $ref: 'http://169.254.169.254/x.json#/X' diff --git a/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/resources/definitions/oas2/ref_clean_no_remote.json b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/resources/definitions/oas2/ref_clean_no_remote.json new file mode 100644 index 000000000000..87de7ffd311a --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/resources/definitions/oas2/ref_clean_no_remote.json @@ -0,0 +1,25 @@ +{ + "swagger": "2.0", + "info": { "title": "clean-no-remote-ref", "version": "1.0.0" }, + "basePath": "/clean-no-remote-ref/1.0.0", + "paths": { + "/x": { + "get": { + "responses": { + "200": { + "description": "ok", + "schema": { "$ref": "#/definitions/X" } + } + } + } + } + }, + "definitions": { + "X": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + } +} diff --git a/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/resources/definitions/oas3/ref_blocked_loopback.json b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/resources/definitions/oas3/ref_blocked_loopback.json new file mode 100644 index 000000000000..5588c2b700a9 --- /dev/null +++ b/components/apimgt/org.wso2.carbon.apimgt.spec.parser/src/test/resources/definitions/oas3/ref_blocked_loopback.json @@ -0,0 +1,20 @@ +{ + "openapi": "3.0.1", + "info": { "title": "blocked-ref", "version": "1.0.0" }, + "paths": { + "/x": { + "get": { + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { "$ref": "http://169.254.169.254/x.yaml#/X" } + } + } + } + } + } + } + } +} diff --git a/features/apimgt/org.wso2.carbon.apimgt.core.feature/src/main/resources/conf_templates/templates/repository/conf/api-manager.xml.j2 b/features/apimgt/org.wso2.carbon.apimgt.core.feature/src/main/resources/conf_templates/templates/repository/conf/api-manager.xml.j2 index b2db8d5688b0..a34a445cad49 100644 --- a/features/apimgt/org.wso2.carbon.apimgt.core.feature/src/main/resources/conf_templates/templates/repository/conf/api-manager.xml.j2 +++ b/features/apimgt/org.wso2.carbon.apimgt.core.feature/src/main/resources/conf_templates/templates/repository/conf/api-manager.xml.j2 @@ -2239,4 +2239,18 @@ {{apim.mediation.enable_secure_xml_processing}} + {% if server.network_security.access_control is defined %} + + true + {% if server.network_security.access_control.mode is defined %} + {{server.network_security.access_control.mode}} + {% endif %} + {{server.network_security.access_control.block_private_network_access | default("false")}} + {% if server.network_security.access_control.hosts is defined %} + {% for host in server.network_security.access_control.hosts %} + {{host}} + {% endfor %} + {% endif %} + + {% endif %}