-
Notifications
You must be signed in to change notification settings - Fork 44
Expand file tree
/
Copy pathAppUrlResolver.java
More file actions
152 lines (137 loc) · 7.37 KB
/
Copy pathAppUrlResolver.java
File metadata and controls
152 lines (137 loc) · 7.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
package com.digitalsanctuary.spring.user.util;
import java.util.List;
import java.util.Locale;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
/**
* Resolves the application base URL used to build security-sensitive email links (password reset, email verification), defending against Host-header /
* X-Forwarded-Host poisoning (CWE-640).
*
* <p>
* Resolution order:
* <ol>
* <li>If {@code user.security.appUrl} is configured, always use it (forwarded headers ignored).</li>
* <li>Otherwise build from the request, honoring {@code X-Forwarded-*} only when the resolved host is in {@code user.security.trustedHosts};
* otherwise use the container's own server name (the untrusted forwarded host is ignored and a warning is logged).</li>
* </ol>
*
* <p>
* The request-derived URL preserves the historical {@code UserUtils.getAppUrl} format with one refinement: the {@code :port} suffix is omitted when the
* port is the default for the scheme (80 for {@code http}, 443 for {@code https}), and the context path is appended only when non-empty. This keeps
* link formatting stable for existing deployments while producing clean canonical URLs.
*/
@Slf4j
public class AppUrlResolver {
private static final int DEFAULT_HTTP_PORT = 80;
private static final int DEFAULT_HTTPS_PORT = 443;
private final String configuredAppUrl;
private final List<String> trustedHosts;
/**
* Creates a resolver.
*
* @param configuredAppUrl the canonical base URL ({@code user.security.appUrl}); blank/null means "not configured"
* @param trustedHosts the allow-listed forwarded hosts ({@code user.security.trustedHosts}); null is treated as empty
*/
public AppUrlResolver(String configuredAppUrl, List<String> trustedHosts) {
String trimmed = (configuredAppUrl == null || configuredAppUrl.isBlank()) ? null : configuredAppUrl.trim();
// Strip any trailing slash to honour the "no trailing slash" contract on resolveAppUrl's return value.
// Without this, appUrl + "/user/..." produces double slashes when the consumer misconfigures a trailing slash.
this.configuredAppUrl = (trimmed != null && trimmed.endsWith("/")) ? trimmed.replaceAll("/+$", "") : trimmed;
// Hostnames are case-insensitive (RFC 4343); normalise the allow-list to lower case so a mixed-case
// configured or forwarded host (e.g. "App.Example.Com") still matches "app.example.com".
this.trustedHosts = trustedHosts == null ? List.of()
: trustedHosts.stream().map(s -> s.trim().toLowerCase(Locale.ROOT)).toList();
}
/**
* Resolves the application base URL for the given request.
*
* @param request the current HTTP request
* @return the resolved base URL (no trailing slash)
*/
public String resolveAppUrl(HttpServletRequest request) {
if (configuredAppUrl != null) {
return configuredAppUrl;
}
// X-Forwarded-Host may be a comma-separated list when the request traverses multiple proxies
// (RFC 7230); the client-facing host is the first value. Match the allow-list against that value
// only, otherwise legitimate multi-proxy chains (e.g. ALB + nginx) never match and silently fall
// back to the container's server name.
String fwdHost = firstHeaderValue(request.getHeader("X-Forwarded-Host"));
boolean useForwarded = fwdHost != null && !fwdHost.isEmpty() && trustedHosts.contains(stripPort(fwdHost).toLowerCase(Locale.ROOT));
if (fwdHost != null && !fwdHost.isEmpty() && !useForwarded) {
log.warn("AppUrlResolver: ignoring untrusted X-Forwarded-Host '{}' (not in user.security.trustedHosts)", sanitizeForLog(fwdHost));
}
String scheme = useForwarded ? forwardedScheme(request) : request.getScheme();
String host = useForwarded ? stripPort(fwdHost) : request.getServerName();
int port = useForwarded ? forwardedPort(request, scheme) : request.getServerPort();
StringBuilder url = new StringBuilder();
url.append(scheme).append("://").append(host);
if (!isDefaultPort(scheme, port)) {
url.append(':').append(port);
}
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty()) {
url.append(contextPath);
}
return url.toString();
}
private static int forwardedPort(HttpServletRequest request, String forwardedScheme) {
String portHeader = firstHeaderValue(request.getHeader("X-Forwarded-Port"));
if (portHeader != null && !portHeader.isBlank()) {
try {
return Integer.parseInt(portHeader.trim());
} catch (NumberFormatException e) {
log.warn("AppUrlResolver: ignoring non-numeric X-Forwarded-Port '{}'", sanitizeForLog(portHeader));
}
}
// No usable X-Forwarded-Port: derive the port from the forwarded scheme so we don't leak the
// container's internal port (e.g. 8080) into the email link. Default ports are omitted later.
return "https".equalsIgnoreCase(forwardedScheme) ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT;
}
/**
* Resolves the forwarded scheme from {@code X-Forwarded-Proto}, accepting only {@code http} or {@code https}. A trusted proxy is expected to send a
* sane value, but a misconfigured or compromised one sending e.g. {@code javascript} must never be allowed to flow into a security-sensitive email
* link, so any unrecognized value falls back to the container's own scheme.
*/
private static String forwardedScheme(HttpServletRequest request) {
String proto = firstHeaderValue(request.getHeader("X-Forwarded-Proto"));
if (proto == null || proto.isEmpty()) {
return request.getScheme();
}
if ("http".equalsIgnoreCase(proto) || "https".equalsIgnoreCase(proto)) {
return proto;
}
log.warn("AppUrlResolver: ignoring invalid X-Forwarded-Proto '{}', falling back to request scheme", sanitizeForLog(proto));
return request.getScheme();
}
/**
* Returns the first value of a possibly comma-separated forwarded header (RFC 7230), trimmed. Returns {@code null} for a null input.
*/
private static String firstHeaderValue(String headerValue) {
if (headerValue == null) {
return null;
}
int comma = headerValue.indexOf(',');
String first = comma >= 0 ? headerValue.substring(0, comma) : headerValue;
return first.trim();
}
/**
* Neutralizes CR/LF/tab in attacker-controlled header values before logging to prevent log-injection / forging.
*/
private static String sanitizeForLog(String value) {
return value == null ? null : value.replaceAll("[\r\n\t]", "_");
}
private static boolean isDefaultPort(String scheme, int port) {
return ("http".equalsIgnoreCase(scheme) && port == DEFAULT_HTTP_PORT)
|| ("https".equalsIgnoreCase(scheme) && port == DEFAULT_HTTPS_PORT);
}
private static String stripPort(String host) {
if (host.startsWith("[")) {
// IPv6 literal: [::1] or [::1]:8443
int bracket = host.indexOf(']');
return bracket > 0 ? host.substring(0, bracket + 1) : host;
}
int colon = host.lastIndexOf(':');
return colon > 0 ? host.substring(0, colon) : host;
}
}