Skip to content

Commit 4c9c625

Browse files
committed
Fix ACME SSRF
Fix #5407
1 parent 8405962 commit 4c9c625

2 files changed

Lines changed: 134 additions & 18 deletions

File tree

base/acme/src/main/java/org/dogtagpki/acme/server/ACMEIdentifierValidator.java

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ public static ValidationResult validateSyntax(ACMEIdentifier id) {
4949
* _policy_ is checked elsewhere.
5050
*/
5151
private static ValidationResult validateSyntaxDNS(String value) {
52+
53+
// dns identifiers must be hostnames, not IP literals (RFC 8555).
54+
// Accepting IPs enables SSRF via HTTP-01 (see dogtagpki/pki#5407).
55+
if (isIpLiteral(value)) {
56+
ACMEError error = new ACMEError();
57+
error.setType("urn:ietf:params:acme:error:malformed");
58+
error.setDetail("DNS identifier must not be an IP address: " + value);
59+
return ValidationResult.fail(error);
60+
}
61+
5262
String[] labels = value.split("\\.");
5363

5464
if (labels.length < 1) {
@@ -109,6 +119,113 @@ private static ValidationResult validateSyntaxDNS(String value) {
109119
return ValidationResult.ok();
110120
}
111121

122+
/**
123+
* True if value is an IPv4/IPv6 literal (optional [], optional leading "*.").
124+
* Wildcard is stripped because newOrder removes "*." before HTTP-01.
125+
* Parsing is lexical only (no InetAddress/DNS) and covers the decimal IPv4
126+
* forms Java accepts (d, d.d, d.d.d, d.d.d.d).
127+
*/
128+
static boolean isIpLiteral(String value) {
129+
String host = value.startsWith("*.") ? value.substring(2) : value;
130+
if (host.startsWith("[") && host.endsWith("]") && host.length() > 2) {
131+
host = host.substring(1, host.length() - 1);
132+
}
133+
if (host.indexOf(':') >= 0) {
134+
return isIpv6Literal(host);
135+
}
136+
return isIpv4Literal(host);
137+
}
138+
139+
/**
140+
* Match Java Inet4Address decimal IPv4 text forms (historical inet_aton style):
141+
* d (32-bit), d.d (8+24), d.d.d (8+8+16), or d.d.d.d (four bytes).
142+
* Digits only; leading zeros allowed. See java.net.Inet4Address.
143+
*/
144+
private static boolean isIpv4Literal(String host) {
145+
if (host.isEmpty()) {
146+
return false;
147+
}
148+
for (int i = 0; i < host.length(); i++) {
149+
char c = host.charAt(i);
150+
if (c != '.' && (c < '0' || c > '9')) {
151+
return false;
152+
}
153+
}
154+
String[] parts = host.split("\\.", -1);
155+
if (parts.length < 1 || parts.length > 4) {
156+
return false;
157+
}
158+
for (String part : parts) {
159+
if (part.isEmpty()) {
160+
return false;
161+
}
162+
}
163+
switch (parts.length) {
164+
case 1: // d -> 32-bit value (e.g. 2130706433)
165+
return inRange(parts[0], 0xFFFFFFFFL);
166+
case 2: // d.d -> 8-bit + 24-bit (e.g. 127.1)
167+
return inRange(parts[0], 255) && inRange(parts[1], 0xFFFFFFL);
168+
case 3: // d.d.d -> 8-bit + 8-bit + 16-bit (e.g. 127.0.1)
169+
return inRange(parts[0], 255) && inRange(parts[1], 255)
170+
&& inRange(parts[2], 0xFFFFL);
171+
case 4: // d.d.d.d -> four 8-bit octets (e.g. 127.0.0.1)
172+
return inRange(parts[0], 255) && inRange(parts[1], 255)
173+
&& inRange(parts[2], 255) && inRange(parts[3], 255);
174+
default:
175+
return false;
176+
}
177+
}
178+
179+
private static boolean inRange(String part, long max) {
180+
try {
181+
long value = Long.parseLong(part);
182+
return value >= 0 && value <= max;
183+
} catch (NumberFormatException e) {
184+
return false;
185+
}
186+
}
187+
188+
/**
189+
* Simplified lexical IPv6 text check (RFC 4291 / RFC 5952 style):
190+
* hex digits and ':', at most one "::" compression, optional dotted IPv4
191+
* tail for mapped forms (e.g. ::ffff:127.0.0.1). Not a full RFC parser.
192+
*/
193+
private static boolean isIpv6Literal(String host) {
194+
int colons = 0;
195+
int doubleColon = 0;
196+
for (int i = 0; i < host.length(); i++) {
197+
char c = host.charAt(i);
198+
if (c == ':') {
199+
colons++;
200+
// "::" may appear at most once
201+
if (i + 1 < host.length() && host.charAt(i + 1) == ':') {
202+
doubleColon++;
203+
i++;
204+
colons++;
205+
}
206+
} else if (!isHex(c) && c != '.') {
207+
// '.' only for IPv4-mapped / IPv4-compatible tails
208+
return false;
209+
}
210+
}
211+
// At least one ":" pair worth of colons; reject multiple "::"
212+
if (colons < 2 || doubleColon > 1) {
213+
return false;
214+
}
215+
// If an IPv4 tail is present after the last ':', validate it as IPv4
216+
int lastColon = host.lastIndexOf(':');
217+
if (lastColon >= 0 && host.indexOf('.', lastColon) >= 0) {
218+
return isIpv4Literal(host.substring(lastColon + 1));
219+
}
220+
return true;
221+
}
222+
223+
private static boolean isHex(char c) {
224+
return c >= '0' && c <= '9'
225+
|| c >= 'a' && c <= 'f'
226+
|| c >= 'A' && c <= 'F';
227+
}
228+
112229
/* helper predicates for "dns" identifier validity */
113230
private static boolean isLetter(char c) { return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z'; }
114231
private static boolean isDigit(char c) { return c >= '0' && c <= '9'; }

base/acme/src/main/java/org/dogtagpki/acme/validator/HTTP01Validator.java

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,15 @@ public ValidationResult validateChallenge(
9292

9393
if (response == null || !response.equals(keyAuthorization)) {
9494

95+
// Log the body for operators; never return it to the ACME client
96+
// (SSRF response disclosure — dogtagpki/pki#5407).
9597
logger.error("Invalid response: " + response);
9698

9799
ACMEError error = new ACMEError();
98100
error.setType("urn:ietf:params:acme:error:incorrectResponse");
99101
error.setDetail(
100-
"Unable to validate HTTP-01 challenge at " + validationURL + "\n" +
101-
"Incorrect response: " + response);
102+
"Unable to validate HTTP-01 challenge at " + validationURL + ": "
103+
+ "response did not match the expected key authorization");
102104

103105
return ValidationResult.fail(error);
104106
}
@@ -110,22 +112,19 @@ public String getResponse(URI validationURL) throws Exception {
110112

111113
logger.info("Retrieving " + validationURL);
112114

113-
CloseableHttpClient httpClient = HttpClients.createDefault();
114-
HttpGet httpGet = new HttpGet(validationURL);
115-
CloseableHttpResponse httpResponse = httpClient.execute(httpGet);
116-
117-
String response;
118-
try {
119-
HttpEntity entity = httpResponse.getEntity();
120-
response = IOUtils.toString(entity.getContent(), "UTF-8").trim();
121-
EntityUtils.consume(entity);
122-
123-
} finally {
124-
httpResponse.close();
115+
// Do not follow redirects: a controlled first hop can otherwise
116+
// redirect validation to internal HTTP targets (dogtagpki/pki#5407).
117+
try (CloseableHttpClient httpClient = HttpClients.custom()
118+
.disableRedirectHandling()
119+
.build()) {
120+
HttpGet httpGet = new HttpGet(validationURL);
121+
try (CloseableHttpResponse httpResponse = httpClient.execute(httpGet)) {
122+
HttpEntity entity = httpResponse.getEntity();
123+
String response = IOUtils.toString(entity.getContent(), "UTF-8").trim();
124+
EntityUtils.consume(entity);
125+
logger.info("Response: " + response);
126+
return response;
127+
}
125128
}
126-
127-
logger.info("Response: " + response);
128-
129-
return response;
130129
}
131130
}

0 commit comments

Comments
 (0)