SsrfValidator.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * 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.entrystore.rest.springboot.security;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.entrystore.repository.config.Settings;
import org.entrystore.rest.springboot.configuration.IndexedListSettings;
import org.entrystore.rest.springboot.configuration.ProxyProperties;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.model.exception.ForbiddenException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.Socket;
import java.net.SocketException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;

@Slf4j
@Component
public class SsrfValidator {

	// Provides the timeouts applied per hop on both outbound paths that use
	// openPinnedConnection: GET /proxy and DELETE /{context-id}/resource/{entry-id}?proxy=true.
	private final ProxyProperties proxyProperties;
	private final Environment environment;
	private final String rowstoreUrl;

	private Set<String> proxyHostWhitelist;
	private Set<Origin> deleteOriginWhitelist;
	private Origin rowstoreOrigin;

	private static final Set<String> ALLOWED_SCHEMES = Set.of("http", "https");

	private static final String ALLOW_RESTRICTED_HEADERS = "sun.net.http.allowRestrictedHeaders";

	private static final List<Pattern> BLACKLIST_REGEX = List.of(
			Pattern.compile("^localhost$"),                                   // localhost
			Pattern.compile("(.+)\\.local$"),                                // any local domains
			Pattern.compile("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"), // IPv4
			Pattern.compile("^\\d+$"),                                       // numeric IPv4 representation (e.g. 2130706433)
			Pattern.compile(":")                                             // IPv6
	);

	/**
	 * Canonical origin record used for DELETE-path trust decisions. The compact constructor
	 * enforces non-null scheme/host and port {@code > 0}; producers ({@link #parseOrigin} and
	 * {@link #toOrigin}) lowercase scheme/host and substitute the scheme's default port so
	 * {@code http://x} and {@code http://x:80} compare equal.
	 */
	record Origin(String scheme, String host, int port) {
		Origin {
			Objects.requireNonNull(scheme, "scheme");
			Objects.requireNonNull(host, "host");
			if (port <= 0) {
				throw new IllegalArgumentException("port must be > 0, got " + port);
			}
		}
	}

	/**
	 * Validated proxy/DELETE target: the parsed URI, the lowercased host (consistent with
	 * {@code uri.getHost().toLowerCase(Locale.ROOT)}), and the DNS-resolved address that
	 * callers MUST pin connections to. All fields are non-null.
	 */
	public record ValidatedTarget(URI uri, String host, InetAddress resolved) {
		public ValidatedTarget {
			Objects.requireNonNull(uri, "uri");
			Objects.requireNonNull(host, "host");
			Objects.requireNonNull(resolved, "resolved");
		}
	}

	public SsrfValidator(ProxyProperties proxyProperties, Environment environment,
			@Value("${entrystore.rowstore.url:#{null}}") String rowstoreUrl) {
		this.proxyProperties = proxyProperties;
		this.environment = environment;
		this.rowstoreUrl = rowstoreUrl;
	}

	@PostConstruct
	void init() {
		proxyHostWhitelist = IndexedListSettings.readHosts(environment, Settings.PROXY_WHITELIST_LOCAL);
		if (!proxyHostWhitelist.isEmpty()) {
			log.info("Proxy GET local whitelist (host-only) initialized with: {}", String.join(", ", proxyHostWhitelist));
		}

		deleteOriginWhitelist = toOriginSet(IndexedListSettings.read(environment,
				Settings.PROXY_REMOTE_RESOURCE_DELETE_WHITELIST), Settings.PROXY_REMOTE_RESOURCE_DELETE_WHITELIST);
		if (!deleteOriginWhitelist.isEmpty()) {
			log.info("Resource DELETE origin whitelist initialized with: {}",
					deleteOriginWhitelist.stream().map(Origin::toString).toList());
		}

		rowstoreOrigin = parseOrigin(rowstoreUrl);
		if (rowstoreOrigin != null) {
			log.info("Resource DELETE auto-trusts RowStore origin: {}", rowstoreOrigin);
		} else if (rowstoreUrl != null && !rowstoreUrl.isBlank()) {
			log.warn("entrystore.rowstore.url is set but did not parse as an http(s) origin; "
					+ "DELETE auto-trust is disabled. Value: {}", rowstoreUrl);
		}

		log.info("SSRF blacklist consists of following regular expressions: {}", BLACKLIST_REGEX);

		if (!isHostHeaderOverrideEffective()) {
			log.error("HttpURLConnection drops the Host header, so proxy and remote resource DELETE requests will "
					+ "fail; set -D{}=true on the JVM command line", ALLOW_RESTRICTED_HEADERS);
		}
	}

