Skip to content

Commit 735e0af

Browse files
committed
Stop negotiating a qop whose mutual authentication cannot be verified
Under auth-int the rspauth signs a response body that has not been read when the header is processed, so verification was skipped and mutual authentication was off for the exchange. A peer could select that by offering qop="auth-int" alone. Do not negotiate it. A server offering auth-int and nothing else can no longer be authenticated against; failing is better than authenticating unverifiably.
1 parent c954bd0 commit 735e0af

3 files changed

Lines changed: 48 additions & 21 deletions

File tree

client/src/main/java/org/asynchttpclient/Realm.java

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -495,19 +495,17 @@ public Builder setMaxIterationCount(int maxIterationCount) {
495495
serverSupportedQops[i] = rawServerSupportedQops[i].trim();
496496
}
497497

498-
// prefer auth over auth-int
499498
for (String rawServerSupportedQop : serverSupportedQops) {
500499
if ("auth".equals(rawServerSupportedQop)) {
501500
return rawServerSupportedQop;
502501
}
503502
}
504503

505-
for (String rawServerSupportedQop : serverSupportedQops) {
506-
if ("auth-int".equals(rawServerSupportedQop)) {
507-
return rawServerSupportedQop;
508-
}
509-
}
510-
504+
// auth-int is deliberately not selected. Its rspauth signs the response entity-body, which has
505+
// not been read when the Authentication-Info header is processed, so the value the server signed
506+
// cannot be derived there and mutual authentication is skipped for the whole exchange. A peer
507+
// that chooses the challenge could therefore switch mutual authentication off simply by offering
508+
// auth-int on its own. Declining to negotiate a mode we cannot verify keeps that decision ours.
511509
return null;
512510
}
513511

client/src/test/java/org/asynchttpclient/DigestMutualAuthTest.java

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -150,17 +150,21 @@ public void invalidRspAuthIsRejected() throws Exception {
150150
}
151151

152152
/**
153-
* RFC 7616 §3.5 defines {@code A2} for {@code qop=auth-int} as {@code ":" request-uri ":" H(entity-body)}
154-
* over the <em>response</em> entity-body. That body has not been received when the {@code
155-
* Authentication-Info} header is processed, so the expected value simply cannot be derived there. The
156-
* client must therefore fall back to warn-and-deliver for auth-int instead of enforcing the auth-only
157-
* formula, which would abort against a perfectly conformant server.
153+
* RFC 7616 Section 3.5 defines {@code A2} for {@code qop=auth-int} as {@code ":" request-uri ":"
154+
* H(entity-body)} over the <em>response</em> entity-body. That body has not arrived when the {@code
155+
* Authentication-Info} header is processed, so the expected rspauth cannot be derived there at all.
158156
* <p>
159-
* The server arm here computes rspauth <em>from the RFC</em> rather than by mirroring the client, which is
160-
* what makes this test able to catch the mismatch at all.
157+
* The client used to negotiate auth-int anyway and then report "cannot verify", which delivered the
158+
* response with mutual authentication silently skipped for the whole exchange. Since the peer chooses
159+
* the challenge, offering {@code qop="auth-int"} on its own was a one-word way to switch mutual
160+
* authentication off. The client now declines to negotiate a mode whose rspauth it cannot check.
161+
* <p>
162+
* Interoperability cost, deliberately accepted: a server offering auth-int and nothing else can no
163+
* longer be authenticated against. {@code auth,auth-int} is unaffected, because auth is preferred and
164+
* its rspauth is verified.
161165
*/
162166
@RepeatedIfExceptionsTest(repeats = 5)
163-
public void authIntRspAuthIsNotEnforcedAgainstAConformantServer() throws Exception {
167+
public void anAuthIntOnlyChallengeIsNotAnsweredWithAuthInt() throws Exception {
164168
restartServer(new AuthIntRspAuthHandler());
165169

166170
try (AsyncHttpClient client = asyncHttpClient()) {
@@ -169,12 +173,12 @@ public void authIntRspAuthIsNotEnforcedAgainstAConformantServer() throws Excepti
169173
.execute();
170174
Response resp = f.get(60, TimeUnit.SECONDS);
171175
assertNotNull(resp);
172-
assertEquals(HttpServletResponse.SC_OK, resp.getStatusCode());
173-
assertEquals(AuthIntRspAuthHandler.BODY, resp.getResponseBody());
174-
// The exchange really did negotiate auth-int; the client did not quietly fall back to auth.
175-
assertNotNull(resp.getHeader("X-Auth"));
176-
assertTrue(resp.getHeader("X-Auth").contains("qop=auth-int"),
177-
"expected the request to have been sent with qop=auth-int but got: " + resp.getHeader("X-Auth"));
176+
// The server only accepts qop=auth-int, so declining it means the exchange is not authenticated.
177+
assertEquals(HttpServletResponse.SC_UNAUTHORIZED, resp.getStatusCode(),
178+
"an auth-int-only challenge must not be answered, since its rspauth cannot be verified");
179+
String sent = resp.getHeader("X-Auth");
180+
assertTrue(sent == null || !sent.contains("qop=auth-int"),
181+
"the client must not negotiate auth-int: " + sent);
178182
}
179183
}
180184

client/src/test/java/org/asynchttpclient/RealmTest.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,31 @@ public void anUnescapedBackslashInARealmIsKept() {
322322
assertEquals("abc123", realm.getNonce());
323323
}
324324

325+
/**
326+
* A peer that offers only auth-int must not get auth-int negotiated. The rspauth of an auth-int
327+
* exchange signs the response entity-body, which has not been read when Authentication-Info is
328+
* processed, so the expected value cannot be derived and mutual authentication ends up skipped for the
329+
* whole exchange. Offering auth-int alone would therefore let the peer choosing the challenge switch
330+
* mutual authentication off. Declining to negotiate it keeps that decision on our side.
331+
*/
332+
@Test
333+
public void aQopOfAuthIntAloneIsNotNegotiated() {
334+
Realm realm = new Realm.Builder("user", "pass")
335+
.parseWWWAuthenticateHeader("Digest realm=\"protected\", nonce=\"N\", qop=\"auth-int\"")
336+
.build();
337+
338+
assertNull(realm.getQop(), "auth-int must not be negotiated: its rspauth cannot be verified");
339+
}
340+
341+
@Test
342+
public void authIsStillPreferredWhenBothAreOffered() {
343+
Realm realm = new Realm.Builder("user", "pass")
344+
.parseWWWAuthenticateHeader("Digest realm=\"protected\", nonce=\"N\", qop=\"auth,auth-int\"")
345+
.build();
346+
347+
assertEquals("auth", realm.getQop());
348+
}
349+
325350
private String getMd5(String what) throws Exception {
326351
MessageDigest md = MessageDigest.getInstance("MD5");
327352
md.update(what.getBytes(StandardCharsets.ISO_8859_1));

0 commit comments

Comments
 (0)