Skip to content

Commit 4ab9b58

Browse files
committed
oauthex: parse WWW-Authenticate in linear time
splitChallenges looked ahead from every comma by trimming and scanning the rest of the header, so parsing took time quadratic in the header length. A header made of 100 KB of commas took about 5.7s to parse, and the work does not observe context cancellation. Compute the lookahead once per run of commas and blanks, and read only the next token to decide whether a comma starts an auth-param (startsWithAuthParam). The result is unchanged except for a comma inside the name before the '=', such as `x, a,b=c`: a token cannot contain a comma (RFC 9110, section 5.6.2), so that comma now ends the challenge. Add cases to TestSplitChallenges and TestParseWWWAuthenticateLinearTime, which checks that a 100 KB run of commas parses within a deadline. Fixes #1455
1 parent 2e00768 commit 4ab9b58

2 files changed

Lines changed: 79 additions & 12 deletions

File tree

‎oauthex/resource_meta.go‎

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,11 @@ func splitChallenges(header string) ([]string, error) {
180180
inQuotes := false
181181
escaped := false
182182
start := 0
183+
// Every comma in a run of commas and blanks sees the same text after the
184+
// run, so the lookahead is computed once per run. Rescanning per comma
185+
// makes a long header take quadratic time.
186+
runEnd := 0
187+
runIsParam := false
183188
for i, r := range header {
184189
if escaped {
185190
// The previous backslash began a quoted-pair, so this character is
@@ -198,20 +203,13 @@ func splitChallenges(header string) ([]string, error) {
198203
// This is a potential challenge separator.
199204
// A new challenge does not start with `key=value`.
200205
// We check if the part after the comma looks like a parameter.
201-
lookahead := trimEmptyListElements(header[i+1:])
202-
eqPos := strings.Index(lookahead, "=")
203-
204-
isParam := false
205-
if eqPos > 0 {
206-
// Check if the part before '=' is a single token (no spaces).
207-
// Whitespace between the token and '=' is allowed (BWS, RFC 9110 section 11.2).
208-
token := strings.TrimRight(lookahead[:eqPos], " \t")
209-
if strings.IndexFunc(token, unicode.IsSpace) == -1 {
210-
isParam = true
211-
}
206+
if i >= runEnd {
207+
lookahead := trimEmptyListElements(header[i+1:])
208+
runEnd = len(header) - len(lookahead)
209+
runIsParam = startsWithAuthParam(lookahead)
212210
}
213211

214-
if !isParam {
212+
if !runIsParam {
215213
// The part after the comma does not look like a parameter,
216214
// so this comma separates challenges.
217215
challenges = append(challenges, header[start:i])
@@ -224,6 +222,26 @@ func splitChallenges(header string) ([]string, error) {
224222
return challenges, nil
225223
}
226224

225+
// startsWithAuthParam reports whether s begins with a token followed by '='.
226+
// Whitespace between the token and '=' is allowed (BWS, RFC 9110 section 11.2).
227+
// It reads no further than that token, so its cost does not depend on the
228+
// length of the rest of the header.
229+
func startsWithAuthParam(s string) bool {
230+
for i, r := range s {
231+
switch {
232+
case r == '=':
233+
return i > 0
234+
case r == ',':
235+
return false
236+
case r == ' ' || r == '\t':
237+
return strings.HasPrefix(strings.TrimLeft(s[i:], " \t"), "=")
238+
case unicode.IsSpace(r):
239+
return false
240+
}
241+
}
242+
return false
243+
}
244+
227245
// parseSingleChallenge parses a string containing exactly one challenge.
228246
// challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ]
229247
func parseSingleChallenge(s string) (Challenge, error) {

‎oauthex/resource_meta_test.go‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import (
99
"net/http"
1010
"net/http/httptest"
1111
"reflect"
12+
"strings"
1213
"testing"
14+
"time"
1315
)
1416

1517
func TestSplitChallenges(t *testing.T) {
@@ -68,6 +70,31 @@ func TestSplitChallenges(t *testing.T) {
6870
input: "",
6971
want: []string{""},
7072
},
73+
{
74+
name: "challenges without params",
75+
input: `Basic,Bearer`,
76+
want: []string{`Basic`, `Bearer`},
77+
},
78+
{
79+
name: "blank between token and equals",
80+
input: "Bearer a=1, b \t=2",
81+
want: []string{"Bearer a=1, b \t=2"},
82+
},
83+
{
84+
name: "whitespace inside the name starts a new challenge",
85+
input: `Basic realm="a", Bearer x y=1`,
86+
want: []string{`Basic realm="a"`, ` Bearer x y=1`},
87+
},
88+
{
89+
name: "non-blank whitespace before equals starts a new challenge",
90+
input: "Basic, Bearer\u00a0=1",
91+
want: []string{"Basic", " Bearer\u00a0=1"},
92+
},
93+
{
94+
name: "comma inside the name starts a new challenge",
95+
input: `Bearer x, a,b=c`,
96+
want: []string{`Bearer x`, ` a,b=c`},
97+
},
7198
}
7299

73100
for _, tt := range tests {
@@ -83,6 +110,28 @@ func TestSplitChallenges(t *testing.T) {
83110
}
84111
}
85112

113+
// The lookahead after a comma must not rescan the rest of the header, or a
114+
// long header from a server takes quadratic time that no context deadline can
115+
// interrupt. The input is kept small: the test only needs the old code to take
116+
// several times longer than the deadline, and it must not load the machine,
117+
// because other packages' tests run in parallel with it.
118+
func TestParseWWWAuthenticateLinearTime(t *testing.T) {
119+
header := "Bearer " + strings.Repeat(",", 100_000)
120+
done := make(chan error, 1)
121+
go func() {
122+
_, err := ParseWWWAuthenticate([]string{header})
123+
done <- err
124+
}()
125+
select {
126+
case err := <-done:
127+
if err != nil {
128+
t.Fatal(err)
129+
}
130+
case <-time.After(2 * time.Second):
131+
t.Fatalf("ParseWWWAuthenticate did not finish within 2s on a %d byte header", len(header))
132+
}
133+
}
134+
86135
// A quoted string may end in an escaped backslash. The quote after it closes
87136
// the string, so the comma that follows still separates two challenges and
88137
// the Bearer challenge the auth handlers look for is not lost.

0 commit comments

Comments
 (0)