From 383ef372ef8cba19f2badbee677eaf37f21c37e6 Mon Sep 17 00:00:00 2001 From: memosr Date: Mon, 17 Aug 2026 00:28:02 +0300 Subject: [PATCH] fix(mpp): match auth-param names case-insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseAuthParams keyed the params map on the raw auth-param name, so the RFC 9110 §11.2 rule its own doc comment cites was never applied. A fully valid mixed-case challenge was rejected with "missing required challenge fields", and a duplicate hidden by case (id="a", ID="b") slipped past the duplicate guard, silently yielding "a" while a normalizing peer reads "b". Lowercase the name token only. Values are left verbatim, so case-sensitive data such as challenge IDs and base64url payloads is preserved, and the unescaped-quote tolerance now keys off the normalized "description" name. --- .../case-insensitive-auth-param-names.md | 5 + pkg/mpp/parse.go | 8 +- pkg/mpp/parse_test.go | 119 ++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 .changelog/case-insensitive-auth-param-names.md diff --git a/.changelog/case-insensitive-auth-param-names.md b/.changelog/case-insensitive-auth-param-names.md new file mode 100644 index 0000000..2fed19b --- /dev/null +++ b/.changelog/case-insensitive-auth-param-names.md @@ -0,0 +1,5 @@ +--- +github.com/tempoxyz/mpp-go: patch +--- + +Match challenge auth-param names case-insensitively as RFC 9110 §11.2 requires, so mixed-case headers parse correctly and duplicate names that differ only in case are rejected instead of silently ignored. diff --git a/pkg/mpp/parse.go b/pkg/mpp/parse.go index 2141ef0..ff15b62 100644 --- a/pkg/mpp/parse.go +++ b/pkg/mpp/parse.go @@ -12,7 +12,9 @@ import ( const maxHeaderPayload = 16 * 1024 // parseAuthParams parses a comma-separated list of key=value or key="value" -// pairs from an auth-param list (RFC 9110 §11.2). +// pairs from an auth-param list (RFC 9110 §11.2). Parameter names are matched +// case-insensitively and returned lowercased, so callers look them up in lower +// case; each name may occur only once per challenge. Values are returned as-is. func parseAuthParams(s string) (map[string]string, error) { params := make(map[string]string) for i := 0; i < len(s); { @@ -31,6 +33,10 @@ func parseAuthParams(s string) (map[string]string, error) { if key == "" { return nil, fmt.Errorf("mpp: malformed auth-param") } + // RFC 9110 §11.2 matches the auth-param name token case-insensitively. + // Normalize the name only: values stay verbatim, since they carry + // case-sensitive data such as challenge IDs and base64url payloads. + key = strings.ToLower(key) for i < len(s) && (s[i] == ' ' || s[i] == '\t') { i++ diff --git a/pkg/mpp/parse_test.go b/pkg/mpp/parse_test.go index 9ed92ab..a69b0ec 100644 --- a/pkg/mpp/parse_test.go +++ b/pkg/mpp/parse_test.go @@ -425,6 +425,125 @@ func TestParseChallenge(t *testing.T) { } } +// TestParseChallengeAuthParamNamesAreCaseInsensitive pins RFC 9110 §11.2: +// "Authentication parameters are name/value pairs, where the name token is +// matched case-insensitively, and each parameter name MUST only occur once per +// challenge." Only the name is normalized; auth-param values keep their case. +func TestParseChallengeAuthParamNamesAreCaseInsensitive(t *testing.T) { + t.Parallel() + + opaqueB64 := b64EncodeSortedStringMap(map[string]string{"trace": "T-123"}) + + tests := []struct { + name string + header string + want *Challenge + wantErr string + }{ + { + name: "mixed-case required names parse successfully", + header: `Payment ID="ch_abc", Realm="api.example.com", METHOD="tempo", InTeNt="charge", REQUEST="e30"`, + want: &Challenge{ + ID: "ch_abc", + Realm: "api.example.com", + Method: "tempo", + Intent: "charge", + Request: map[string]any{}, + RequestB64: "e30", + }, + }, + { + name: "mixed-case optional names parse successfully", + header: `Payment id="ch_opt", realm="api.example.com", method="tempo", intent="charge", request="e30", ` + + `EXPIRES="2026-01-01T00:00:00.000Z", Digest="sha-256=:abc123:", ` + + `DESCRIPTION="Pay for API access", OPAQUE="` + opaqueB64 + `"`, + want: &Challenge{ + ID: "ch_opt", + Realm: "api.example.com", + Method: "tempo", + Intent: "charge", + Request: map[string]any{}, + RequestB64: "e30", + Expires: "2026-01-01T00:00:00.000Z", + Digest: "sha-256=:abc123:", + Description: "Pay for API access", + Opaque: map[string]string{"trace": "T-123"}, + }, + }, + { + name: "values keep their case", + header: `Payment ID="Ch_ABC123", Realm="API.Example.COM", method="tempo", InTeNt="Charge_XYZ", ` + + `REQUEST="e30", Description="Premium ACCESS"`, + want: &Challenge{ + ID: "Ch_ABC123", + Realm: "API.Example.COM", + Method: "tempo", + Intent: "Charge_XYZ", + Request: map[string]any{}, + RequestB64: "e30", + Description: "Premium ACCESS", + }, + }, + { + // Guards the fix against over-reaching: lowercasing the whole + // auth-param would turn "Tempo" into a valid method name. + name: "uppercase method value is still rejected", + header: `Payment ID="abc", Realm="api.example.com", METHOD="Tempo", InTeNt="charge", REQUEST="e30"`, + wantErr: `invalid challenge method`, + }, + { + name: "duplicate required name hidden by case is rejected", + header: `Payment id="a", ID="b", realm="api.example.com", method="tempo", intent="charge", request="e30"`, + wantErr: `duplicate auth-param`, + }, + { + name: "duplicate optional name hidden by case is rejected", + header: `Payment id="a", realm="api.example.com", method="tempo", intent="charge", request="e30", expires="1", EXPIRES="2"`, + wantErr: `duplicate auth-param`, + }, + { + name: "duplicate name in the same case is still rejected", + header: `Payment id="a", id="b", realm="api.example.com", method="tempo", intent="charge", request="e30"`, + wantErr: `duplicate auth-param`, + }, + { + // The unescaped-quote tolerance keyed on "description" must follow + // the normalized name, not the raw one. + name: "mixed-case description tolerates unescaped quotes", + header: `Payment id="ch_special", realm="api.example.com", method="tempo", intent="charge", ` + + `request="e30", DeScRiPtIoN="Payment for "Premium" service"`, + want: &Challenge{ + ID: "ch_special", + Realm: "api.example.com", + Method: "tempo", + Intent: "charge", + Request: map[string]any{}, + RequestB64: "e30", + Description: "Payment for ", + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := ParseChallenge(tt.header) + if tt.wantErr != "" { + require.Errorf(t, err, "ParseChallenge() error = nil, want substring %q", tt.wantErr) + assert.Containsf(t, err.Error(), tt.wantErr, + "ParseChallenge() error = %v, want substring %q", err, tt.wantErr) + + return + } + + require.NoErrorf(t, err, "ParseChallenge() unexpected error: %v", err) + assertChallengeEqual(t, got, tt.want) + }) + } +} + func TestParseCredential(t *testing.T) { t.Parallel()