From 5245ed7dbb77d0405ab642f626ca195e414cb51b Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 17 Jun 2026 05:02:10 +0300 Subject: [PATCH] fix: reject stray trailing token after a valid auth-param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthChallengeParser accepted a malformed challenge of the shape `Scheme key=token ` — a bare token directly following an otherwise valid auth-param with no separating comma (e.g. `Bearer realm="x" garbage`). The continuation loop only kept the param list open while the next non-whitespace char was a comma, so it broke on the stray token and left the cursor parked on it. The outer parse loop then re-read that token as the scheme of a phantom second challenge, so `Digest realm=value extra` parsed into two challenges instead of being treated as a single malformed challenge. RFC 7235 §2.1 permits only a comma (another auth-param or the next challenge) or end-of-input after an auth-param; a bare token there has no production. The parser now skips the stray tail to the next top-level comma via recoverToNextChallenge and emits the challenge with the params parsed before the garbage, consistent with the parser's existing lenient "preserve prior params" recovery on malformed continuations. This stops the stray token from being silently dropped or misread as a phantom challenge scheme. Adds regression tests for the unquoted and quoted forms (`Digest realm=value extra`, `Digest realm="value" extra`) and for a stray token sitting between a valid param and a comma-separated next challenge. Closes #111 --- .../sdk/core/http/auth/AuthChallengeParser.kt | 15 ++++++- .../core/http/auth/AuthChallengeParserTest.kt | 43 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthChallengeParser.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthChallengeParser.kt index 572891cd..c031c24b 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthChallengeParser.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthChallengeParser.kt @@ -97,7 +97,20 @@ public object AuthChallengeParser { // param (likely the next challenge's scheme). while (true) { cursor.skipOws() - if (!cursor.hasMore() || cursor.peek() != ',') break + if (!cursor.hasMore()) break + if (cursor.peek() != ',') { + // After a valid auth-param the grammar only permits a comma + // (another param or the next challenge) or end-of-input. Anything + // else is a stray trailing token with no separating comma — e.g. + // `Bearer realm="x" garbage`. RFC 7235 §2.1 has no production for + // it, so the tail is malformed. Skip it to the next top-level + // comma so it is not silently misread as a phantom second + // challenge's scheme on the next outer iteration, then emit this + // challenge with the params parsed before the garbage — matching + // the parser's lenient "preserve prior params" recovery contract. + cursor.recoverToNextChallenge() + break + } // Save position before consuming the comma — if what follows is the // next scheme rather than a param of THIS challenge, we need to leave // the comma in place for the outer loop. diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthChallengeParserTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthChallengeParserTest.kt index 3d671650..d6310de9 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthChallengeParserTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthChallengeParserTest.kt @@ -412,6 +412,49 @@ class AuthChallengeParserTest { ) } + @Test + fun `stray token after an unquoted param is rejected not parsed as a phantom challenge`() { + // `Digest realm=value extra` — `realm=value` is a valid auth-param, but `extra` + // is a bare token with no separating comma. RFC 7235 §2.1 permits only a comma + // (or EOF) after an auth-param, so the trailing token is malformed. The parser + // skips it (rather than silently dropping it and re-reading `extra` as the scheme + // of a second challenge) and emits the single Digest challenge with `realm=value`. + val challenges = AuthChallengeParser.parse("Digest realm=value extra") + assertEquals(1, challenges.size, "the stray token must not produce a second challenge") + assertEquals("digest", challenges[0].scheme) + assertEquals("value", challenges[0].parameters["realm"]) + assertTrue( + challenges.none { it.scheme == "extra" }, + "the stray trailing token must not be parsed as a phantom challenge scheme", + ) + } + + @Test + fun `stray token after a quoted param is rejected not parsed as a phantom challenge`() { + // Quoted-value variant of the stray-trailing-token case: `Digest realm="value" extra`. + val challenges = AuthChallengeParser.parse("""Digest realm="value" extra""") + assertEquals(1, challenges.size, "the stray token must not produce a second challenge") + assertEquals("digest", challenges[0].scheme) + assertEquals("value", challenges[0].parameters["realm"]) + assertTrue( + challenges.none { it.scheme == "extra" }, + "the stray trailing token must not be parsed as a phantom challenge scheme", + ) + } + + @Test + fun `stray token between a valid param and a comma-separated next challenge is dropped`() { + // `Digest realm=value extra, Basic realm="x"` — the stray `extra` after the + // first challenge's param is skipped up to the next top-level comma, and the + // following Basic challenge is still picked up cleanly. + val challenges = AuthChallengeParser.parse("""Digest realm=value extra, Basic realm="x"""") + assertEquals(2, challenges.size) + assertEquals("digest", challenges[0].scheme) + assertEquals("value", challenges[0].parameters["realm"]) + assertEquals("basic", challenges[1].scheme) + assertEquals("x", challenges[1].parameters["realm"]) + } + @Test fun `quote with only a backslash inside before close quote`() { // `"\"` — opens, sees backslash, advances, sees `"` (the close), appends