OslcTransport.java
package com.taxonomy.interop.oslc;
import com.taxonomy.exchange.ExchangeXml;
import com.taxonomy.interop.IntegrationProblem;
import com.taxonomy.interop.persistence.IntegrationStore.Connection;
import com.taxonomy.workspace.service.RepositoryContext;
import com.taxonomy.workspace.service.SystemRepositoryService;
import org.apache.hc.client5.http.DnsResolver;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.core5.util.Timeout;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.*;
import java.util.*;
/** Scoped HTTPS transport with DNS validation in the actual connection path, bounded responses and no redirect/retry. */
@Component
public class OslcTransport {
private final OslcRemoteProfiles profiles;
private final SystemRepositoryService repositories;
private final Environment environment;
private final java.util.concurrent.ScheduledExecutorService deadlines = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(r -> {
Thread thread = new Thread(r, "oslc-deadline"); thread.setDaemon(true); return thread;
});
@jakarta.annotation.PreDestroy public void close() { deadlines.shutdownNow(); }
public OslcTransport(OslcRemoteProfiles profiles, SystemRepositoryService repositories, Environment environment) {
this.profiles = profiles; this.repositories = repositories; this.environment = environment;
}
public record Response(URI resource, String etag, byte[] content) { public Response { content = content.clone(); } @Override public byte[] content() { return content.clone(); } }
public URI validate(RepositoryContext context, Connection connection, String resource) {
return target(profile(context, connection), resource);
}
public Response read(RepositoryContext context, Connection connection, String resource, String expectedVersion) {
var profile = profile(context, connection); URI target = target(profile, resource);
DnsResolver resolver = new DnsResolver() {
@Override public InetAddress[] resolve(String hostname) throws UnknownHostException {
if (!target.getHost().equalsIgnoreCase(hostname)) throw new UnknownHostException("Host outside configured integration origin");
InetAddress[] addresses = InetAddress.getAllByName(hostname);
for (InetAddress address : addresses) if (!allowedAddress(address, profile.allowPrivateNetworks())) throw new UnknownHostException("Address outside integration network policy");
return addresses;
}
@Override public String resolveCanonicalHostname(String hostname) throws UnknownHostException { resolve(hostname); return hostname; }
};
var manager = PoolingHttpClientConnectionManagerBuilder.create().setDnsResolver(resolver)
.setDefaultConnectionConfig(ConnectionConfig.custom().setConnectTimeout(Timeout.ofSeconds(3)).setSocketTimeout(Timeout.ofSeconds(5)).build())
.setMaxConnTotal(1).setMaxConnPerRoute(1).build();
try (var client = HttpClients.custom().setConnectionManager(manager).disableRedirectHandling().disableAutomaticRetries().disableCookieManagement()
.disableContentCompression().setDefaultRequestConfig(RequestConfig.custom().setConnectionRequestTimeout(Timeout.ofSeconds(2)).setResponseTimeout(Timeout.ofSeconds(5)).build()).build()) {
HttpGet request = new HttpGet(target); request.setHeader("Accept", "application/rdf+xml"); request.setHeader("OSLC-Core-Version", "3.0");
if (expectedVersion != null) {
if (expectedVersion.length() > 2048 || expectedVersion.chars().anyMatch(Character::isISOControl)) throw new IllegalArgumentException("Invalid external version");
request.setHeader("If-Match", expectedVersion);
}
String configuration = connection.externalScope().configuration();
if (configuration != null) {
URI uri = URI.create(configuration);
if (!uri.isAbsolute() || configuration.length() > 2048 || configuration.chars().anyMatch(Character::isISOControl)) throw new IllegalArgumentException("Invalid configuration");
request.setHeader("Configuration-Context", configuration);
}
if (profile.credentialEnvironmentVariable() != null) {
if (!profile.credentialEnvironmentVariable().matches("[A-Z][A-Z0-9_]{0,127}")) throw new IllegalStateException("Invalid credential reference");
String token = environment.getProperty(profile.credentialEnvironmentVariable());
if (token == null || !token.matches("[A-Za-z0-9._~+/=-]{1,8192}")) throw new IntegrationProblem("REMOTE_CREDENTIAL_UNAVAILABLE", 503, "Configured credential is unavailable");
request.setHeader("Authorization", "Bearer " + token);
}
long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
var cancellation = deadlines.schedule(() -> request.cancel(), 15, java.util.concurrent.TimeUnit.SECONDS);
try { return client.execute(request, response -> {
int code = response.getCode();
if (code != 200) throw remoteFailure(code);
String media = response.getFirstHeader("Content-Type") == null ? "" : response.getFirstHeader("Content-Type").getValue().split(";", 2)[0].strip();
if (!media.equalsIgnoreCase("application/rdf+xml")) throw new IntegrationProblem("REMOTE_MEDIA_TYPE", 502, "OSLC provider did not return the requested RDF/XML representation");
if (response.getEntity() == null || response.getEntity().getContentLength() > ExchangeXml.MAX_BYTES) throw new IntegrationProblem("REMOTE_RESPONSE_LIMIT", 502, "Remote response exceeds the supported bound");
try (var input = response.getEntity().getContent(); var output = new ByteArrayOutputStream()) {
byte[] buffer = new byte[8192]; int count;
while ((count = input.read(buffer)) != -1) {
if (System.nanoTime() > deadline) throw new IntegrationProblem("REMOTE_TIMEOUT", 504, "Remote response deadline exceeded");
if (output.size() + count > ExchangeXml.MAX_BYTES) throw new IntegrationProblem("REMOTE_RESPONSE_LIMIT", 502, "Remote response exceeds the supported bound");
output.write(buffer, 0, count);
}
String etag = response.getFirstHeader("ETag") == null ? null : response.getFirstHeader("ETag").getValue();
if (etag != null && (etag.length() > 2048 || etag.chars().anyMatch(Character::isISOControl))) throw new IntegrationProblem("REMOTE_VERSION_INVALID", 502, "Invalid remote version metadata");
return new Response(target, etag, output.toByteArray());
}
}); } finally { cancellation.cancel(false); }
} catch (SocketTimeoutException failure) { throw new IntegrationProblem("REMOTE_TIMEOUT", 504, "Remote request timed out"); }
catch (IOException failure) { throw new IntegrationProblem("REMOTE_UNAVAILABLE", 502, "Remote resource is unavailable under the configured network policy"); }
}
private OslcRemoteProfiles.RemoteProfile profile(RepositoryContext context, Connection connection) {
var value = connection.remoteProfile() == null ? null : profiles.getRemotes().get(connection.remoteProfile());
var repository = repositories.getRepository(context.repositoryId());
String owner = repository.getOwnerType() + ":" + repository.getOwnerId();
if (value == null || !context.repositoryId().equals(value.repositoryId()) || !owner.equals(value.organizationId()) || !owner.equals(connection.organizationId())) throw IntegrationProblem.missing();
return value;
}
public static URI target(OslcRemoteProfiles.RemoteProfile profile, String resource) {
URI base = profile.baseUri();
if (base == null || base.getHost() == null || base.getRawUserInfo() != null || base.getRawQuery() != null || base.getRawFragment() != null
|| !base.getPath().endsWith("/") || !(base.getScheme().equals("https") || profile.allowInsecureHttp() && base.getScheme().equals("http")))
throw new IntegrationProblem("REMOTE_PROFILE_INVALID", 503, "Configured endpoint must define an explicit origin and path boundary");
URI uri;
try { uri = resource == null || resource.isBlank() ? base : base.resolve(URI.create(resource)); }
catch (IllegalArgumentException invalid) { throw new IntegrationProblem("REMOTE_URI_REJECTED", 400, "Invalid remote resource identity"); }
String rawPath = uri.getRawPath() == null ? "" : uri.getRawPath().toLowerCase(Locale.ROOT);
if (!Objects.equals(uri.getScheme(), base.getScheme()) || !Objects.equals(uri.getHost(), base.getHost()) || port(uri) != port(base)
|| uri.getUserInfo() != null || uri.getFragment() != null || !uri.normalize().getPath().startsWith(base.getPath())
|| rawPath.contains("%2e") || rawPath.contains("%2f") || rawPath.contains("%5c") || rawPath.contains("%25") || rawPath.contains("\\")
|| uri.getPath().chars().anyMatch(Character::isISOControl) || uri.toString().length() > 2048)
throw new IntegrationProblem("REMOTE_URI_REJECTED", 400, "Resource is outside the configured integration boundary");
if (uri.getRawQuery() != null) for (String parameter : uri.getRawQuery().split("&")) {
String name = URLDecoder.decode(parameter.split("=", 2)[0], java.nio.charset.StandardCharsets.UTF_8);
if (!Set.of("oslc.where", "oslc.select", "oslc.searchTerms", "oslc.prefix", "oslc.orderBy", "oslc.paging", "oslc.pageSize", "page", "pageToken", "configuration", "oslc_config.context", "repositoryId", "workspaceId", "branch").contains(name))
throw new IntegrationProblem("REMOTE_QUERY_REJECTED", 400, "Remote query parameter is outside the declared OSLC profile");
}
return uri.normalize();
}
private static int port(URI uri) { return uri.getPort() < 0 ? uri.getScheme().equals("https") ? 443 : 80 : uri.getPort(); }
public static boolean allowedAddress(InetAddress address, boolean privateNetworks) {
if (address.isAnyLocalAddress() || address.isLinkLocalAddress() || address.isMulticastAddress()) return false;
byte[] bytes = address.getAddress();
if (bytes.length == 4) {
int a = Byte.toUnsignedInt(bytes[0]), b = Byte.toUnsignedInt(bytes[1]);
if (a == 0 || a >= 240 || a == 169 && b == 254) return false;
if (!privateNetworks && (a == 100 && b >= 64 && b <= 127 || a == 198 && (b == 18 || b == 19))) return false;
} else if (!privateNetworks && (bytes[0] & 0xfe) == 0xfc) return false;
return privateNetworks || !(address.isLoopbackAddress() || address.isSiteLocalAddress());
}
private static IntegrationProblem remoteFailure(int status) {
String code = switch (status) { case 401, 403 -> "REMOTE_UNAUTHORIZED"; case 404, 410 -> "REMOTE_MISSING"; case 412 -> "REMOTE_STALE"; case 429 -> "REMOTE_RATE_LIMITED"; case 301, 302, 303, 307, 308 -> "REMOTE_MOVED"; default -> "REMOTE_UNAVAILABLE"; };
return new IntegrationProblem(code, status == 412 ? 409 : status == 429 ? 429 : 502, "Remote request was not accepted; no synchronization success was recorded");
}
}