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