Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package uk.co.compendiumdev.thingifier.adapter.httpserver;

final class HttpRequestOrigin {

private HttpRequestOrigin() {}

static String from(final HttpServerRequest request) {
return "%s://%s".formatted(schemeFrom(request), hostFrom(request));
}

private static String schemeFrom(final HttpServerRequest request) {
final String forwardedProto = forwardedHeaderValue(request.header("Forwarded"), "proto");
if (hasText(forwardedProto)) {
return forwardedProto;
}

final String proxyProto = firstHeaderValue(request.header("X-Forwarded-Proto"));
if (hasText(proxyProto)) {
return proxyProto;
}

return request.scheme();
}

private static String hostFrom(final HttpServerRequest request) {
final String forwardedHost = forwardedHeaderValue(request.header("Forwarded"), "host");
if (hasText(forwardedHost)) {
return forwardedHost;
}

final String proxyHost = firstHeaderValue(request.header("X-Forwarded-Host"));
if (hasText(proxyHost)) {
return proxyHost;
Comment on lines +31 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include X-Forwarded-Port in proxy origins

When a reverse proxy exposes the service on a non-default port and sends it separately as X-Forwarded-Port (for example, X-Forwarded-Proto: https, X-Forwarded-Host: api.example, and X-Forwarded-Port: 8443), this builds https://api.example instead of https://api.example:8443. The generated OpenAPI server then directs Swagger UI requests to port 443 rather than the externally reachable service port.

Useful? React with 👍 / 👎.

}

return request.host();
}

private static String forwardedHeaderValue(final String header, final String key) {
final String firstValue = firstHeaderValue(header);
if (!hasText(firstValue)) {
return "";
}

final String prefix = "%s=".formatted(key);
final String[] parts = firstValue.split(";");
for (final String part : parts) {
final String trimmed = part.trim();
if (trimmed.toLowerCase().startsWith(prefix)) {
return unquote(trimmed.substring(prefix.length()).trim());
}
}

return "";
}

private static String firstHeaderValue(final String header) {
if (!hasText(header)) {
return "";
}
return header.split(",", 2)[0].trim();
}

private static String unquote(final String value) {
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
return value.substring(1, value.length() - 1);
}
return value;
}

private static boolean hasText(final String value) {
return value != null && !value.trim().isEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public ThingifierAutoDocGenRouting(
response.type("application/json");
response.status(200);
return new Swaggerizer(apiDefn)
.asJsonWithPreferredServer(requestOrigin(request));
.asJsonWithPreferredServer(HttpRequestOrigin.from(request));
});

get(
Expand Down Expand Up @@ -99,11 +99,8 @@ public ThingifierAutoDocGenRouting(
// TODO: the swaggerizer could be stored at a class level and allow caching to
// be used for the output
return new Swaggerizer(apiDefn)
.asJsonWithPreferredServer(permissive != null, requestOrigin(request));
.asJsonWithPreferredServer(
permissive != null, HttpRequestOrigin.from(request));
});
}

private String requestOrigin(final HttpServerRequest request) {
return "%s://%s".formatted(request.scheme(), request.host());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package uk.co.compendiumdev.thingifier.adapter.httpserver;

import java.lang.reflect.Proxy;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

class HttpRequestOriginTest {

@Test
void usesDirectRequestOriginWhenProxyHeadersAreAbsent() {
final HttpServerRequest request = request("http", "localhost:4567");

Assertions.assertEquals("http://localhost:4567", HttpRequestOrigin.from(request));
}

@Test
void usesForwardedProtoAndHostWhenPresent() {
final HttpServerRequest request =
request(
"http",
"internal:4567",
"X-Forwarded-Proto",
"https",
"X-Forwarded-Host",
"apichallenges.eviltester.com");

Assertions.assertEquals(
"https://apichallenges.eviltester.com", HttpRequestOrigin.from(request));
}

@Test
void usesStandardForwardedHeaderWhenPresent() {
final HttpServerRequest request =
request(
"http",
"internal:4567",
"Forwarded",
"for=192.0.2.60;proto=https;host=\"apichallenges.eviltester.com\"");

Assertions.assertEquals(
"https://apichallenges.eviltester.com", HttpRequestOrigin.from(request));
}

private HttpServerRequest request(final String scheme, final String host) {
return request(scheme, host, "", "");
}

private HttpServerRequest request(
final String scheme,
final String host,
final String firstHeaderName,
final String firstHeaderValue) {
return request(scheme, host, firstHeaderName, firstHeaderValue, "", "");
}

private HttpServerRequest request(
final String scheme,
final String host,
final String firstHeaderName,
final String firstHeaderValue,
final String secondHeaderName,
final String secondHeaderValue) {
return (HttpServerRequest)
Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[] {HttpServerRequest.class},
(proxy, method, args) -> {
if ("scheme".equals(method.getName())) {
return scheme;
}
if ("host".equals(method.getName())) {
return host;
}
if ("header".equals(method.getName())) {
final String requestedHeader = (String) args[0];
if (requestedHeader.equals(firstHeaderName)) {
return firstHeaderValue;
}
if (requestedHeader.equals(secondHeaderName)) {
return secondHeaderValue;
}
return null;
}
throw new UnsupportedOperationException(method.getName());
});
}
}
Loading