SsrfSafeHttpClient.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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.entrystore.rest.springboot.configuration.ProxyProperties;
import org.entrystore.rest.springboot.model.exception.CustomResponseException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.ConnectException;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Map;
import java.util.function.Function;
/**
* Executes outbound HTTP requests against SSRF-validated targets, following redirects safely:
* every hop's {@code Location} is resolved against the current URI and passed through the
* caller-supplied validator before a new pinned connection is opened, and each connection is
* disconnected before the next hop.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SsrfSafeHttpClient {
private final SsrfValidator ssrfValidator;
private final ProxyProperties proxyProperties;
/**
* Handles the final (non-3xx) response of {@link #execute}. The connection is disconnected
* after the handler returns, so any response body must be consumed inside the handler.
*/
@FunctionalInterface
public interface ResponseHandler<T> {
T handle(int status, HttpURLConnection connection) throws IOException;
}
/**
* Executes {@code httpMethod} against a pre-validated target, following up to
* {@code entrystore.proxy.max-redirects} redirects (default 15). Failure mapping: redirect without
* Location, too many redirects, and IO or malformed-Location failures throw 502 Bad Gateway;
* connection timeouts and refusals throw 504 Gateway Timeout.
*
* <p>Note the loop bound is inclusive, so a cap of N opens up to N+1 connections: the initial
* request plus N redirect hops.
*
* @param initialTarget the already-validated request target
* @param httpMethod HTTP method to send on every hop
* @param requestHeaders headers to set on every hop; never client-chosen names, because
* restricted headers such as {@code Host} are unlocked JVM-wide
* @param redirectValidator SSRF re-validation applied to each resolved redirect location
* @param responseHandler converts the final response; see {@link ResponseHandler}
*/
public <T> T execute(SsrfValidator.ValidatedTarget initialTarget, String httpMethod,
Map<String, String> requestHeaders,
Function<String, SsrfValidator.ValidatedTarget> redirectValidator,
ResponseHandler<T> responseHandler) {
SsrfValidator.ValidatedTarget target = initialTarget;
int maxRedirects = proxyProperties.maxRedirects();
// Inclusive bound, preserved deliberately: a cap of N allows the initial request plus N hops.
for (int redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
HttpURLConnection conn = null;
try {
conn = ssrfValidator.openPinnedConnection(target.uri(), target.resolved());
conn.setRequestMethod(httpMethod);
for (Map.Entry<String, String> header : requestHeaders.entrySet()) {
conn.setRequestProperty(header.getKey(), header.getValue());
}
int status = conn.getResponseCode();
if (status >= 300 && status < 400) {
String location = conn.getHeaderField("Location");
if (location == null) {
log.warn("Upstream returned {} redirect without Location header for URL: {}", status, target.uri());
throw new CustomResponseException("Upstream returned redirect without Location header", HttpStatus.BAD_GATEWAY);
}
String resolvedLocation = target.uri().resolve(location).toString();
target = redirectValidator.apply(resolvedLocation);
continue;
}
return responseHandler.handle(status, conn);
} catch (SocketTimeoutException | ConnectException e) {
throw new CustomResponseException("Gateway timeout", HttpStatus.GATEWAY_TIMEOUT,
upstreamFailure(target, e));
} catch (IOException | URISyntaxException | IllegalArgumentException e) {
// IllegalArgumentException: URI.resolve(location) on a malformed upstream Location header.
throw new CustomResponseException("Proxy request failed", HttpStatus.BAD_GATEWAY,
upstreamFailure(target, e));
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
// Phrased as the cap rather than as a loop count: with entrystore.proxy.max-redirects=0 — a
// supported value meaning "do not follow redirects" — a single well-behaved 302 lands here, and
// "more than 0 loops detected" would point whoever debugs the 502 at the upstream instead of at
// their own configuration.
log.warn("Upstream exceeded the configured redirect cap of {} (entrystore.proxy.max-redirects), aborting",
maxRedirects);
throw new CustomResponseException("Too many redirects", HttpStatus.BAD_GATEWAY);
}
/**
* Names the failing hop (which may be a redirect target) for the server log. The query is left
* out because it may carry credentials.
*/
private static IOException upstreamFailure(SsrfValidator.ValidatedTarget target, Exception cause) {
URI uri = target.uri();
String path = uri.getRawPath() != null ? uri.getRawPath() : "";
return new IOException("Request to " + uri.getScheme() + "://" + uri.getRawAuthority() + path + " failed",
cause);
}
}