	/**
	 * Whether {@link #openPinnedConnection} can set the {@code Host} header. The JDK reads
	 * {@value #ALLOW_RESTRICTED_HEADERS} once, when {@code HttpURLConnection} is first initialized,
	 * and otherwise drops restricted headers without an error. A probe that cannot be opened is
	 * reported as effective; {@link #setHostHeader} still guards every request.
	 */
	static boolean isHostHeaderOverrideEffective() {
		HttpURLConnection probe;
		try {
			probe = (HttpURLConnection) URI.create("http://127.0.0.1/").toURL().openConnection();
		} catch (IOException e) {
			log.warn("Could not probe whether HttpURLConnection accepts the Host header", e);
			return true;
		}
		probe.setRequestProperty("Host", "probe.invalid");
		return "probe.invalid".equals(probe.getRequestProperty("Host"));
	}

	private static Set<Origin> toOriginSet(Collection<String> values, String settingKeyForLog) {
		Set<Origin> result = new LinkedHashSet<>();
		for (String entry : values) {
			Origin origin = parseOrigin(entry);
			if (origin != null) {
				result.add(origin);
			} else if (!entry.isBlank()) {
				log.warn("Skipping malformed origin in {}: {}", settingKeyForLog, entry);
			}
		}
		return result;
	}

	/**
	 * Canonicalizes a URL into an {@link Origin} (scheme/host lowercased with Locale.ROOT;
	 * default port substituted). Returns {@code null} for null/blank input, malformed input,
	 * missing scheme/host, or any scheme outside {@link #ALLOWED_SCHEMES}, so callers can
	 * log-and-skip.
	 */
	static Origin parseOrigin(String urlString) {
		if (urlString == null || urlString.isBlank()) {
			return null;
		}
		URI uri;
		try {
			uri = new URI(urlString.trim());
		} catch (URISyntaxException e) {
			return null;
		}
		if (uri.getScheme() == null || uri.getHost() == null) {
			return null;
		}
		return toOrigin(uri);
	}

	/**
	 * Canonicalizes an already-parsed URI into an {@link Origin}. Returns {@code null} if the
	 * URI's scheme is not in {@link #ALLOWED_SCHEMES}. Caller must ensure {@code uri.getHost()}
	 * is non-null (the producer of the URI typically already enforces this).
	 */
	private static Origin toOrigin(URI uri) {
		String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
		if (!ALLOWED_SCHEMES.contains(scheme)) {
			return null;
		}
		int port = uri.getPort();
		if (port == -1) {
			port = defaultPort(scheme);
		}
		return new Origin(scheme, uri.getHost().toLowerCase(Locale.ROOT), port);
	}

	private static int defaultPort(String scheme) {
		return switch (scheme) {
			case "http" -> 80;
			case "https" -> 443;
			default -> -1;
		};
	}

	/**
	 * Cheap pre-auth validation: parses the URL and rejects malformed input, disallowed
	 * schemes, embedded credentials, and missing host. Does NOT perform DNS resolution or
	 * blacklist checks — callers may run this before authorization, then call
	 * {@link #resolveForProxy(URI)} or {@link #resolveForDelete(URI)} post-auth.
	 */
	public URI parseAndValidateUrl(String url) {
		URI uri;
		try {
			uri = new URI(url);
		} catch (URISyntaxException e) {
			throw new BadRequestException("Malformed URL: " + url);
		}
		String scheme = uri.getScheme();
		if (scheme == null || !ALLOWED_SCHEMES.contains(scheme.toLowerCase(Locale.ROOT))) {
			throw new BadRequestException("Only http and https URLs are supported");
		}
		if (uri.getUserInfo() != null) {
			throw new BadRequestException("URLs with embedded credentials are not allowed");
		}
		if (uri.getHost() == null) {
			throw new BadRequestException("URL has no host: " + url);
		}
		return uri;
	}

	/**
	 * Proxy GET path resolve step. Runs DNS resolution and applies the host blacklist; a
	 * whitelisted host (per {@link Settings#PROXY_WHITELIST_LOCAL}) suppresses BOTH the
	 * hostname regex blacklist AND the resolved-address loopback/site-local/link-local checks.
	 * Heavyweight — call after authorization.
	 */
	public ValidatedTarget resolveForProxy(URI uri) {
		String host = uri.getHost().toLowerCase(Locale.ROOT);
		return validate(uri, host, proxyHostWhitelist.contains(host));
	}

