ProxyProperties.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.configuration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.boot.convert.DurationUnit;
import org.springframework.util.unit.DataSize;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
/**
* Bindings for the outbound-fetch limits under {@code entrystore.proxy.*}, consumed by
* {@code ProxyService} (response-size cap), {@code SsrfSafeHttpClient} (redirect cap) and
* {@code SsrfValidator} (socket timeouts). The proxy/SSRF whitelists under the same prefix are indexed
* lists, read through {@link IndexedListSettings} by their consumers.
*
* <p><b>Outbound-fetch limits.</b> The defaults are the 5.x values, the constants these keys replaced, except that the
* response size is capped at 100 MB, where 5.x had no limit. The timeouts apply per hop on <b>both</b> outbound paths
* that go through {@code SsrfValidator.openPinnedConnection}: {@code GET /proxy} (and its context-scoped form) and
* {@code DELETE /{context-id}/resource/{entry-id}?proxy=true}. Establishing all hops costs at worst roughly
* {@code (maxRedirects + 1) × connectTimeout}; {@code readTimeout} bounds each socket read rather than the exchange,
* so total wall time is <b>not</b> bounded by it — a slow-drip upstream can hold a request thread for as long as it
* keeps sending, or until {@code maxResponseSize} is reached. Plus DNS resolution, which no timeout here covers.
* {@code maxResponseSize} is unlimited when negative; the body is streamed, so the cap bounds transfer size and thread
* time, not heap. A JSONP response is the exception: it is buffered to be wrapped, so there the cap also bounds heap.
* It applies to {@code GET /proxy} only — the resource-DELETE path never reads a response body.
*/
@ConfigurationProperties(prefix = "entrystore.proxy")
public record ProxyProperties(
@DefaultValue("100MB") DataSize maxResponseSize,
@DefaultValue("15") int maxRedirects,
@DurationUnit(ChronoUnit.SECONDS) @DefaultValue("30s") Duration connectTimeout,
@DurationUnit(ChronoUnit.SECONDS) @DefaultValue("60s") Duration readTimeout
) {
/** Guards against an operator turning one request into an unbounded chain of outbound connections. */
private static final int MAX_REDIRECTS_CEILING = 50;
/**
* Upper bound on the timeouts. Also what makes {@link #connectTimeoutMillis()} safe: those return
* {@code int} because that is what {@code URLConnection} takes, and an hour is far short of the
* ~24.8 days at which a millisecond count would overflow.
*/
private static final Duration TIMEOUT_CEILING = Duration.ofHours(1);
public ProxyProperties {
if (maxResponseSize == null || maxResponseSize.toBytes() == 0) {
throw new IllegalArgumentException("entrystore.proxy.max-response-size must be positive, "
+ "or negative for no limit, got " + maxResponseSize);
}
if (maxRedirects < 0 || maxRedirects > MAX_REDIRECTS_CEILING) {
throw new IllegalArgumentException("entrystore.proxy.max-redirects must be between 0 and "
+ MAX_REDIRECTS_CEILING + ", got " + maxRedirects);
}
requireBoundedAndPositive(connectTimeout, "entrystore.proxy.connect-timeout");
requireBoundedAndPositive(readTimeout, "entrystore.proxy.read-timeout");
}
private static void requireBoundedAndPositive(Duration value, String key) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalArgumentException(key + " must be positive, got " + value);
}
if (value.compareTo(TIMEOUT_CEILING) > 0) {
throw new IllegalArgumentException(key + " must not exceed " + TIMEOUT_CEILING + ", got " + value);
}
}
/** Whether {@code GET /proxy} caps the upstream body at {@link #maxResponseSize()}. */
public boolean isResponseSizeLimited() {
return maxResponseSize.toBytes() > 0;
}
/**
* The connect timeout as the {@code int} milliseconds {@code URLConnection} takes. Bounded by the
* validation above well short of overflow.
*/
public int connectTimeoutMillis() {
return (int) connectTimeout.toMillis();
}
/** The read timeout as the {@code int} milliseconds {@code URLConnection} takes. */
public int readTimeoutMillis() {
return (int) readTimeout.toMillis();
}
}