Skip to content

Commit d15448b

Browse files
committed
fix(ssrf): block unparseable host notations and add bypass tests
- Reject URLs where URI.getHost() is null but URI.getAuthority() is non-null: this covers octal (0251.0376.0251.0376) and Unicode digit hosts that java.net.URI cannot parse into a standard hostname. Without this guard, octal-encoded private IPs pass validation entirely. - Use authority != null instead of StringUtils.hasText() — getAuthority() is never an empty string, so the plain null-check is exact and clearer. - Add BypassAttempts test class covering: decimal-encoded IPs (resolved correctly by InetAddress), octal and Unicode (caught by the new guard), and uppercase scheme normalisation (SBA's own toLowerCase logic). Tests that only exercise JDK URI parsing or Spring Boot's InetAddressFilter internals were not added — those are tested by their respective owners.
1 parent a5b270f commit d15448b

2 files changed

Lines changed: 64 additions & 1 deletion

File tree

spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/utils/SsrfUrlValidator.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,17 @@ public void validate(@Nullable String url) {
9191
}
9292

9393
checkScheme(url, uri.getScheme());
94-
checkAddress(url, uri.getHost());
94+
95+
String host = uri.getHost();
96+
// If the URI has an authority component but getHost() returned null, the host
97+
// literal could not be parsed as a standard hostname or IP by java.net.URI
98+
// (e.g. octal notation "0251.0376.0251.0376", Unicode digits). Reject it —
99+
// we cannot safely validate what we cannot parse.
100+
if (host == null && uri.getAuthority() != null) {
101+
throw new SsrfProtectionException("URL '" + url
102+
+ "' contains a host that cannot be parsed and validated. Rejecting to prevent bypass.");
103+
}
104+
checkAddress(url, host);
95105
}
96106

97107
private void checkScheme(String url, @Nullable String scheme) {

spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/utils/SsrfUrlValidatorTest.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,59 @@ void allows_publicUrls(String url) {
8383
assertThatCode(() -> validator.validate(url)).doesNotThrowAnyException();
8484
}
8585

86+
// -------------------------------------------------------------------------
87+
// URL encoding / notation bypass attempts
88+
// -------------------------------------------------------------------------
89+
90+
@Nested
91+
class BypassAttempts {
92+
93+
@Test
94+
void blocks_decimalEncodedPrivateIp() {
95+
// 169.254.169.254 expressed as a single 32-bit decimal integer.
96+
// java.net.URI parses "2852039166" as a hostname; InetAddress resolves
97+
// it to the link-local metadata address. Verifies SBA relies correctly
98+
// on InetAddress resolution rather than string matching.
99+
assertThatThrownBy(() -> validator.validate("http://2852039166/latest/meta-data"))
100+
.isInstanceOf(SsrfProtectionException.class);
101+
}
102+
103+
@Test
104+
void blocks_decimalEncodedLoopback() {
105+
// 127.0.0.1 as a 32-bit decimal integer (2130706433).
106+
assertThatThrownBy(() -> validator.validate("http://2130706433/"))
107+
.isInstanceOf(SsrfProtectionException.class);
108+
}
109+
110+
@Test
111+
void rejects_octalEncodedPrivateIp() {
112+
// 0251.0376.0251.0376 is octal for 169.254.169.254.
113+
// java.net.URI cannot parse octal notation and returns host=null with a
114+
// non-null authority — rejected to prevent bypass.
115+
assertThatThrownBy(() -> validator.validate("http://0251.0376.0251.0376/"))
116+
.isInstanceOf(SsrfProtectionException.class)
117+
.hasMessageContaining("cannot be parsed and validated");
118+
}
119+
120+
@Test
121+
void rejects_unicodeDigitHost() {
122+
// Unicode digit characters that look like "127.0.0.1" — URI returns
123+
// host=null with a non-null authority, so we reject rather than skip.
124+
assertThatThrownBy(() -> validator.validate("http://①②⑦.0.0.1/"))
125+
.isInstanceOf(SsrfProtectionException.class)
126+
.hasMessageContaining("cannot be parsed and validated");
127+
}
128+
129+
@Test
130+
void blocks_schemeCaseVariant_upper() {
131+
// HTTP:// (uppercase) must still be normalised and blocked when pointing
132+
// to a private address — SBA's checkScheme() uses scheme.toLowerCase().
133+
assertThatThrownBy(() -> validator.validate("HTTP://127.0.0.1/"))
134+
.isInstanceOf(SsrfProtectionException.class);
135+
}
136+
137+
}
138+
86139
// -------------------------------------------------------------------------
87140
// Scheme checks
88141
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)