Repository navigation
Add deploy-from-url validation #1920
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| package org.cloudfoundry.multiapps.controller.core.util; | ||
|
|
||
| import java.net.InetAddress; | ||
| import java.net.URI; | ||
| import java.net.UnknownHostException; | ||
| import java.text.MessageFormat; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
|
|
||
| import jakarta.inject.Inject; | ||
| import jakarta.inject.Named; | ||
| import org.cloudfoundry.multiapps.common.SLException; | ||
| import org.cloudfoundry.multiapps.controller.core.Messages; | ||
|
|
||
| @Named | ||
| public class AddressValidator { | ||
|
|
||
| private final boolean validationEnabled; | ||
| private final List<CidrRange> denyList; | ||
|
|
||
| @Inject | ||
| public AddressValidator(ApplicationConfiguration applicationConfiguration) { | ||
| this.validationEnabled = applicationConfiguration.isDeployFromUrlAddressValidationEnabled(); | ||
| this.denyList = parseCidrRanges(applicationConfiguration.getDeployFromUrlAddressDenyList()); | ||
| } | ||
|
|
||
| public void validateTarget(String url, String jobId) { | ||
| if (!validationEnabled) { | ||
| return; | ||
| } | ||
| String host = extractHost(url); | ||
| InetAddress[] addresses = resolveAddresses(host); | ||
| for (InetAddress address : addresses) { | ||
| if (isAddressDenied(address)) { | ||
| throw new SLException( | ||
| MessageFormat.format(Messages.DEPLOY_FROM_URL_TARGET_ADDRESS_0_DENIED_FOR_JOB_WITH_ID_1, host, jobId)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private boolean isAddressDenied(InetAddress address) { | ||
| if (isReservedAddress(address)) { | ||
| return true; | ||
| } | ||
| byte[] candidate = address.getAddress(); | ||
| for (CidrRange range : denyList) { | ||
| if (range.containsAddress(candidate)) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| private boolean isReservedAddress(InetAddress address) { | ||
| return address.isLoopbackAddress() || address.isLinkLocalAddress() || address.isAnyLocalAddress() || address.isSiteLocalAddress(); | ||
| } | ||
|
|
||
| private String extractHost(String url) { | ||
| String host = URI.create(url) | ||
| .getHost(); | ||
| if (host == null) { | ||
| throw new SLException(MessageFormat.format(Messages.INVALID_URL, url)); | ||
| } | ||
| return host; | ||
| } | ||
|
|
||
| protected InetAddress[] resolveAddresses(String host) { | ||
| try { | ||
| return InetAddress.getAllByName(host); | ||
| } catch (UnknownHostException e) { | ||
| throw new SLException(e, MessageFormat.format(Messages.DEPLOY_FROM_URL_CANNOT_RESOLVE_HOST, host)); | ||
| } | ||
| } | ||
|
|
||
| private static List<CidrRange> parseCidrRanges(String denyList) { | ||
| List<CidrRange> ranges = new ArrayList<>(); | ||
| if (denyList == null || denyList.isBlank()) { | ||
| return ranges; | ||
| } | ||
| for (String entry : denyList.split(",")) { | ||
| String trimmed = entry.trim(); | ||
| if (!trimmed.isEmpty()) { | ||
| ranges.add(CidrRange.fromCidrNotation(trimmed)); | ||
| } | ||
| } | ||
| return ranges; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| package org.cloudfoundry.multiapps.controller.core.util; | ||
|
|
||
| import java.net.InetAddress; | ||
| import java.net.UnknownHostException; | ||
| import java.text.MessageFormat; | ||
|
|
||
| import org.cloudfoundry.multiapps.common.SLException; | ||
| import org.cloudfoundry.multiapps.controller.core.Messages; | ||
|
|
||
| // CIDR (Classless Inter-Domain Routing) notation describes an IP address range as a base address | ||
| // plus a prefix length, e.g. 10.0.0.0/8 or fc00::/7 — the prefix is how many leading bits are fixed. | ||
| public class CidrRange { | ||
|
|
||
| private final byte[] networkAddressBytes; | ||
| private final int prefixLengthBits; | ||
|
|
||
| private CidrRange(byte[] networkAddressBytes, int prefixLengthBits) { | ||
| this.networkAddressBytes = networkAddressBytes; | ||
| this.prefixLengthBits = prefixLengthBits; | ||
| } | ||
|
|
||
| public static CidrRange fromCidrNotation(String cidr) { | ||
| String[] addressAndMaskParts = cidr.split("/", 2); | ||
| byte[] networkAddressBytes = resolveNetworkAddressBytes(addressAndMaskParts[0], cidr); | ||
| boolean hasMask = addressAndMaskParts.length == 2; | ||
| int maskBits; | ||
| if (hasMask) { | ||
| maskBits = Integer.parseInt(addressAndMaskParts[1].trim()); | ||
| } else { | ||
| maskBits = networkAddressBytes.length * 8; | ||
| } | ||
| return new CidrRange(networkAddressBytes, maskBits); | ||
| } | ||
|
|
||
| private static byte[] resolveNetworkAddressBytes(String networkAddress, String cidr) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. here we are resolving the denyList where if there is an error, we would get the same generic message regarding the Url and when checking the logs I think it would be difficult to unhesitant since one log "Invalid URL " might be related to the url provided for downloading the MTAR, and another log with the same message might be regarding the denied list of urls. |
||
| try { | ||
| return InetAddress.getByName(networkAddress) | ||
| .getAddress(); | ||
| } catch (UnknownHostException e) { | ||
| throw new SLException(e, MessageFormat.format(Messages.DEPLOY_FROM_URL_INVALID_DENY_LIST_ENTRY, cidr)); | ||
| } | ||
| } | ||
|
|
||
| public boolean containsAddress(byte[] candidateAddressBytes) { | ||
| if (candidateAddressBytes.length != networkAddressBytes.length) { | ||
| return false; | ||
| } | ||
| return matchesFullOctets(candidateAddressBytes) && matchesPartialOctet(candidateAddressBytes); | ||
| } | ||
|
|
||
| private boolean matchesFullOctets(byte[] candidateAddressBytes) { | ||
| int fullPrefixBytes = prefixLengthBits / 8; | ||
| for (int i = 0; i < fullPrefixBytes; i++) { | ||
| if (candidateAddressBytes[i] != networkAddressBytes[i]) { | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| private boolean matchesPartialOctet(byte[] candidateAddressBytes) { | ||
| int remainingBitsInPartialOctet = prefixLengthBits % 8; | ||
| if (remainingBitsInPartialOctet == 0) { | ||
| return true; | ||
| } | ||
| int partialOctetBitMask = 0xFF << (8 - remainingBitsInPartialOctet) & 0xFF; | ||
| int fullPrefixBytes = prefixLengthBits / 8; | ||
| return (candidateAddressBytes[fullPrefixBytes] & partialOctetBitMask) == (networkAddressBytes[fullPrefixBytes] & partialOctetBitMask); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| package org.cloudfoundry.multiapps.controller.core.util; | ||
|
|
||
| import java.net.InetAddress; | ||
| import java.net.UnknownHostException; | ||
| import java.util.stream.Stream; | ||
|
|
||
| import org.cloudfoundry.multiapps.common.SLException; | ||
| import org.junit.jupiter.api.Assertions; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.params.ParameterizedTest; | ||
| import org.junit.jupiter.params.provider.Arguments; | ||
| import org.junit.jupiter.params.provider.MethodSource; | ||
| import org.mockito.Mockito; | ||
|
|
||
| class AddressValidatorTest { | ||
|
|
||
| private static final String DENY_LIST = "127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,100.100.100.200/32,::1/128,fe80::/10,fc00::/7"; | ||
|
|
||
| static Stream<Arguments> validateTargetRejectsDeniedAddress() { | ||
| return Stream.of(Arguments.of("https://loopback.test/file", "127.0.0.1"), | ||
| Arguments.of("https://loopback.test/file", "127.5.6.7"), | ||
| Arguments.of("https://rfc1918-10.test/file", "10.1.2.3"), | ||
| Arguments.of("https://rfc1918-172.test/file", "172.16.5.5"), | ||
| Arguments.of("https://rfc1918-192.test/file", "192.168.0.1"), | ||
| Arguments.of("https://linklocal.test/file", "169.254.1.1"), | ||
| Arguments.of("https://metadata.test/file", "169.254.169.254"), | ||
| Arguments.of("https://alicloud-metadata.test/file", "100.100.100.200"), | ||
| Arguments.of("https://ipv6-loopback.test/file", "::1"), | ||
| Arguments.of("https://ipv6-linklocal.test/file", "fe80::1"), | ||
| Arguments.of("https://ipv6-ula.test/file", "fc00::1")); | ||
| } | ||
|
|
||
| @MethodSource | ||
| @ParameterizedTest | ||
| void validateTargetRejectsDeniedAddress(String url, String resolvedIp) { | ||
| AddressValidator validator = validatorResolvingTo(resolvedIp); | ||
| Assertions.assertThrows(SLException.class, () -> validator.validateTarget(url, "job-1")); | ||
| } | ||
|
|
||
| static Stream<Arguments> validateTargetAllowsPublicAddress() { | ||
| return Stream.of(Arguments.of("https://public.test/file", "8.8.8.8"), | ||
| Arguments.of("https://public.test/file", "93.184.216.34"), | ||
| Arguments.of("https://alicloud-near.test/file", "100.100.100.199"), | ||
| Arguments.of("https://ipv6-public.test/file", "2001:4860:4860::8888")); | ||
| } | ||
|
|
||
| @MethodSource | ||
| @ParameterizedTest | ||
| void validateTargetAllowsPublicAddress(String url, String resolvedIp) { | ||
| AddressValidator validator = validatorResolvingTo(resolvedIp); | ||
| Assertions.assertDoesNotThrow(() -> validator.validateTarget(url, "job-1")); | ||
| } | ||
|
|
||
| @Test | ||
| void validateTargetRejectsWhenAnyResolvedAddressIsDenied() { | ||
| AddressValidator validator = validatorResolvingTo("8.8.8.8", "127.0.0.1"); | ||
| Assertions.assertThrows(SLException.class, () -> validator.validateTarget("https://mixed.test/file", "job-1")); | ||
| } | ||
|
|
||
| @Test | ||
| void validateTargetRejectsUrlWithoutHost() { | ||
| AddressValidator validator = validatorResolvingTo("8.8.8.8"); | ||
| Assertions.assertThrows(SLException.class, () -> validator.validateTarget("file:///etc/passwd", "job-1")); | ||
| } | ||
|
|
||
| private static AddressValidator validatorResolvingTo(String... ips) { | ||
| ApplicationConfiguration configuration = Mockito.mock(ApplicationConfiguration.class); | ||
| Mockito.when(configuration.isDeployFromUrlAddressValidationEnabled()) | ||
| .thenReturn(true); | ||
| Mockito.when(configuration.getDeployFromUrlAddressDenyList()) | ||
| .thenReturn(DENY_LIST); | ||
| return new AddressValidator(configuration) { | ||
| @Override | ||
| protected InetAddress[] resolveAddresses(String host) { | ||
| try { | ||
| InetAddress[] addresses = new InetAddress[ips.length]; | ||
| for (int i = 0; i < ips.length; i++) { | ||
| addresses[i] = InetAddress.getByName(ips[i]); | ||
| } | ||
| return addresses; | ||
| } catch (UnknownHostException e) { | ||
| throw new IllegalStateException(e); | ||
| } | ||
| } | ||
| }; | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.