ProxyService.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.service;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.entrystore.Context;
import org.entrystore.PrincipalManager;
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.CustomResponseException;
import org.entrystore.rest.springboot.model.exception.ForbiddenException;
import org.entrystore.rest.springboot.security.SsrfSafeHttpClient;
import org.entrystore.rest.springboot.security.SsrfValidator;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
@Slf4j
@Service
@RequiredArgsConstructor
public class ProxyService {
private final PrincipalManager principalManager;
private final ContextService contextService;
private final SsrfValidator ssrfValidator;
private final SsrfSafeHttpClient ssrfSafeHttpClient;
private final ProxyProperties proxyProperties;
private final Environment environment;
/**
* Entity headers copied from the upstream response besides Content-Type and Content-Length: those 5.x
* passed through with the upstream entity, except Content-Location and Content-MD5.
*/
private static final List<String> PASSED_THROUGH_HEADERS = List.of(HttpHeaders.CONTENT_ENCODING,
HttpHeaders.CONTENT_LANGUAGE, HttpHeaders.CONTENT_DISPOSITION, HttpHeaders.LAST_MODIFIED,
HttpHeaders.ETAG, HttpHeaders.EXPIRES);
private Set<String> whitelistAnon;
@PostConstruct
void init() {
whitelistAnon = IndexedListSettings.readHosts(environment, Settings.PROXY_WHITELIST_ANONYMOUS);
if (!whitelistAnon.isEmpty()) {
log.info("Proxy whitelist for guest users initialized with following domains: {}; Requests to other domains require authentication",
String.join(", ", whitelistAnon));
} else {
log.info("No domains provided for proxy whitelist; only authenticated users are allowed to perform proxy requests");
}
}
public void validateGlobalAccess(String host) {
if (principalManager.getGuestUser().getURI().equals(principalManager.getAuthenticatedUserURI())) {
if (!whitelistAnon.contains(host)) {
throw new ForbiddenException("Guest user is not allowed to proxy requests to host: " + host);
}
}
}
public void validateContextAccess(String contextId) {
Context context = contextService.getContextOrThrow(contextId);
principalManager.checkAuthenticatedUserAuthorized(context.getEntry(),
PrincipalManager.AccessProperty.ReadResource);
}
void setWhitelistAnon(Set<String> whitelistAnon) {
this.whitelistAnon = whitelistAnon;
}
/**
* Fetches {@code target} and streams the upstream status, Content-Type, Content-Length, the headers in
* {@link #PASSED_THROUGH_HEADERS} and the body to {@code response}, so heap use does not grow with the
* body size. The request thread is held for the whole transfer, as it was while the body was buffered.
*
* <p>With {@code entrystore.proxy.max-response-size} set, an upstream announcing a larger Content-Length
* is answered 502 before anything is sent. A body without Content-Length that grows past the cap is cut
* off: before the response is committed it is answered 502, afterwards the connection is aborted.
*
* <p>With {@code headersOnly}, for a HEAD request, the upstream is still fetched with GET, as in 5.x, but its
* body is never read.
*/
public void proxy(SsrfValidator.ValidatedTarget target, String acceptHeader, boolean headersOnly,
HttpServletResponse response) {
Map<String, String> requestHeaders = acceptHeader != null ? Map.of("Accept", acceptHeader) : Map.of();
ssrfSafeHttpClient.execute(target, "GET", requestHeaders,
this::validateRedirectTarget,
(status, conn) -> {
streamResponse(status, conn, headersOnly, response);
return null;
});
}
private void streamResponse(int status, HttpURLConnection conn, boolean headersOnly, HttpServletResponse response)
throws IOException {
if (status < 100) {
// HttpURLConnection reports -1 for a response that is not valid HTTP.
throw new CustomResponseException("Proxy request failed", HttpStatus.BAD_GATEWAY);
}
// With Transfer-Encoding, Content-Length must be ignored (RFC 9112, section 6.3).
HttpHeaders upstream = upstreamHeaders(conn);
long contentLength = upstream.getOrEmpty(HttpHeaders.TRANSFER_ENCODING).isEmpty() ? conn.getContentLengthLong() : -1;
if (proxyProperties.isResponseSizeLimited() && contentLength > proxyProperties.maxResponseSize().toBytes()) {
throw responseTooLarge();
}
Set<String> hopByHop = connectionNominatedHeaders(upstream);
response.setStatus(status);
response.setHeader("Content-Security-Policy", "script-src 'none'; form-action 'none';"); // XSS and SSRF protection
if (conn.getContentType() != null && !hopByHop.contains(HttpHeaders.CONTENT_TYPE)) {
response.setContentType(conn.getContentType());
}
if (contentLength >= 0 && !hopByHop.contains(HttpHeaders.CONTENT_LENGTH)) {
response.setContentLengthLong(contentLength);
}
for (String name : PASSED_THROUGH_HEADERS) {
if (!hopByHop.contains(name)) {
upstream.getOrEmpty(name).forEach(value -> response.addHeader(name, value));
}
}
if (headersOnly) {
return;
}
try (InputStream in = (status >= 400) ? conn.getErrorStream() : conn.getInputStream()) {
if (in != null) {
copyWithLimit(in, response.getOutputStream());
}
} catch (IOException | RuntimeException e) {
if (!response.isCommitted()) {
// The error response that follows must carry neither the upstream's entity headers nor its bytes.
response.resetBuffer();
response.setContentLengthLong(-1);
PASSED_THROUGH_HEADERS.forEach(name -> response.setHeader(name, null));
}
throw e;
}
}
/**
* The header names the upstream's Connection headers nominate as hop-by-hop (RFC 9110, section 7.6.1), which
* must not be forwarded, whichever header they name.
*/
private static Set<String> connectionNominatedHeaders(HttpHeaders upstream) {
Set<String> names = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
upstream.getOrEmpty(HttpHeaders.CONNECTION).stream()
.flatMap(value -> Arrays.stream(value.split(",")))
.map(String::trim)
.filter(token -> !token.isEmpty())
.forEach(names::add);
return names;
}
/**
* The upstream's header fields in the order received, with names matched case-insensitively. Repeated fields
* keep their wire order whatever their spelling, which {@code getHeaderFields()} does not: it groups values by
* the exact spelling of the name. Index 0 is the status line, which has no name.
*/
private static HttpHeaders upstreamHeaders(HttpURLConnection conn) {
HttpHeaders headers = new HttpHeaders();
for (int i = 0; ; i++) {
String name = conn.getHeaderFieldKey(i);
String value = conn.getHeaderField(i);
if (name == null && value == null) {
return headers;
}
if (name != null && value != null) {
headers.add(name, value);
}
}
}
private void copyWithLimit(InputStream in, OutputStream out) throws IOException {
boolean limited = proxyProperties.isResponseSizeLimited();
long maxBytes = proxyProperties.maxResponseSize().toBytes();
byte[] buf = new byte[8192];
long total = 0;
int read;
while ((read = in.read(buf)) != -1) {
total += read;
if (limited && total > maxBytes) {
throw responseTooLarge();
}
try {
out.write(buf, 0, read);
} catch (IOException e) {
// Unchecked, so SsrfSafeHttpClient does not report the client's disconnect as an upstream failure.
throw new CustomResponseException("Proxy client went away", HttpStatus.BAD_GATEWAY, e);
}
}
}
private CustomResponseException responseTooLarge() {
return new CustomResponseException("Upstream response exceeds maximum allowed size of "
+ proxyProperties.maxResponseSize().toBytes() + " bytes", HttpStatus.BAD_GATEWAY);
}
/**
* Re-validates a resolved redirect location: SSRF validation
* ({@link SsrfValidator#validateForProxy(String)}) and the guest anon-whitelist, on every hop, so that a
* whitelisted upstream cannot redirect a guest elsewhere. Both proxy routes enforce the whitelist, as 5.x did.
*/
SsrfValidator.ValidatedTarget validateRedirectTarget(String resolvedLocation) {
log.debug("Request redirected to {}", resolvedLocation);
SsrfValidator.ValidatedTarget next = ssrfValidator.validateForProxy(resolvedLocation);
validateGlobalAccess(next.host());
return next;
}
/**
* Sends an SSRF-guarded DELETE to a remote resource, re-validating every redirect hop. Used for
* {@code DELETE /{ctx}/resource/{id}?proxy=true} on link entries; the caller performs the ACL check. Unlike
* {@link #proxy}, the URL is validated here because the caller holds a stored resource URI, not a
* client-supplied one already resolved by the controller. A non-2xx upstream answer is reported as 502 without
* echoing the upstream body.
*/
public void deleteUrl(String url) {
SsrfValidator.ValidatedTarget target = ssrfValidator.validateForDelete(url);
ssrfSafeHttpClient.execute(target, "DELETE", Map.of(),
location -> {
log.info("DELETE request redirected to {}", location);
return ssrfValidator.validateForDelete(location);
},
(status, conn) -> {
if (status >= 200 && status < 300) {
return null;
}
// Suppress upstream body — may leak internal details.
throw new CustomResponseException("Delete request received an error response (status " + status + ")",
HttpStatus.BAD_GATEWAY);
});
}
}