fix(parsing): decode \uXXXX escapes in challenge quoted-strings - #247
Open
kriss39 wants to merge 1 commit into
Open
fix(parsing): decode \uXXXX escapes in challenge quoted-strings#247kriss39 wants to merge 1 commit into
kriss39 wants to merge 1 commit into
Conversation
A header value cannot carry characters above Latin-1, so a challenge escapes
them as `\uXXXX` and the reference implementation decodes them back on parse.
pympp treated the backslash as a plain RFC 9110 quoted-pair, so it dropped it
and kept the rest verbatim: a description serialized as
description="Payment — 50% off ☕"
parsed as "Payment u2014 50% off u2615" instead of "Payment — 50% off ☕".
Decode the escape in `_unescape_quoted`, recombining the surrogate pairs that
astral characters are serialized as. An unpaired surrogate becomes U+FFFD,
since it has no Python representation that survives encoding. A doubled
backslash is still consumed first, so `\\u2014` stays the literal text.
Characters at or below Latin-1 are not escaped and already parsed correctly;
they are covered here to pin that down.
Author
|
Companion fixes for the same escape, so the three SDKs land on the reference behaviour together: tempoxyz/mpp-go#154 (Go), tempoxyz/mpp-rs#418 (Rust). |
This was referenced Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A header value cannot carry characters above Latin-1, so
mppxescapes them as\uXXXXwhen it serializes a challenge and decodes them again when it parses one. pympp treats the backslash as a plain RFC 9110 quoted-pair — drop the backslash, keep the next character — so the escape survives as literal text.Round-tripping a description through the reference implementation and then through pympp:
mpp-goandmpp-rsdecode it the same wrong way; companion PRs are open for both.descriptionis excluded from the challenge binding, so a corrupted one is cosmetic. The same escape is applied to every quoted parameter though, includingrealm, which is slot 0 of the challenge ID HMAC — a realm above Latin-1 would come back altered and the binding check would then fail.Fix
Decode the escape in
_unescape_quoted, matchingreadQuotedAuthParamValueinmppx:\uXXXXbecomes that code unit.😀→😀), so a high surrogate is combined with the escape that follows it.\\u2014stays the literal text—— the same reasoning the reference implementation documents for why a bare\uis unambiguous.Only the parse side changes. Characters at or below Latin-1 are not escaped by the serializer and already parsed correctly; they are covered in the tests to pin that down.
Tests
Eight cases in
tests/test_parsing.py: BMP escapes, an astral surrogate pair, raw Latin-1, a mixed value with a quote and a backslash, both unpaired surrogates, a doubled backslash, and a truncated escape. Five of them fail onmain.uv run pytest -m "not integration"passes (960 passed, 11 skipped),ruff check,ruff format --checkandpyrightare clean.The expectations were taken from
mppxrather than written by hand: each header in the tests is whatChallenge.serializeproduces, and each expected value is whatChallenge.deserializereturns for it.