fix(personal): harden URL resolution and normalization
This commit is contained in:
+179
-116
@@ -4,6 +4,13 @@ import org.dromara.aihr.personal.support.PersonalKnowledgeProperties;
|
|||||||
import org.dromara.common.core.exception.ServiceException;
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.naming.Context;
|
||||||
|
import javax.naming.NamingEnumeration;
|
||||||
|
import javax.naming.NamingException;
|
||||||
|
import javax.naming.directory.Attribute;
|
||||||
|
import javax.naming.directory.Attributes;
|
||||||
|
import javax.naming.directory.DirContext;
|
||||||
|
import javax.naming.directory.InitialDirContext;
|
||||||
import javax.net.ssl.SNIHostName;
|
import javax.net.ssl.SNIHostName;
|
||||||
import javax.net.ssl.SSLParameters;
|
import javax.net.ssl.SSLParameters;
|
||||||
import javax.net.ssl.SSLSocket;
|
import javax.net.ssl.SSLSocket;
|
||||||
@@ -21,13 +28,12 @@ import java.net.InetSocketAddress;
|
|||||||
import java.net.Socket;
|
import java.net.Socket;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
import java.net.UnknownHostException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Hashtable;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -35,15 +41,7 @@ import java.util.List;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.ArrayBlockingQueue;
|
|
||||||
import java.util.concurrent.ExecutionException;
|
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
import java.util.concurrent.Future;
|
|
||||||
import java.util.concurrent.RejectedExecutionException;
|
|
||||||
import java.util.concurrent.ThreadPoolExecutor;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.TimeoutException;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class PersonalUrlFetchService {
|
public class PersonalUrlFetchService {
|
||||||
@@ -61,13 +59,6 @@ public class PersonalUrlFetchService {
|
|||||||
private static final int MAX_LINE_BYTES = 8 * 1024;
|
private static final int MAX_LINE_BYTES = 8 * 1024;
|
||||||
private static final long TOTAL_TIMEOUT_NANOS = 15_000_000_000L;
|
private static final long TOTAL_TIMEOUT_NANOS = 15_000_000_000L;
|
||||||
private static final long HARD_MAX_BODY_BYTES = 10L * 1024 * 1024;
|
private static final long HARD_MAX_BODY_BYTES = 10L * 1024 * 1024;
|
||||||
private static final AtomicInteger DNS_THREAD_SEQUENCE = new AtomicInteger();
|
|
||||||
private static final ExecutorService DNS_EXECUTOR = new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS,
|
|
||||||
new ArrayBlockingQueue<>(8), runnable -> {
|
|
||||||
Thread thread = new Thread(runnable, "personal-url-dns-" + DNS_THREAD_SEQUENCE.incrementAndGet());
|
|
||||||
thread.setDaemon(true);
|
|
||||||
return thread;
|
|
||||||
}, new ThreadPoolExecutor.AbortPolicy());
|
|
||||||
private static final String USER_AGENT = "wygj-personal-url-fetch/1.0";
|
private static final String USER_AGENT = "wygj-personal-url-fetch/1.0";
|
||||||
private static final String ACCEPT = "text/html,text/plain,application/pdf,application/msword,application/vnd.ms-excel,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
private static final String ACCEPT = "text/html,text/plain,application/pdf,application/msword,application/vnd.ms-excel,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||||
private static final Map<String, String> SAFE_HEADERS = Map.of(
|
private static final Map<String, String> SAFE_HEADERS = Map.of(
|
||||||
@@ -89,8 +80,7 @@ public class PersonalUrlFetchService {
|
|||||||
private final Fetcher fetcher;
|
private final Fetcher fetcher;
|
||||||
|
|
||||||
public PersonalUrlFetchService(PersonalKnowledgeProperties properties) {
|
public PersonalUrlFetchService(PersonalKnowledgeProperties properties) {
|
||||||
this(properties, new DeadlineDnsResolver(DNS_EXECUTOR,
|
this(properties, new DeadlineDnsResolver(new JndiDnsQuery()), new RawSocketFetcher());
|
||||||
host -> Arrays.asList(InetAddress.getAllByName(host))), new RawSocketFetcher());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private PersonalUrlFetchService(PersonalKnowledgeProperties properties, Resolver resolver, Fetcher fetcher) {
|
private PersonalUrlFetchService(PersonalKnowledgeProperties properties, Resolver resolver, Fetcher fetcher) {
|
||||||
@@ -136,7 +126,7 @@ public class PersonalUrlFetchService {
|
|||||||
enforceDeclaredLength(response.headers(), maxBodyBytes);
|
enforceDeclaredLength(response.headers(), maxBodyBytes);
|
||||||
if (isRedirect(response.status())) {
|
if (isRedirect(response.status())) {
|
||||||
if (redirects >= MAX_REDIRECTS) throw new ServiceException(REDIRECT_LIMIT);
|
if (redirects >= MAX_REDIRECTS) throw new ServiceException(REDIRECT_LIMIT);
|
||||||
String location = firstHeader(response.headers(), "location");
|
String location = strictSingletonHeader(response.headers(), "location");
|
||||||
if (location == null || location.isBlank()) throw new ServiceException(RESPONSE_INVALID);
|
if (location == null || location.isBlank()) throw new ServiceException(RESPONSE_INVALID);
|
||||||
URI next;
|
URI next;
|
||||||
try {
|
try {
|
||||||
@@ -152,7 +142,7 @@ public class PersonalUrlFetchService {
|
|||||||
if (response.status() < 200 || response.status() >= 300) {
|
if (response.status() < 200 || response.status() >= 300) {
|
||||||
throw new ServiceException(RESPONSE_INVALID);
|
throw new ServiceException(RESPONSE_INVALID);
|
||||||
}
|
}
|
||||||
String contentType = normalizeContentType(firstHeader(response.headers(), "content-type"));
|
String contentType = normalizeContentType(strictSingletonHeader(response.headers(), "content-type"));
|
||||||
if (!ALLOWED_CONTENT_TYPES.contains(contentType)) {
|
if (!ALLOWED_CONTENT_TYPES.contains(contentType)) {
|
||||||
throw new ServiceException(CONTENT_TYPE_UNSUPPORTED);
|
throw new ServiceException(CONTENT_TYPE_UNSUPPORTED);
|
||||||
}
|
}
|
||||||
@@ -184,7 +174,7 @@ public class PersonalUrlFetchService {
|
|||||||
throw new ServiceException(BLOCKED);
|
throw new ServiceException(BLOCKED);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
URI parsed = new URI(rawUrl.trim()).normalize();
|
URI parsed = new URI(rawUrl.trim());
|
||||||
String scheme = parsed.getScheme() == null ? "" : parsed.getScheme().toLowerCase(Locale.ROOT);
|
String scheme = parsed.getScheme() == null ? "" : parsed.getScheme().toLowerCase(Locale.ROOT);
|
||||||
if (!("http".equals(scheme) || "https".equals(scheme))
|
if (!("http".equals(scheme) || "https".equals(scheme))
|
||||||
|| parsed.getRawUserInfo() != null || parsed.getHost() == null || parsed.getHost().isBlank()) {
|
|| parsed.getRawUserInfo() != null || parsed.getHost() == null || parsed.getHost().isBlank()) {
|
||||||
@@ -199,8 +189,12 @@ public class PersonalUrlFetchService {
|
|||||||
: IDN.toASCII(rawHost, IDN.USE_STD3_ASCII_RULES).toLowerCase(Locale.ROOT);
|
: IDN.toASCII(rawHost, IDN.USE_STD3_ASCII_RULES).toLowerCase(Locale.ROOT);
|
||||||
if (host.isBlank() || host.length() > 253) throw new ServiceException(BLOCKED);
|
if (host.isBlank() || host.length() > 253) throw new ServiceException(BLOCKED);
|
||||||
if ("localhost".equals(host) || host.endsWith(".localhost")) throw new ServiceException(BLOCKED);
|
if ("localhost".equals(host) || host.endsWith(".localhost")) throw new ServiceException(BLOCKED);
|
||||||
URI normalized = new URI(scheme, null, host, port,
|
String authority = host.indexOf(':') >= 0 ? "[" + host + "]" : host;
|
||||||
parsed.getRawPath().isEmpty() ? "/" : parsed.getRawPath(), parsed.getRawQuery(), null).normalize();
|
if (port >= 0) authority += ":" + port;
|
||||||
|
String rawPath = parsed.getRawPath().isEmpty() ? "/" : parsed.getRawPath();
|
||||||
|
StringBuilder rebuilt = new StringBuilder(scheme).append("://").append(authority).append(rawPath);
|
||||||
|
if (parsed.getRawQuery() != null) rebuilt.append('?').append(parsed.getRawQuery());
|
||||||
|
URI normalized = new URI(new URI(rebuilt.toString()).normalize().toASCIIString());
|
||||||
if (normalized.toASCIIString().length() > MAX_URL_LENGTH) throw new ServiceException(BLOCKED);
|
if (normalized.toASCIIString().length() > MAX_URL_LENGTH) throw new ServiceException(BLOCKED);
|
||||||
return normalized;
|
return normalized;
|
||||||
} catch (URISyntaxException | IllegalArgumentException ex) {
|
} catch (URISyntaxException | IllegalArgumentException ex) {
|
||||||
@@ -263,6 +257,12 @@ public class PersonalUrlFetchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String strictFramingHeader(Map<String, List<String>> headers, String name) {
|
private static String strictFramingHeader(Map<String, List<String>> headers, String name) {
|
||||||
|
String found = strictSingletonHeader(headers, name);
|
||||||
|
if (found != null && found.indexOf(',') >= 0) throw new ServiceException(RESPONSE_INVALID);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String strictSingletonHeader(Map<String, List<String>> headers, String name) {
|
||||||
if (headers == null) return null;
|
if (headers == null) return null;
|
||||||
String found = null;
|
String found = null;
|
||||||
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
|
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
|
||||||
@@ -271,22 +271,13 @@ public class PersonalUrlFetchService {
|
|||||||
throw new ServiceException(RESPONSE_INVALID);
|
throw new ServiceException(RESPONSE_INVALID);
|
||||||
}
|
}
|
||||||
found = entry.getValue().get(0);
|
found = entry.getValue().get(0);
|
||||||
if (found == null || found.isBlank() || found.indexOf(',') >= 0) {
|
if (found == null || found.isBlank()) {
|
||||||
throw new ServiceException(RESPONSE_INVALID);
|
throw new ServiceException(RESPONSE_INVALID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String firstHeader(Map<String, List<String>> headers, String name) {
|
|
||||||
if (headers == null) return null;
|
|
||||||
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
|
|
||||||
if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(name)
|
|
||||||
&& entry.getValue() != null && !entry.getValue().isEmpty()) return entry.getValue().get(0);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static boolean isGloballyRoutable(InetAddress address) {
|
static boolean isGloballyRoutable(InetAddress address) {
|
||||||
if (address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isLinkLocalAddress()
|
if (address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isLinkLocalAddress()
|
||||||
|| address.isSiteLocalAddress() || address.isMulticastAddress()) return false;
|
|| address.isSiteLocalAddress() || address.isMulticastAddress()) return false;
|
||||||
@@ -307,7 +298,7 @@ public class PersonalUrlFetchService {
|
|||||||
if (a == 100 && b >= 64 && b <= 127) return false;
|
if (a == 100 && b >= 64 && b <= 127) return false;
|
||||||
if (a == 169 && b == 254) return false;
|
if (a == 169 && b == 254) return false;
|
||||||
if (a == 172 && b >= 16 && b <= 31) return false;
|
if (a == 172 && b >= 16 && b <= 31) return false;
|
||||||
if (a == 192 && (b == 168 || (b == 0 && (c == 0 || c == 2)))) return false;
|
if (a == 192 && (b == 168 || b == 0)) return false;
|
||||||
if (a == 192 && ((b == 31 && c == 196) || (b == 52 && c == 193)
|
if (a == 192 && ((b == 31 && c == 196) || (b == 52 && c == 193)
|
||||||
|| (b == 88 && c == 99) || (b == 175 && c == 48))) return false;
|
|| (b == 88 && c == 99) || (b == 175 && c == 48))) return false;
|
||||||
if (a == 198 && (b == 18 || b == 19 || (b == 51 && c == 100))) return false;
|
if (a == 198 && (b == 18 || b == 19 || (b == 51 && c == 100))) return false;
|
||||||
@@ -339,48 +330,93 @@ public class PersonalUrlFetchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
interface HostLookup {
|
interface DnsQuery {
|
||||||
List<InetAddress> resolve(String host) throws UnknownHostException;
|
List<String> resolve(String host, int timeoutMillis, int retries) throws NamingException;
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class DeadlineDnsResolver implements Resolver {
|
static final class DeadlineDnsResolver implements Resolver {
|
||||||
private final ExecutorService executor;
|
private final DnsQuery query;
|
||||||
private final HostLookup lookup;
|
|
||||||
|
|
||||||
DeadlineDnsResolver(ExecutorService executor, HostLookup lookup) {
|
DeadlineDnsResolver(DnsQuery query) {
|
||||||
this.executor = executor;
|
this.query = query;
|
||||||
this.lookup = lookup;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<InetAddress> resolve(String host, long deadlineNanos) throws IOException {
|
public List<InetAddress> resolve(String host, long deadlineNanos) throws IOException {
|
||||||
long remaining = deadlineNanos - System.nanoTime();
|
long remaining = deadlineNanos - System.nanoTime();
|
||||||
if (remaining <= 0) throw new IOException("resolution deadline exceeded");
|
long remainingMillis = TimeUnit.NANOSECONDS.toMillis(remaining);
|
||||||
Future<List<InetAddress>> future;
|
if (remainingMillis <= 0) throw new IOException("resolution deadline exceeded");
|
||||||
|
int timeoutMillis = (int) Math.min(5_000L, remainingMillis);
|
||||||
|
List<String> literals;
|
||||||
try {
|
try {
|
||||||
future = executor.submit(() -> lookup.resolve(host));
|
literals = query.resolve(host, timeoutMillis, 0);
|
||||||
} catch (RejectedExecutionException ex) {
|
} catch (NamingException | RuntimeException ex) {
|
||||||
throw new IOException("resolution unavailable");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return future.get(remaining, TimeUnit.NANOSECONDS);
|
|
||||||
} catch (TimeoutException ex) {
|
|
||||||
cancelAndPurge(future);
|
|
||||||
throw new IOException("resolution deadline exceeded");
|
|
||||||
} catch (InterruptedException ex) {
|
|
||||||
cancelAndPurge(future);
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
throw new IOException("resolution interrupted");
|
|
||||||
} catch (ExecutionException ex) {
|
|
||||||
cancelAndPurge(future);
|
|
||||||
throw new IOException("resolution failed");
|
throw new IOException("resolution failed");
|
||||||
}
|
}
|
||||||
|
if (System.nanoTime() >= deadlineNanos) throw new IOException("resolution deadline exceeded");
|
||||||
|
List<InetAddress> addresses = new ArrayList<>();
|
||||||
|
if (literals != null) {
|
||||||
|
for (String literal : literals) addresses.add(numericAddress(literal));
|
||||||
|
}
|
||||||
|
return List.copyOf(addresses);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class JndiDnsQuery implements DnsQuery {
|
||||||
|
@Override
|
||||||
|
public List<String> resolve(String host, int timeoutMillis, int retries) throws NamingException {
|
||||||
|
Hashtable<String, String> environment = environment(timeoutMillis);
|
||||||
|
environment.put("com.sun.jndi.dns.timeout.retries", Integer.toString(Math.max(0, retries)));
|
||||||
|
DirContext context = new InitialDirContext(environment);
|
||||||
|
try {
|
||||||
|
Attributes attributes = context.getAttributes(host, new String[] {"A", "AAAA"});
|
||||||
|
List<String> values = new ArrayList<>();
|
||||||
|
collect(attributes.get("A"), values);
|
||||||
|
collect(attributes.get("AAAA"), values);
|
||||||
|
return values;
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void cancelAndPurge(Future<?> future) {
|
static Hashtable<String, String> environment(int timeoutMillis) {
|
||||||
future.cancel(true);
|
Hashtable<String, String> environment = new Hashtable<>();
|
||||||
if (executor instanceof ThreadPoolExecutor pool) pool.purge();
|
environment.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
|
||||||
|
environment.put("com.sun.jndi.dns.timeout.initial", Integer.toString(Math.max(1, timeoutMillis)));
|
||||||
|
environment.put("com.sun.jndi.dns.timeout.retries", "0");
|
||||||
|
return environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void collect(Attribute attribute, List<String> values) throws NamingException {
|
||||||
|
if (attribute == null) return;
|
||||||
|
NamingEnumeration<?> all = attribute.getAll();
|
||||||
|
while (all.hasMore()) values.add(String.valueOf(all.next()).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InetAddress numericAddress(String literal) throws IOException {
|
||||||
|
if (literal == null || literal.isBlank() || literal.indexOf('%') >= 0) throw new IOException("invalid DNS answer");
|
||||||
|
String value = literal.trim();
|
||||||
|
if (value.indexOf(':') < 0) {
|
||||||
|
String[] parts = value.split("\\.", -1);
|
||||||
|
if (parts.length != 4) throw new IOException("invalid DNS answer");
|
||||||
|
byte[] bytes = new byte[4];
|
||||||
|
for (int i = 0; i < parts.length; i++) {
|
||||||
|
if (parts[i].isEmpty() || !parts[i].chars().allMatch(Character::isDigit)) {
|
||||||
|
throw new IOException("invalid DNS answer");
|
||||||
|
}
|
||||||
|
int octet;
|
||||||
|
try { octet = Integer.parseInt(parts[i]); }
|
||||||
|
catch (NumberFormatException ex) { throw new IOException("invalid DNS answer"); }
|
||||||
|
if (octet > 255) throw new IOException("invalid DNS answer");
|
||||||
|
bytes[i] = (byte) octet;
|
||||||
|
}
|
||||||
|
return InetAddress.getByAddress(bytes);
|
||||||
|
}
|
||||||
|
if (!value.matches("[0-9A-Fa-f:.]+")) throw new IOException("invalid DNS answer");
|
||||||
|
InetAddress address = InetAddress.getByName(value);
|
||||||
|
if (!(address instanceof Inet6Address)) throw new IOException("invalid DNS answer");
|
||||||
|
return address;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
@@ -420,7 +456,7 @@ public class PersonalUrlFetchService {
|
|||||||
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
interface ConnectionFactory {
|
interface ConnectionFactory {
|
||||||
Connection connect(URI uri, InetAddress address, int port, int connectTimeout, int readTimeout) throws IOException;
|
Connection connect(URI uri, InetAddress address, int port, long deadlineNanos) throws IOException;
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class RawSocketFetcher implements Fetcher {
|
static final class RawSocketFetcher implements Fetcher {
|
||||||
@@ -450,9 +486,7 @@ public class PersonalUrlFetchService {
|
|||||||
private TransportResponse fetchAddress(FetchRequest request, InetAddress address) throws IOException {
|
private TransportResponse fetchAddress(FetchRequest request, InetAddress address) throws IOException {
|
||||||
URI uri = request.uri();
|
URI uri = request.uri();
|
||||||
int port = uri.getPort() >= 0 ? uri.getPort() : ("https".equals(uri.getScheme()) ? 443 : 80);
|
int port = uri.getPort() >= 0 ? uri.getPort() : ("https".equals(uri.getScheme()) ? 443 : 80);
|
||||||
int connectTimeout = timeout(request.deadlineNanos(), 5_000);
|
try (Connection connection = connections.connect(uri, address, port, request.deadlineNanos())) {
|
||||||
int readTimeout = timeout(request.deadlineNanos(), 5_000);
|
|
||||||
try (Connection connection = connections.connect(uri, address, port, connectTimeout, readTimeout)) {
|
|
||||||
writeRequest(connection.output(), request);
|
writeRequest(connection.output(), request);
|
||||||
connection.setReadTimeout(timeout(request.deadlineNanos(), 5_000));
|
connection.setReadTimeout(timeout(request.deadlineNanos(), 5_000));
|
||||||
TransportResponse response = parseHttpResponse(
|
TransportResponse response = parseHttpResponse(
|
||||||
@@ -505,25 +539,24 @@ public class PersonalUrlFetchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class JvmConnectionFactory implements ConnectionFactory {
|
static final class JvmConnectionFactory implements ConnectionFactory {
|
||||||
@Override
|
@Override
|
||||||
public Connection connect(URI uri, InetAddress address, int port,
|
public Connection connect(URI uri, InetAddress address, int port, long deadlineNanos) throws IOException {
|
||||||
int connectTimeout, int readTimeout) throws IOException {
|
|
||||||
Socket plain = new Socket();
|
Socket plain = new Socket();
|
||||||
try {
|
try {
|
||||||
// The socket connects to the exact address already approved by the resolver policy.
|
// The socket connects to the exact address already approved by the resolver policy.
|
||||||
plain.connect(new InetSocketAddress(address, port), connectTimeout);
|
plain.connect(new InetSocketAddress(address, port), RawSocketFetcher.timeout(deadlineNanos, 5_000));
|
||||||
plain.setSoTimeout(readTimeout);
|
plain.setSoTimeout(RawSocketFetcher.timeout(deadlineNanos, 5_000));
|
||||||
Socket active = plain;
|
Socket active = plain;
|
||||||
if ("https".equals(uri.getScheme())) {
|
if ("https".equals(uri.getScheme())) {
|
||||||
String tlsHost = canonicalHost(uri);
|
String tlsHost = canonicalHost(uri);
|
||||||
// JVM defaults preserve the configured trust store; no permissive trust manager is installed.
|
// JVM defaults preserve the configured trust store; no permissive trust manager is installed.
|
||||||
SSLSocket ssl = (SSLSocket) ((SSLSocketFactory) SSLSocketFactory.getDefault())
|
SSLSocket ssl = (SSLSocket) defaultSslSocketFactory()
|
||||||
.createSocket(plain, tlsHost, port, true);
|
.createSocket(plain, tlsHost, port, true);
|
||||||
SSLParameters parameters = ssl.getSSLParameters();
|
SSLParameters parameters = ssl.getSSLParameters();
|
||||||
RawSocketFetcher.configureTlsParameters(parameters, tlsHost);
|
RawSocketFetcher.configureTlsParameters(parameters, tlsHost);
|
||||||
ssl.setSSLParameters(parameters);
|
ssl.setSSLParameters(parameters);
|
||||||
ssl.setSoTimeout(readTimeout);
|
ssl.setSoTimeout(RawSocketFetcher.timeout(deadlineNanos, 5_000));
|
||||||
ssl.startHandshake();
|
ssl.startHandshake();
|
||||||
active = ssl;
|
active = ssl;
|
||||||
}
|
}
|
||||||
@@ -533,6 +566,10 @@ public class PersonalUrlFetchService {
|
|||||||
throw ex;
|
throw ex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static SSLSocketFactory defaultSslSocketFactory() {
|
||||||
|
return (SSLSocketFactory) SSLSocketFactory.getDefault();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private record SocketConnection(Socket socket) implements Connection {
|
private record SocketConnection(Socket socket) implements Connection {
|
||||||
@@ -570,51 +607,17 @@ public class PersonalUrlFetchService {
|
|||||||
if (maxBodyBytes < 0) throw new ServiceException(RESPONSE_TOO_LARGE);
|
if (maxBodyBytes < 0) throw new ServiceException(RESPONSE_TOO_LARGE);
|
||||||
try {
|
try {
|
||||||
BufferedInputStream buffered = input instanceof BufferedInputStream value ? value : new BufferedInputStream(input);
|
BufferedInputStream buffered = input instanceof BufferedInputStream value ? value : new BufferedInputStream(input);
|
||||||
int[] headerBytes = {0};
|
int interimCount = 0;
|
||||||
String statusLine = readLine(buffered, headerBytes);
|
|
||||||
if (statusLine == null || !statusLine.startsWith("HTTP/1.")) throw new ServiceException(RESPONSE_INVALID);
|
|
||||||
String[] statusParts = statusLine.split(" ", 3);
|
|
||||||
if (statusParts.length < 2) throw new ServiceException(RESPONSE_INVALID);
|
|
||||||
int status;
|
|
||||||
try { status = Integer.parseInt(statusParts[1]); }
|
|
||||||
catch (NumberFormatException ex) { throw new ServiceException(RESPONSE_INVALID); }
|
|
||||||
|
|
||||||
Map<String, List<String>> headers = new LinkedHashMap<>();
|
|
||||||
while (true) {
|
while (true) {
|
||||||
String line = readLine(buffered, headerBytes);
|
TransportResponse response = parseOneHttpResponse(buffered, maxBodyBytes);
|
||||||
if (line == null) throw new ServiceException(RESPONSE_INVALID);
|
if (response.status() == 101) throw new ServiceException(RESPONSE_INVALID);
|
||||||
if (line.isEmpty()) break;
|
if (response.status() == 100 || response.status() == 102 || response.status() == 103) {
|
||||||
int colon = line.indexOf(':');
|
if (++interimCount > 3) throw new ServiceException(RESPONSE_INVALID);
|
||||||
if (colon <= 0) throw new ServiceException(RESPONSE_INVALID);
|
continue;
|
||||||
String name = line.substring(0, colon).trim().toLowerCase(Locale.ROOT);
|
|
||||||
String value = line.substring(colon + 1).trim();
|
|
||||||
if (name.isEmpty()) throw new ServiceException(RESPONSE_INVALID);
|
|
||||||
headers.computeIfAbsent(name, ignored -> new ArrayList<>()).add(value);
|
|
||||||
}
|
|
||||||
String transferEncoding = strictFramingHeader(headers, "transfer-encoding");
|
|
||||||
String contentLength = strictFramingHeader(headers, "content-length");
|
|
||||||
if (transferEncoding != null && contentLength != null) throw new ServiceException(RESPONSE_INVALID);
|
|
||||||
byte[] body;
|
|
||||||
if (hasNoBody(status)) {
|
|
||||||
if (transferEncoding != null) throw new ServiceException(RESPONSE_INVALID);
|
|
||||||
if (status != 304 && contentLength != null) {
|
|
||||||
throw new ServiceException(RESPONSE_INVALID);
|
|
||||||
}
|
}
|
||||||
if (contentLength != null) parseContentLength(contentLength);
|
if (response.status() >= 100 && response.status() < 200) throw new ServiceException(RESPONSE_INVALID);
|
||||||
body = new byte[0];
|
return response;
|
||||||
} else if (transferEncoding != null) {
|
|
||||||
if (!"chunked".equalsIgnoreCase(transferEncoding.trim())) throw new ServiceException(RESPONSE_INVALID);
|
|
||||||
body = readChunked(buffered, maxBodyBytes);
|
|
||||||
} else if (contentLength != null) {
|
|
||||||
long length = parseContentLength(contentLength);
|
|
||||||
if (length > maxBodyBytes || length > Integer.MAX_VALUE) {
|
|
||||||
throw new ServiceException(RESPONSE_TOO_LARGE);
|
|
||||||
}
|
|
||||||
body = readExactly(buffered, (int) length);
|
|
||||||
} else {
|
|
||||||
body = readUntilEof(buffered, maxBodyBytes);
|
|
||||||
}
|
}
|
||||||
return new TransportResponse(status, headers, body);
|
|
||||||
} catch (ServiceException ex) {
|
} catch (ServiceException ex) {
|
||||||
throw ex;
|
throw ex;
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
@@ -622,6 +625,63 @@ public class PersonalUrlFetchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static TransportResponse parseOneHttpResponse(BufferedInputStream buffered, long maxBodyBytes) throws IOException {
|
||||||
|
int[] headerBytes = {0};
|
||||||
|
String statusLine = readLine(buffered, headerBytes);
|
||||||
|
if (statusLine == null || !(statusLine.startsWith("HTTP/1.0 ") || statusLine.startsWith("HTTP/1.1 "))) {
|
||||||
|
throw new ServiceException(RESPONSE_INVALID);
|
||||||
|
}
|
||||||
|
String[] statusParts = statusLine.split(" ", 3);
|
||||||
|
if (statusParts.length < 2) throw new ServiceException(RESPONSE_INVALID);
|
||||||
|
int status;
|
||||||
|
try { status = Integer.parseInt(statusParts[1]); }
|
||||||
|
catch (NumberFormatException ex) { throw new ServiceException(RESPONSE_INVALID); }
|
||||||
|
|
||||||
|
Map<String, List<String>> headers = new LinkedHashMap<>();
|
||||||
|
while (true) {
|
||||||
|
String line = readLine(buffered, headerBytes);
|
||||||
|
if (line == null) throw new ServiceException(RESPONSE_INVALID);
|
||||||
|
if (line.isEmpty()) break;
|
||||||
|
int colon = line.indexOf(':');
|
||||||
|
if (colon <= 0) throw new ServiceException(RESPONSE_INVALID);
|
||||||
|
String rawName = line.substring(0, colon);
|
||||||
|
if (!validHeaderName(rawName)) throw new ServiceException(RESPONSE_INVALID);
|
||||||
|
String name = rawName.toLowerCase(Locale.ROOT);
|
||||||
|
String value = line.substring(colon + 1).trim();
|
||||||
|
headers.computeIfAbsent(name, ignored -> new ArrayList<>()).add(value);
|
||||||
|
}
|
||||||
|
String transferEncoding = strictFramingHeader(headers, "transfer-encoding");
|
||||||
|
String contentLength = strictFramingHeader(headers, "content-length");
|
||||||
|
if (transferEncoding != null && contentLength != null) throw new ServiceException(RESPONSE_INVALID);
|
||||||
|
byte[] body;
|
||||||
|
if (hasNoBody(status)) {
|
||||||
|
if (transferEncoding != null) throw new ServiceException(RESPONSE_INVALID);
|
||||||
|
if (status != 304 && contentLength != null) throw new ServiceException(RESPONSE_INVALID);
|
||||||
|
if (contentLength != null) parseContentLength(contentLength);
|
||||||
|
body = new byte[0];
|
||||||
|
} else if (transferEncoding != null) {
|
||||||
|
if (!"chunked".equalsIgnoreCase(transferEncoding.trim())) throw new ServiceException(RESPONSE_INVALID);
|
||||||
|
body = readChunked(buffered, maxBodyBytes);
|
||||||
|
} else if (contentLength != null) {
|
||||||
|
long length = parseContentLength(contentLength);
|
||||||
|
if (length > maxBodyBytes || length > Integer.MAX_VALUE) throw new ServiceException(RESPONSE_TOO_LARGE);
|
||||||
|
body = readExactly(buffered, (int) length);
|
||||||
|
} else {
|
||||||
|
body = readUntilEof(buffered, maxBodyBytes);
|
||||||
|
}
|
||||||
|
return new TransportResponse(status, headers, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean validHeaderName(String name) {
|
||||||
|
if (name.isEmpty()) return false;
|
||||||
|
for (int i = 0; i < name.length(); i++) {
|
||||||
|
char ch = name.charAt(i);
|
||||||
|
boolean token = Character.isLetterOrDigit(ch) || "!#$%&'*+-.^_`|~".indexOf(ch) >= 0;
|
||||||
|
if (!token || ch > 127) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean hasNoBody(int status) {
|
private static boolean hasNoBody(int status) {
|
||||||
return status >= 100 && status < 200 || status == 204 || status == 304;
|
return status >= 100 && status < 200 || status == 204 || status == 304;
|
||||||
}
|
}
|
||||||
@@ -643,7 +703,10 @@ public class PersonalUrlFetchService {
|
|||||||
String trailer = readLine(input, framingBytes);
|
String trailer = readLine(input, framingBytes);
|
||||||
if (trailer == null) throw new ServiceException(RESPONSE_INVALID);
|
if (trailer == null) throw new ServiceException(RESPONSE_INVALID);
|
||||||
if (trailer.isEmpty()) return body.toByteArray();
|
if (trailer.isEmpty()) return body.toByteArray();
|
||||||
if (trailer.indexOf(':') <= 0) throw new ServiceException(RESPONSE_INVALID);
|
int colon = trailer.indexOf(':');
|
||||||
|
if (colon <= 0 || !validHeaderName(trailer.substring(0, colon))) {
|
||||||
|
throw new ServiceException(RESPONSE_INVALID);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ((long) body.size() + size > maxBodyBytes) throw new ServiceException(RESPONSE_TOO_LARGE);
|
if ((long) body.size() + size > maxBodyBytes) throw new ServiceException(RESPONSE_TOO_LARGE);
|
||||||
|
|||||||
+128
-21
@@ -7,21 +7,23 @@ import org.junit.jupiter.api.Test;
|
|||||||
|
|
||||||
import javax.net.ssl.SNIHostName;
|
import javax.net.ssl.SNIHostName;
|
||||||
import javax.net.ssl.SSLParameters;
|
import javax.net.ssl.SSLParameters;
|
||||||
|
import javax.net.ssl.SSLSocketFactory;
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.net.Socket;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayDeque;
|
import java.util.ArrayDeque;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Hashtable;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ArrayBlockingQueue;
|
|
||||||
import java.util.concurrent.ThreadPoolExecutor;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
@@ -40,6 +42,7 @@ class PersonalUrlFetchServiceTest {
|
|||||||
"file:///etc/passwd", "ftp://example.com/a", "data:text/plain,hello",
|
"file:///etc/passwd", "ftp://example.com/a", "data:text/plain,hello",
|
||||||
"http://user:secret@example.com", "http:///missing", "not a url",
|
"http://user:secret@example.com", "http:///missing", "not a url",
|
||||||
"http://localhost/admin", "http://service.localhost/admin",
|
"http://localhost/admin", "http://service.localhost/admin",
|
||||||
|
"http://[fe80::1%25en0]/admin",
|
||||||
"http://example.com/" + "x".repeat(5000))) {
|
"http://example.com/" + "x".repeat(5000))) {
|
||||||
assertCode("PERSONAL_URL_BLOCKED", () -> service.validate(raw), raw);
|
assertCode("PERSONAL_URL_BLOCKED", () -> service.validate(raw), raw);
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,15 @@ class PersonalUrlFetchServiceTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsEntireIanaSpecial192Dot0Dot0Slash24() {
|
||||||
|
for (int last : List.of(0, 8, 9, 10, 170, 171, 255)) {
|
||||||
|
String ip = "192.0.0." + last;
|
||||||
|
var service = fixture((host, deadline) -> List.of(address(ip)), request -> ok("text/plain", "ok"));
|
||||||
|
assertCode("PERSONAL_URL_BLOCKED", () -> service.validate("http://example.com"), ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsEmptyOrMixedDnsAnswersAndAcceptsPublicResolution() {
|
void rejectsEmptyOrMixedDnsAnswersAndAcceptsPublicResolution() {
|
||||||
assertCode("PERSONAL_URL_BLOCKED",
|
assertCode("PERSONAL_URL_BLOCKED",
|
||||||
@@ -204,21 +216,39 @@ class PersonalUrlFetchServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void boundedProductionDnsResolverTimesOutAndCancels() {
|
void boundedProductionDnsResolverTimesOutAndCancels() {
|
||||||
AtomicInteger interrupted = new AtomicInteger();
|
AtomicInteger timeoutSeen = new AtomicInteger();
|
||||||
ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS,
|
AtomicInteger retriesSeen = new AtomicInteger(-1);
|
||||||
new ArrayBlockingQueue<>(1), runnable -> { Thread thread = new Thread(runnable, "dns-test"); thread.setDaemon(true); return thread; },
|
var resolver = new PersonalUrlFetchService.DeadlineDnsResolver((host, timeoutMillis, retries) -> {
|
||||||
new ThreadPoolExecutor.AbortPolicy());
|
timeoutSeen.set(timeoutMillis); retriesSeen.set(retries);
|
||||||
try {
|
return List.of("93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946");
|
||||||
var resolver = new PersonalUrlFetchService.DeadlineDnsResolver(executor, host -> {
|
});
|
||||||
try { Thread.sleep(5_000); }
|
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(200);
|
||||||
catch (InterruptedException ex) { interrupted.incrementAndGet(); Thread.currentThread().interrupt(); }
|
assertEquals(2, assertDoesNotThrow(() -> resolver.resolve("example.com", deadline)).size());
|
||||||
return List.of(PUBLIC);
|
assertTrue(timeoutSeen.get() > 0 && timeoutSeen.get() <= 200);
|
||||||
});
|
assertEquals(0, retriesSeen.get());
|
||||||
assertThrows(IOException.class, () -> resolver.resolve("example.com", System.nanoTime() + 20_000_000L));
|
assertThrows(IOException.class, () -> resolver.resolve("example.com", System.nanoTime() - 1));
|
||||||
assertTrue(interrupted.get() > 0 || executor.getActiveCount() == 0);
|
var nonNumeric = new PersonalUrlFetchService.DeadlineDnsResolver(
|
||||||
} finally {
|
(host, timeoutMillis, retries) -> List.of("internal.example", "fe80::1%en0"));
|
||||||
executor.shutdownNow();
|
assertThrows(IOException.class, () -> nonNumeric.resolve("example.com",
|
||||||
}
|
System.nanoTime() + TimeUnit.SECONDS.toNanos(1)));
|
||||||
|
|
||||||
|
Hashtable<String, String> environment = PersonalUrlFetchService.JndiDnsQuery.environment(123);
|
||||||
|
assertEquals("123", environment.get("com.sun.jndi.dns.timeout.initial"));
|
||||||
|
assertEquals("0", environment.get("com.sun.jndi.dns.timeout.retries"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void preservesRawPathQueryAndUnicodeEncodingAcrossNormalizationAndRedirects() {
|
||||||
|
var seen = new ArrayList<PersonalUrlFetchService.FetchRequest>();
|
||||||
|
var responses = new ArrayDeque<PersonalUrlFetchService.TransportResponse>();
|
||||||
|
responses.add(response(302, Map.of("location", List.of("../%E4%B8%AD%2Fnext?sig=a%252Fb%2Fz")), new byte[0]));
|
||||||
|
responses.add(ok("text/plain", "ok"));
|
||||||
|
var service = fixture((host, deadline) -> List.of(PUBLIC), request -> { seen.add(request); return responses.remove(); });
|
||||||
|
service.fetch("https://example.com/a/%2Fkeep?x=%25&u=%E4%B8%AD#fragment");
|
||||||
|
assertEquals("/a/%2Fkeep", seen.get(0).uri().getRawPath());
|
||||||
|
assertEquals("x=%25&u=%E4%B8%AD", seen.get(0).uri().getRawQuery());
|
||||||
|
assertEquals("/%E4%B8%AD%2Fnext", seen.get(1).uri().getRawPath());
|
||||||
|
assertEquals("sig=a%252Fb%2Fz", seen.get(1).uri().getRawQuery());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -240,7 +270,7 @@ class PersonalUrlFetchServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void noBodyStatusesDoNotWaitForPayload() {
|
void noBodyStatusesDoNotWaitForPayload() {
|
||||||
for (int status : List.of(100, 204, 304)) {
|
for (int status : List.of(204, 304)) {
|
||||||
var response = PersonalUrlFetchService.parseHttpResponse(stream(
|
var response = PersonalUrlFetchService.parseHttpResponse(stream(
|
||||||
"HTTP/1.1 " + status + " No Body\r\nContent-Type: text/plain\r\n\r\n"), 100);
|
"HTTP/1.1 " + status + " No Body\r\nContent-Type: text/plain\r\n\r\n"), 100);
|
||||||
assertEquals(0, response.body().length);
|
assertEquals(0, response.body().length);
|
||||||
@@ -251,6 +281,31 @@ class PersonalUrlFetchServiceTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void consumesLimitedInterimResponsesAndRejectsSwitchingProtocols() {
|
||||||
|
String finalResponse = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nok";
|
||||||
|
String interim = "HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 103 Early Hints\r\nLink: </a>\r\n\r\n" + finalResponse;
|
||||||
|
assertEquals("ok", new String(PersonalUrlFetchService.parseHttpResponse(stream(interim), 100).body(), StandardCharsets.US_ASCII));
|
||||||
|
assertCode("PERSONAL_URL_RESPONSE_INVALID", () -> PersonalUrlFetchService.parseHttpResponse(stream(
|
||||||
|
"HTTP/1.1 101 Switching Protocols\r\n\r\n"), 100), "101");
|
||||||
|
String tooMany = "HTTP/1.1 100 Continue\r\n\r\n".repeat(4) + finalResponse;
|
||||||
|
assertCode("PERSONAL_URL_RESPONSE_INVALID", () -> PersonalUrlFetchService.parseHttpResponse(stream(tooMany), 100), "interim limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidHeaderNamesObsFoldAndDuplicateSemanticHeaders() {
|
||||||
|
for (String line : List.of("Bad Header: x", "Content-Type : text/plain", "\tcontinued")) {
|
||||||
|
assertCode("PERSONAL_URL_RESPONSE_INVALID", () -> PersonalUrlFetchService.parseHttpResponse(stream(
|
||||||
|
"HTTP/1.1 200 OK\r\n" + line + "\r\n\r\n"), 100), line);
|
||||||
|
}
|
||||||
|
assertCode("PERSONAL_URL_RESPONSE_INVALID", () -> fixture((host, deadline) -> List.of(PUBLIC), request ->
|
||||||
|
response(200, Map.of("content-type", List.of("text/plain", "text/html")), new byte[0]))
|
||||||
|
.fetch("https://example.com"), "duplicate content type");
|
||||||
|
assertCode("PERSONAL_URL_RESPONSE_INVALID", () -> fixture((host, deadline) -> List.of(PUBLIC), request ->
|
||||||
|
response(302, Map.of("location", List.of("/a", "/b")), new byte[0]))
|
||||||
|
.fetch("https://example.com"), "duplicate location");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsOversizedHeaderBlockAndLine() {
|
void rejectsOversizedHeaderBlockAndLine() {
|
||||||
String longLine = "X-Large: " + "x".repeat(8 * 1024 + 1);
|
String longLine = "X-Large: " + "x".repeat(8 * 1024 + 1);
|
||||||
@@ -272,9 +327,10 @@ class PersonalUrlFetchServiceTest {
|
|||||||
ByteArrayOutputStream requestBytes = new ByteArrayOutputStream();
|
ByteArrayOutputStream requestBytes = new ByteArrayOutputStream();
|
||||||
byte[] rawResponse = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nok"
|
byte[] rawResponse = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nok"
|
||||||
.getBytes(StandardCharsets.US_ASCII);
|
.getBytes(StandardCharsets.US_ASCII);
|
||||||
PersonalUrlFetchService.ConnectionFactory factory = (uri, address, port, connectTimeout, readTimeout) -> {
|
PersonalUrlFetchService.ConnectionFactory factory = (uri, address, port, deadlineNanos) -> {
|
||||||
connected.set(address); host.set(uri.getHost());
|
connected.set(address); host.set(uri.getHost());
|
||||||
connectTimeoutSeen.set(connectTimeout); readTimeoutSeen.set(readTimeout);
|
int remaining = (int) TimeUnit.NANOSECONDS.toMillis(deadlineNanos - System.nanoTime());
|
||||||
|
connectTimeoutSeen.set(remaining); readTimeoutSeen.set(remaining);
|
||||||
return new FakeConnection(new ByteArrayInputStream(rawResponse), requestBytes);
|
return new FakeConnection(new ByteArrayInputStream(rawResponse), requestBytes);
|
||||||
};
|
};
|
||||||
var fetcher = new PersonalUrlFetchService.RawSocketFetcher(factory);
|
var fetcher = new PersonalUrlFetchService.RawSocketFetcher(factory);
|
||||||
@@ -302,13 +358,31 @@ class PersonalUrlFetchServiceTest {
|
|||||||
SSLParameters parameters = PersonalUrlFetchService.RawSocketFetcher.tlsParameters("origin.example");
|
SSLParameters parameters = PersonalUrlFetchService.RawSocketFetcher.tlsParameters("origin.example");
|
||||||
assertEquals("HTTPS", parameters.getEndpointIdentificationAlgorithm());
|
assertEquals("HTTPS", parameters.getEndpointIdentificationAlgorithm());
|
||||||
assertEquals("origin.example", ((SNIHostName) parameters.getServerNames().get(0)).getAsciiName());
|
assertEquals("origin.example", ((SNIHostName) parameters.getServerNames().get(0)).getAsciiName());
|
||||||
|
assertEquals(SSLSocketFactory.getDefault().getClass(),
|
||||||
|
PersonalUrlFetchService.JvmConnectionFactory.defaultSslSocketFactory().getClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rawTransportWritesBracketedIpv6Host() throws Exception {
|
||||||
|
ByteArrayOutputStream requestBytes = new ByteArrayOutputStream();
|
||||||
|
byte[] rawResponse = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nok"
|
||||||
|
.getBytes(StandardCharsets.US_ASCII);
|
||||||
|
PersonalUrlFetchService.ConnectionFactory factory = (uri, address, port, deadlineNanos) ->
|
||||||
|
new FakeConnection(new ByteArrayInputStream(rawResponse), requestBytes);
|
||||||
|
var fetcher = new PersonalUrlFetchService.RawSocketFetcher(factory);
|
||||||
|
URI uri = URI.create("http://[2606:2800:220:1:248:1893:25c8:1946]:8080/a");
|
||||||
|
fetcher.fetch(new PersonalUrlFetchService.FetchRequest(uri,
|
||||||
|
List.of(address("2606:2800:220:1:248:1893:25c8:1946")),
|
||||||
|
System.nanoTime() + TimeUnit.SECONDS.toNanos(1), 100, Map.of()));
|
||||||
|
assertTrue(requestBytes.toString(StandardCharsets.US_ASCII)
|
||||||
|
.contains("Host: [2606:2800:220:1:248:1893:25c8:1946]:8080\r\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rawTransportEnforcesTotalDeadlineWhileReadingSlowBody() {
|
void rawTransportEnforcesTotalDeadlineWhileReadingSlowBody() {
|
||||||
byte[] rawResponse = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nok"
|
byte[] rawResponse = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nok"
|
||||||
.getBytes(StandardCharsets.US_ASCII);
|
.getBytes(StandardCharsets.US_ASCII);
|
||||||
PersonalUrlFetchService.ConnectionFactory factory = (uri, address, port, connectTimeout, readTimeout) ->
|
PersonalUrlFetchService.ConnectionFactory factory = (uri, address, port, deadlineNanos) ->
|
||||||
new FakeConnection(new SlowInputStream(rawResponse, 30), new ByteArrayOutputStream());
|
new FakeConnection(new SlowInputStream(rawResponse, 30), new ByteArrayOutputStream());
|
||||||
var fetcher = new PersonalUrlFetchService.RawSocketFetcher(factory);
|
var fetcher = new PersonalUrlFetchService.RawSocketFetcher(factory);
|
||||||
var request = new PersonalUrlFetchService.FetchRequest(URI.create("http://origin.example/"), List.of(PUBLIC),
|
var request = new PersonalUrlFetchService.FetchRequest(URI.create("http://origin.example/"), List.of(PUBLIC),
|
||||||
@@ -316,6 +390,39 @@ class PersonalUrlFetchServiceTest {
|
|||||||
assertThrows(IOException.class, () -> fetcher.fetch(request));
|
assertThrows(IOException.class, () -> fetcher.fetch(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rawTransportUsesOriginFormOverRealLoopbackSocket() throws Exception {
|
||||||
|
InetAddress loopback = InetAddress.getLoopbackAddress();
|
||||||
|
try (ServerSocket server = new ServerSocket(0, 1, loopback)) {
|
||||||
|
AtomicReference<String> wire = new AtomicReference<>();
|
||||||
|
Thread peer = new Thread(() -> {
|
||||||
|
try (Socket socket = server.accept()) {
|
||||||
|
socket.setSoTimeout(2_000);
|
||||||
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||||
|
int value;
|
||||||
|
while ((value = socket.getInputStream().read()) >= 0) {
|
||||||
|
bytes.write(value);
|
||||||
|
byte[] data = bytes.toByteArray();
|
||||||
|
int size = data.length;
|
||||||
|
if (size >= 4 && data[size - 4] == '\r' && data[size - 3] == '\n'
|
||||||
|
&& data[size - 2] == '\r' && data[size - 1] == '\n') break;
|
||||||
|
}
|
||||||
|
wire.set(bytes.toString(StandardCharsets.US_ASCII));
|
||||||
|
socket.getOutputStream().write("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nok"
|
||||||
|
.getBytes(StandardCharsets.US_ASCII));
|
||||||
|
} catch (IOException ex) { throw new AssertionError(ex); }
|
||||||
|
}, "url-fetch-loopback-peer");
|
||||||
|
peer.start();
|
||||||
|
var fetcher = new PersonalUrlFetchService.RawSocketFetcher();
|
||||||
|
URI uri = URI.create("http://public.example:" + server.getLocalPort() + "/raw/%2F?a=%25");
|
||||||
|
var response = fetcher.fetch(new PersonalUrlFetchService.FetchRequest(uri, List.of(loopback),
|
||||||
|
System.nanoTime() + TimeUnit.SECONDS.toNanos(2), 100, Map.of()));
|
||||||
|
peer.join(2_000);
|
||||||
|
assertEquals("ok", new String(response.body(), StandardCharsets.US_ASCII));
|
||||||
|
assertTrue(wire.get().startsWith("GET /raw/%2F?a=%25 HTTP/1.1\r\nHost: public.example:" + server.getLocalPort()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static PersonalUrlFetchService fixture(PersonalUrlFetchService.Resolver resolver,
|
private static PersonalUrlFetchService fixture(PersonalUrlFetchService.Resolver resolver,
|
||||||
PersonalUrlFetchService.Fetcher fetcher) {
|
PersonalUrlFetchService.Fetcher fetcher) {
|
||||||
return PersonalUrlFetchService.forTest(properties(), resolver, fetcher);
|
return PersonalUrlFetchService.forTest(properties(), resolver, fetcher);
|
||||||
|
|||||||
Reference in New Issue
Block a user