Skip to content
Merged

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ public class AS2AsynchronousMDNManager {
private String password;
private String accessToken;
private String allowedHosts;
private boolean requireHttps;

/**
* @deprecated use
Expand Down Expand Up @@ -127,7 +128,22 @@ public AS2AsynchronousMDNManager(String as2Version,
String password,
String accessToken,
String allowedHosts) {
this(as2Version, userAgent, senderFQDN, signingCertificateChain, signingPrivateKey, userName, password,
accessToken, allowedHosts, false);
}

public AS2AsynchronousMDNManager(String as2Version,
String userAgent,
String senderFQDN,
Certificate[] signingCertificateChain,
PrivateKey signingPrivateKey,
String userName,
String password,
String accessToken,
String allowedHosts,
boolean requireHttps) {
this.allowedHosts = allowedHosts;
this.requireHttps = requireHttps;
this.signingCertificateChain = signingCertificateChain;
this.signingPrivateKey = signingPrivateKey;
this.userName = userName;
Expand Down Expand Up @@ -155,6 +171,14 @@ public HttpCoreContext send(
// header), so it is untrusted input that selects an outbound destination.
URI uri = URI.create(recipientDeliveryAddress);
String scheme = uri.getScheme() == null ? null : uri.getScheme().toLowerCase(Locale.US);
// When the operator requires https (asyncMdnRequireHttps), refuse a plain-http delivery address before the
// connection is opened and before the credentials are attached, so they are never written in cleartext to a
// sender-chosen destination.
if (requireHttps && !"https".equals(scheme)) {
throw new HttpException(
"Refusing to deliver the asynchronous MDN: asyncMdnRequireHttps is set but the delivery address"
+ " does not use https");
}
// Only http. This class delivers over a plain Socket and has no TLS of any kind, so accepting https
// would mean writing the request - including the Authorization header - in cleartext to the TLS port.
// https delivery has never worked here for that reason, so refusing it removes nothing that functioned.
Expand All @@ -167,12 +191,15 @@ public HttpCoreContext send(
if (host == null) {
throw new HttpException("Refusing to deliver the asynchronous MDN: the delivery address has no host");
}
int port = uri.getPort() != -1 ? uri.getPort() : 80;
int defaultPort = 80;
int port = uri.getPort() != -1 ? uri.getPort() : defaultPort;

boolean hostIsAllowed = isAllowedHost(host);
if (allowedHosts != null && !allowedHosts.isBlank() && !hostIsAllowed) {
// Match the full authority - host and port - of the delivery address against the allow-list, not the host
// alone, so a listed host does not authorise every port on that host.
boolean authorityIsAllowed = isAllowedAuthority(host, port, defaultPort);
if (allowedHosts != null && !allowedHosts.isBlank() && !authorityIsAllowed) {
throw new HttpException(
"Refusing to deliver the asynchronous MDN: the delivery address host is not in asyncMdnAllowedHosts");
"Refusing to deliver the asynchronous MDN: the delivery address is not in asyncMdnAllowedHosts");
}

int buffSize = 8 * 1024;
Expand All @@ -189,14 +216,14 @@ public HttpCoreContext send(

ClassicHttpRequest request = new BasicClassicHttpRequest("POST", uri);
request.setHeader(AS2Header.CONTENT_TYPE, contentType);
// Credentials are only attached to a host the operator has vouched for. Without an allow-list the
// destination is entirely sender-chosen, so the MDN is still delivered but without them.
if (hostIsAllowed) {
// Credentials are only attached to an authority - host and port - the operator has vouched for. Without an
// allow-list the destination is entirely sender-chosen, so the MDN is still delivered but without them.
if (authorityIsAllowed) {
AS2HeaderUtils.addAuthorizationHeader(request, userName, password, accessToken);
} else if (userName != null || accessToken != null) {
LOG.warn("Asynchronous MDN credentials not sent to sender-supplied host {}:"
LOG.warn("Asynchronous MDN credentials not sent to sender-supplied authority {}:"
+ " set asyncMdnAllowedHosts to authorise it",
host);
formatAuthority(host, port));
}
httpContext.setAttribute(HttpCoreContext.HTTP_REQUEST, request);
multipartMimeEntity.setMainBody(true);
Expand Down Expand Up @@ -228,18 +255,82 @@ private static String normalizeHost(String host) {
return host;
}

private boolean isAllowedHost(String host) {
/**
* Writes a delivery authority the way an {@code asyncMdnAllowedHosts} entry is written, so a log message names an
* entry the operator can copy. The host has been through normalizeHost, so an IPv6 literal (the only host that
* contains a colon) is put back in square brackets ({@code [::1]:8443}): unbracketed, {@code ::1:8443} would be
* read as another IPv6 address on the default port. Any other host is written as {@code host:port}.
*/
static String formatAuthority(String host, int port) {
return host.indexOf(':') >= 0 ? "[" + host + "]:" + port : host + ":" + port;
}

/**
* Matches the resolved delivery authority - host and port - against {@code asyncMdnAllowedHosts}. Each entry is a
* host with an optional port ({@code host} or {@code host:port}); a bare-host entry matches only the default port
* for the scheme ({@code defaultPort}), so a listed host does not authorise every port on that host.
*/
boolean isAllowedAuthority(String host, int port, int defaultPort) {
if (allowedHosts == null || allowedHosts.isBlank()) {
return false;
}
for (String allowed : allowedHosts.split(",")) {
if (allowed.trim().equalsIgnoreCase(host)) {
String entry = allowed.trim();
if (!entry.isEmpty() && authorityMatches(entry, host, port, defaultPort)) {
return true;
}
}
return false;
}

private static boolean authorityMatches(String entry, String host, int port, int defaultPort) {
String entryHost;
int entryPort;
if (entry.charAt(0) == '[') {
// bracketed IPv6 literal, optionally followed by :port
int close = entry.indexOf(']');
if (close < 0) {
return false;
}
entryHost = entry.substring(1, close);
entryPort = defaultPort;
if (close + 1 < entry.length()) {
if (entry.charAt(close + 1) != ':') {
return false;
}
Integer parsed = parsePort(entry.substring(close + 2));
if (parsed == null) {
return false;
}
entryPort = parsed;
}
} else {
int colon = entry.indexOf(':');
if (colon >= 0 && entry.indexOf(':', colon + 1) < 0) {
// a single colon separates host and port; an unbracketed IPv6 literal has several and keeps the default
entryHost = entry.substring(0, colon);
Integer parsed = parsePort(entry.substring(colon + 1));
if (parsed == null) {
return false;
}
entryPort = parsed;
} else {
entryHost = entry;
entryPort = defaultPort;
}
}
return entryPort == port && entryHost.equalsIgnoreCase(host);
}

private static Integer parsePort(String value) {
try {
int parsed = Integer.parseInt(value.trim());
return parsed >= 0 && parsed <= 65535 ? parsed : null;
} catch (NumberFormatException e) {
return null;
}
}

private HttpResponse send(HttpClientConnection httpConnection, ClassicHttpRequest request, HttpCoreContext httpContext)
throws HttpException, IOException {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ public static class AS2ConsumerConfiguration {
private final String mdnPassword;
private final String mdnAccessToken;
private final String asyncMdnAllowedHosts;
private final boolean asyncMdnRequireHttps;

/**
* Creates a configuration without asynchronous MDN settings: an asynchronous MDN for a request matched by it is
Expand Down Expand Up @@ -168,6 +169,26 @@ public AS2ConsumerConfiguration(
String mdnPassword,
String mdnAccessToken,
String asyncMdnAllowedHosts) {
this(signingAlgorithm, signingCertificateChain, signingPrivateKey, decryptingPrivateKey,
validateSigningCertificateChain, mdnUserName, mdnPassword, mdnAccessToken, asyncMdnAllowedHosts, false);
}

/**
* Creates a configuration that additionally requires asynchronous MDN delivery to use https
* ({@code asyncMdnRequireHttps}): a delivery address matched by it that does not use https is refused before
* the connection is opened and before the credentials are attached.
*/
public AS2ConsumerConfiguration(
AS2SignatureAlgorithm signingAlgorithm,
Certificate[] signingCertificateChain,
PrivateKey signingPrivateKey,
PrivateKey decryptingPrivateKey,
Certificate[] validateSigningCertificateChain,
String mdnUserName,
String mdnPassword,
String mdnAccessToken,
String asyncMdnAllowedHosts,
boolean asyncMdnRequireHttps) {
this.signingAlgorithm = signingAlgorithm;
this.signingCertificateChain = signingCertificateChain;
this.signingPrivateKey = signingPrivateKey;
Expand All @@ -177,6 +198,7 @@ public AS2ConsumerConfiguration(
this.mdnPassword = mdnPassword;
this.mdnAccessToken = mdnAccessToken;
this.asyncMdnAllowedHosts = asyncMdnAllowedHosts;
this.asyncMdnRequireHttps = asyncMdnRequireHttps;
}

// Getters
Expand Down Expand Up @@ -215,6 +237,10 @@ public String getMdnAccessToken() {
public String getAsyncMdnAllowedHosts() {
return asyncMdnAllowedHosts;
}

public boolean isAsyncMdnRequireHttps() {
return asyncMdnRequireHttps;
}
}

/**
Expand Down Expand Up @@ -543,7 +569,8 @@ public void run() {
config.getMdnUserName(),
config.getMdnPassword(),
config.getMdnAccessToken(),
config.getAsyncMdnAllowedHosts());
config.getAsyncMdnAllowedHosts(),
config.isAsyncMdnRequireHttps());

HttpRequest request = coreContext.getAttribute(HttpCoreContext.HTTP_REQUEST, HttpRequest.class);
AS2SignedDataGenerator gen = ResponseMDN.createSigningGenerator(
Expand Down Expand Up @@ -624,6 +651,28 @@ public AS2ServerConnection(String as2Version,
String accessToken,
String asyncMdnAllowedHosts)
throws IOException {
this(as2Version, originServer, serverFqdn, serverPortNumber, signingAlgorithm, signingCertificateChain,
signingPrivateKey, decryptingPrivateKey, mdnMessageTemplate, validateSigningCertificateChain, sslContext,
userName, password, accessToken, asyncMdnAllowedHosts, false);
}

public AS2ServerConnection(String as2Version,
String originServer,
String serverFqdn,
Integer serverPortNumber,
AS2SignatureAlgorithm signingAlgorithm,
Certificate[] signingCertificateChain,
PrivateKey signingPrivateKey,
PrivateKey decryptingPrivateKey,
String mdnMessageTemplate,
Certificate[] validateSigningCertificateChain,
SSLContext sslContext,
String userName,
String password,
String accessToken,
String asyncMdnAllowedHosts,
boolean asyncMdnRequireHttps)
throws IOException {
this.as2Version = ObjectHelper.notNull(as2Version, "as2Version");
this.originServer = ObjectHelper.notNull(originServer, "userAgent");
this.serverFqdn = ObjectHelper.notNull(serverFqdn, "serverFqdn");
Expand All @@ -643,7 +692,8 @@ public AS2ServerConnection(String as2Version,
userName,
password,
accessToken,
asyncMdnAllowedHosts);
asyncMdnAllowedHosts,
asyncMdnRequireHttps);
registerConsumerConfiguration("/", consumerConfig);

listenerService = new RequestListenerService(
Expand Down
Loading
Loading