	/**
	 * Resource DELETE path resolve step. Runs DNS resolution and applies the host blacklist;
	 * a request whose origin matches {@link Settings#PROXY_REMOTE_RESOURCE_DELETE_WHITELIST}
	 * or the implicit {@link Settings#ROWSTORE_URL} origin suppresses BOTH the hostname
	 * blacklist AND the resolved-address loopback/site-local/link-local checks. Heavyweight —
	 * call after authorization.
	 */
	public ValidatedTarget resolveForDelete(URI uri) {
		Origin origin = toOrigin(uri);
		if (origin == null) {
			// uri.getScheme() failed ALLOWED_SCHEMES — shouldn't happen if parseAndValidateUrl was called first
			throw new BadRequestException("Only http and https URLs are supported");
		}
		boolean isTrustedOrigin = deleteOriginWhitelist.contains(origin)
				|| origin.equals(rowstoreOrigin);
		return validate(uri, origin.host(), isTrustedOrigin);
	}

	/**
	 * Combined parse + resolve for the proxy GET path. Equivalent to
	 * {@code resolveForProxy(parseAndValidateUrl(url))}. Used by {@code ProxyService.proxy}
	 * on each redirect hop where parse and resolve always happen together.
	 */
	public ValidatedTarget validateForProxy(String url) {
		return resolveForProxy(parseAndValidateUrl(url));
	}

	/**
	 * Combined parse + resolve for the resource DELETE path. Equivalent to
	 * {@code resolveForDelete(parseAndValidateUrl(url))}.
	 */
	public ValidatedTarget validateForDelete(String url) {
		return resolveForDelete(parseAndValidateUrl(url));
	}

	private ValidatedTarget validate(URI uri, String host, boolean trusted) {
		return new ValidatedTarget(uri, host, doValidateHost(host, trusted));
	}

	private InetAddress doValidateHost(String host, boolean isWhitelistedLocal) {
		if (!isWhitelistedLocal) {
			for (Pattern p : BLACKLIST_REGEX) {
				if (p.matcher(host).find()) {
					throw new ForbiddenException("Access denied: host is blacklisted");
				}
			}
		}

		InetAddress[] allAddresses;
		try {
			allAddresses = InetAddress.getAllByName(host);
		} catch (UnknownHostException e) {
			log.warn("Failed to resolve host: {}", host);
			throw new ForbiddenException("Access denied: host cannot be resolved");
		}

		for (InetAddress addr : allAddresses) {
			if (isDisallowedAddress(addr, isWhitelistedLocal)) {
				throw new ForbiddenException("Access denied: host resolves to a disallowed address");
			}
		}

		return allAddresses[0];
	}

	private boolean isDisallowedAddress(InetAddress address, boolean whitelistedLocal) {
		if (address.isAnyLocalAddress() || address.isMulticastAddress()) {
			return true;
		}
		if (!whitelistedLocal) {
			return address.isSiteLocalAddress() ||
					address.isLoopbackAddress() ||
					address.isLinkLocalAddress();
		}
		return false;
	}

	/**
	 * Opens an {@link HttpURLConnection} pinned to the resolved IP address with standard
	 * SSRF-safe defaults: connect/read timeouts applied, automatic redirects disabled, original
	 * hostname preserved in the {@code Host} header for virtual hosting, and (for HTTPS) SNI and
	 * certificate verification against the original hostname during the TLS handshake. Callers
	 * MUST re-run {@link #validateForProxy(String)} / {@link #validateForDelete(String)} on every
	 * redirect hop before reusing this method; the SSRF guarantee depends on it.
	 */
	public HttpURLConnection openPinnedConnection(URI originalUri, InetAddress resolved)
			throws IOException, URISyntaxException {
		URI pinnedUri = buildPinnedUri(originalUri, resolved);
		HttpURLConnection conn = (HttpURLConnection) pinnedUri.toURL().openConnection();
		conn.setInstanceFollowRedirects(false);
		conn.setConnectTimeout(proxyProperties.connectTimeoutMillis());
		conn.setReadTimeout(proxyProperties.readTimeoutMillis());
		setHostHeader(conn, buildHostHeader(originalUri));
		if (conn instanceof HttpsURLConnection httpsConn) {
			configureSsl(httpsConn, originalUri.getHost());
		}
		return conn;
	}

	/**
	 * Fails instead of sending the pinned IP as {@code Host}, which virtual-hosted upstreams would
	 * answer with another site's content or an error indistinguishable from a real response.
	 */
	static void setHostHeader(HttpURLConnection conn, String host) throws IOException {
		conn.setRequestProperty("Host", host);
		if (!host.equals(conn.getRequestProperty("Host"))) {
			throw new IOException("HttpURLConnection dropped the Host header; the JVM needs -D"
					+ ALLOW_RESTRICTED_HEADERS + "=true");
		}
	}

	/**
	 * Replaces only the authority of {@code originalUri} with the pinned IP. Assembled from the
	 * raw components because the multi-argument {@link URI} constructors always quote {@code %},
	 * which would double-encode the upstream path and query. The fragment is dropped; it is never
	 * sent over HTTP.
	 */
	static URI buildPinnedUri(URI originalUri, InetAddress resolved) throws URISyntaxException {
		StringBuilder pinned = new StringBuilder(originalUri.getScheme()).append("://");
		if (resolved instanceof Inet6Address) {
			pinned.append('[').append(resolved.getHostAddress()).append(']');
		} else {
			pinned.append(resolved.getHostAddress());
		}
		if (originalUri.getPort() != -1) {
			pinned.append(':').append(originalUri.getPort());
		}
		if (originalUri.getRawPath() != null) {
			pinned.append(originalUri.getRawPath());
		}
		if (originalUri.getRawQuery() != null) {
			pinned.append('?').append(originalUri.getRawQuery());
		}
		return new URI(pinned.toString());
	}

	private String buildHostHeader(URI uri) {
		int port = uri.getPort();
		String scheme = uri.getScheme();
		boolean isDefaultPort = port == -1
				|| (scheme != null && port == defaultPort(scheme.toLowerCase(Locale.ROOT)));
		return isDefaultPort ? uri.getHost() : uri.getHost() + ":" + port;
	}

	/**
	 * The certificate is verified against {@code originalHost} in the handshake (see
	 * {@link SniSSLSocketFactory}). The default hostname verifier is kept on purpose: the JDK
	 * checks a custom verifier against the URL host (the pinned IP), whereas with the default one
	 * it leaves verification to the handshake, so a lost endpoint identification still fails closed.
	 */
	private void configureSsl(HttpsURLConnection httpsConn, String originalHost) {
		httpsConn.setSSLSocketFactory(new SniSSLSocketFactory(httpsConn.getSSLSocketFactory(), originalHost));
	}

	void setProxyHostWhitelist(Set<String> proxyHostWhitelist) {
		this.proxyHostWhitelist = proxyHostWhitelist;
	}

	void setDeleteOriginWhitelist(Set<Origin> deleteOriginWhitelist) {
		this.deleteOriginWhitelist = deleteOriginWhitelist;
	}

	void setRowstoreOrigin(Origin rowstoreOrigin) {
		this.rowstoreOrigin = rowstoreOrigin;
	}

	/**
	 * Layers TLS for the ORIGINAL hostname over a TCP socket connected to the pinned IP. The
	 * original hostname is the SSL peer host, which drives SNI, the TLS session cache and, through
	 * endpoint identification (RFC 6125), certificate verification in the handshake. The JDK calls
	 * {@link #createSocket()}, connects it to the pinned IP, then layers TLS via
	 * {@link #createSocket(Socket, String, int, boolean)}. Overloads taking a host name throw
	 * because opening them would resolve DNS and bypass the pinning.
	 */
	@RequiredArgsConstructor
	static class SniSSLSocketFactory extends SSLSocketFactory {

		private final SSLSocketFactory delegate;
		private final String hostname;

		@Override
		public String[] getDefaultCipherSuites() {
			return delegate.getDefaultCipherSuites();
		}

		@Override
		public String[] getSupportedCipherSuites() {
			return delegate.getSupportedCipherSuites();
		}

		/**
		 * The {@code host} argument (the pinned IP) is ignored in favour of the original hostname.
		 */
		@Override
		public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
			SSLSocket ssl = (SSLSocket) delegate.createSocket(s, hostname, port, autoClose);
			SSLParameters params = ssl.getSSLParameters();
			params.setEndpointIdentificationAlgorithm("HTTPS");
			ssl.setSSLParameters(params);
			return ssl;
		}

		/**
		 * Returns a plain socket: an SSL socket here would make the JDK handshake with the pinned
		 * IP as peer host instead of layering TLS through the overload above.
		 */
		@Override
		public Socket createSocket() {
			return new Socket();
		}

		@Override
		public Socket createSocket(InetAddress host, int port) throws IOException {
			return createSocket(new Socket(host, port), host.getHostAddress(), port, true);
		}

		@Override
		public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort)
				throws IOException {
			return createSocket(new Socket(address, port, localAddress, localPort), address.getHostAddress(), port,
					true);
		}

		/**
		 * Throws an {@link IOException} because the JDK calls this as a fallback when layering
		 * fails and then rethrows the original {@code IOException}.
		 */
		@Override
		public Socket createSocket(String host, int port) throws IOException {
			throw new SocketException("Pinned connections must not resolve host names");
		}

		@Override
		public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException {
			throw new SocketException("Pinned connections must not resolve host names");
		}
	}
}