From 6eb4764557c727f3a292267ee8938198bc91a0b3 Mon Sep 17 00:00:00 2001 From: Jes Cok Date: Fri, 12 Apr 2024 01:52:37 +0800 Subject: [PATCH] net/http: add ParseCookie, ParseSetCookie Fixes #66008 Change-Id: I64acb7da47a03bdef955f394682004906245a18b Reviewed-on: https://go-review.googlesource.com/c/go/+/578275 Reviewed-by: Damien Neil Auto-Submit: Cherry Mui LUCI-TryBot-Result: Go LUCI Reviewed-by: Cherry Mui --- api/next/66008.txt | 2 + doc/next/6-stdlib/99-minor/net/http/66008.md | 7 + src/net/http/cookie.go | 218 +++++++++++-------- src/net/http/cookie_test.go | 203 +++++++++++++++++ 4 files changed, 342 insertions(+), 88 deletions(-) create mode 100644 api/next/66008.txt create mode 100644 doc/next/6-stdlib/99-minor/net/http/66008.md diff --git a/api/next/66008.txt b/api/next/66008.txt new file mode 100644 index 00000000000000..ea72f6406801bf --- /dev/null +++ b/api/next/66008.txt @@ -0,0 +1,2 @@ +pkg net/http, func ParseCookie(string) ([]*Cookie, error) #66008 +pkg net/http, func ParseSetCookie(string) (*Cookie, error) #66008 diff --git a/doc/next/6-stdlib/99-minor/net/http/66008.md b/doc/next/6-stdlib/99-minor/net/http/66008.md new file mode 100644 index 00000000000000..e8603707ef3150 --- /dev/null +++ b/doc/next/6-stdlib/99-minor/net/http/66008.md @@ -0,0 +1,7 @@ +The new [ParseCookie] function parses a Cookie header value and +returns all the cookies which were set in it. Since the same cookie +name can appear multiple times the returned Values can contain +more than one value for a given key. + +The new [ParseSetCookie] function parses a Set-Cookie header value and +returns a cookie. It returns an error on syntax error. diff --git a/src/net/http/cookie.go b/src/net/http/cookie.go index c22897f3f99e4d..ab84625ba0bdb9 100644 --- a/src/net/http/cookie.go +++ b/src/net/http/cookie.go @@ -55,110 +55,152 @@ const ( SameSiteNoneMode ) -// readSetCookies parses all "Set-Cookie" values from -// the header h and returns the successfully parsed Cookies. -func readSetCookies(h Header) []*Cookie { - cookieCount := len(h["Set-Cookie"]) - if cookieCount == 0 { - return []*Cookie{} - } - cookies := make([]*Cookie, 0, cookieCount) - for _, line := range h["Set-Cookie"] { - parts := strings.Split(textproto.TrimString(line), ";") - if len(parts) == 1 && parts[0] == "" { - continue +var ( + errBlankCookie = errors.New("http: blank cookie") + errEqualNotFoundInCookie = errors.New("http: '=' not found in cookie") + errInvalidCookieName = errors.New("http: invalid cookie name") + errInvalidCookieValue = errors.New("http: invalid cookie value") +) + +// ParseCookie parses a Cookie header value and returns all the cookies +// which were set in it. Since the same cookie name can appear multiple times +// the returned Values can contain more than one value for a given key. +func ParseCookie(line string) ([]*Cookie, error) { + parts := strings.Split(textproto.TrimString(line), ";") + if len(parts) == 1 && parts[0] == "" { + return nil, errBlankCookie + } + cookies := make([]*Cookie, 0, len(parts)) + for _, s := range parts { + s = textproto.TrimString(s) + name, value, found := strings.Cut(s, "=") + if !found { + return nil, errEqualNotFoundInCookie } - parts[0] = textproto.TrimString(parts[0]) - name, value, ok := strings.Cut(parts[0], "=") - if !ok { + if !isCookieNameValid(name) { + return nil, errInvalidCookieName + } + value, found = parseCookieValue(value, true) + if !found { + return nil, errInvalidCookieValue + } + cookies = append(cookies, &Cookie{Name: name, Value: value}) + } + return cookies, nil +} + +// ParseSetCookie parses a Set-Cookie header value and returns a cookie. +// It returns an error on syntax error. +func ParseSetCookie(line string) (*Cookie, error) { + parts := strings.Split(textproto.TrimString(line), ";") + if len(parts) == 1 && parts[0] == "" { + return nil, errBlankCookie + } + parts[0] = textproto.TrimString(parts[0]) + name, value, ok := strings.Cut(parts[0], "=") + if !ok { + return nil, errEqualNotFoundInCookie + } + name = textproto.TrimString(name) + if !isCookieNameValid(name) { + return nil, errInvalidCookieName + } + value, ok = parseCookieValue(value, true) + if !ok { + return nil, errInvalidCookieValue + } + c := &Cookie{ + Name: name, + Value: value, + Raw: line, + } + for i := 1; i < len(parts); i++ { + parts[i] = textproto.TrimString(parts[i]) + if len(parts[i]) == 0 { continue } - name = textproto.TrimString(name) - if !isCookieNameValid(name) { + + attr, val, _ := strings.Cut(parts[i], "=") + lowerAttr, isASCII := ascii.ToLower(attr) + if !isASCII { continue } - value, ok = parseCookieValue(value, true) + val, ok = parseCookieValue(val, false) if !ok { + c.Unparsed = append(c.Unparsed, parts[i]) continue } - c := &Cookie{ - Name: name, - Value: value, - Raw: line, - } - for i := 1; i < len(parts); i++ { - parts[i] = textproto.TrimString(parts[i]) - if len(parts[i]) == 0 { - continue - } - attr, val, _ := strings.Cut(parts[i], "=") - lowerAttr, isASCII := ascii.ToLower(attr) - if !isASCII { + switch lowerAttr { + case "samesite": + lowerVal, ascii := ascii.ToLower(val) + if !ascii { + c.SameSite = SameSiteDefaultMode continue } - val, ok = parseCookieValue(val, false) - if !ok { - c.Unparsed = append(c.Unparsed, parts[i]) - continue + switch lowerVal { + case "lax": + c.SameSite = SameSiteLaxMode + case "strict": + c.SameSite = SameSiteStrictMode + case "none": + c.SameSite = SameSiteNoneMode + default: + c.SameSite = SameSiteDefaultMode } - - switch lowerAttr { - case "samesite": - lowerVal, ascii := ascii.ToLower(val) - if !ascii { - c.SameSite = SameSiteDefaultMode - continue - } - switch lowerVal { - case "lax": - c.SameSite = SameSiteLaxMode - case "strict": - c.SameSite = SameSiteStrictMode - case "none": - c.SameSite = SameSiteNoneMode - default: - c.SameSite = SameSiteDefaultMode - } - continue - case "secure": - c.Secure = true - continue - case "httponly": - c.HttpOnly = true - continue - case "domain": - c.Domain = val - continue - case "max-age": - secs, err := strconv.Atoi(val) - if err != nil || secs != 0 && val[0] == '0' { - break - } - if secs <= 0 { - secs = -1 - } - c.MaxAge = secs - continue - case "expires": - c.RawExpires = val - exptime, err := time.Parse(time.RFC1123, val) + continue + case "secure": + c.Secure = true + continue + case "httponly": + c.HttpOnly = true + continue + case "domain": + c.Domain = val + continue + case "max-age": + secs, err := strconv.Atoi(val) + if err != nil || secs != 0 && val[0] == '0' { + break + } + if secs <= 0 { + secs = -1 + } + c.MaxAge = secs + continue + case "expires": + c.RawExpires = val + exptime, err := time.Parse(time.RFC1123, val) + if err != nil { + exptime, err = time.Parse("Mon, 02-Jan-2006 15:04:05 MST", val) if err != nil { - exptime, err = time.Parse("Mon, 02-Jan-2006 15:04:05 MST", val) - if err != nil { - c.Expires = time.Time{} - break - } + c.Expires = time.Time{} + break } - c.Expires = exptime.UTC() - continue - case "path": - c.Path = val - continue } - c.Unparsed = append(c.Unparsed, parts[i]) + c.Expires = exptime.UTC() + continue + case "path": + c.Path = val + continue + } + c.Unparsed = append(c.Unparsed, parts[i]) + } + return c, nil +} + +// readSetCookies parses all "Set-Cookie" values from +// the header h and returns the successfully parsed Cookies. +func readSetCookies(h Header) []*Cookie { + cookieCount := len(h["Set-Cookie"]) + if cookieCount == 0 { + return []*Cookie{} + } + cookies := make([]*Cookie, 0, cookieCount) + for _, line := range h["Set-Cookie"] { + if cookie, err := ParseSetCookie(line); err == nil { + cookies = append(cookies, cookie) } - cookies = append(cookies, c) } return cookies } diff --git a/src/net/http/cookie_test.go b/src/net/http/cookie_test.go index e5bd46a744fba5..5337c33aa90a28 100644 --- a/src/net/http/cookie_test.go +++ b/src/net/http/cookie_test.go @@ -6,6 +6,7 @@ package http import ( "encoding/json" + "errors" "fmt" "log" "os" @@ -650,3 +651,205 @@ func BenchmarkReadCookies(b *testing.B) { b.Fatalf("readCookies:\nhave: %s\nwant: %s\n", toJSON(c), toJSON(wantCookies)) } } + +func TestParseCookie(t *testing.T) { + tests := []struct { + line string + cookies []*Cookie + err error + }{ + { + line: "Cookie-1=v$1", + cookies: []*Cookie{{Name: "Cookie-1", Value: "v$1"}}, + }, + { + line: "Cookie-1=v$1;c2=v2", + cookies: []*Cookie{{Name: "Cookie-1", Value: "v$1"}, {Name: "c2", Value: "v2"}}, + }, + { + line: `Cookie-1="v$1";c2="v2"`, + cookies: []*Cookie{{Name: "Cookie-1", Value: "v$1"}, {Name: "c2", Value: "v2"}}, + }, + { + line: "k1=", + cookies: []*Cookie{{Name: "k1", Value: ""}}, + }, + { + line: "", + err: errBlankCookie, + }, + { + line: "whatever", + err: errEqualNotFoundInCookie, + }, + { + line: "=v1", + err: errInvalidCookieName, + }, + { + line: "k1=\\", + err: errInvalidCookieValue, + }, + } + for i, tt := range tests { + gotCookies, gotErr := ParseCookie(tt.line) + if !errors.Is(gotErr, tt.err) { + t.Errorf("#%d ParseCookie got error %v, want error %v", i, gotErr, tt.err) + } + if !reflect.DeepEqual(gotCookies, tt.cookies) { + t.Errorf("#%d ParseCookie:\ngot cookies: %s\nwant cookies: %s\n", i, toJSON(gotCookies), toJSON(tt.cookies)) + } + } +} + +func TestParseSetCookie(t *testing.T) { + tests := []struct { + line string + cookie *Cookie + err error + }{ + { + line: "Cookie-1=v$1", + cookie: &Cookie{Name: "Cookie-1", Value: "v$1", Raw: "Cookie-1=v$1"}, + }, + { + line: "NID=99=YsDT5i3E-CXax-; expires=Wed, 23-Nov-2011 01:05:03 GMT; path=/; domain=.google.ch; HttpOnly", + cookie: &Cookie{ + Name: "NID", + Value: "99=YsDT5i3E-CXax-", + Path: "/", + Domain: ".google.ch", + HttpOnly: true, + Expires: time.Date(2011, 11, 23, 1, 5, 3, 0, time.UTC), + RawExpires: "Wed, 23-Nov-2011 01:05:03 GMT", + Raw: "NID=99=YsDT5i3E-CXax-; expires=Wed, 23-Nov-2011 01:05:03 GMT; path=/; domain=.google.ch; HttpOnly", + }, + }, + { + line: ".ASPXAUTH=7E3AA; expires=Wed, 07-Mar-2012 14:25:06 GMT; path=/; HttpOnly", + cookie: &Cookie{ + Name: ".ASPXAUTH", + Value: "7E3AA", + Path: "/", + Expires: time.Date(2012, 3, 7, 14, 25, 6, 0, time.UTC), + RawExpires: "Wed, 07-Mar-2012 14:25:06 GMT", + HttpOnly: true, + Raw: ".ASPXAUTH=7E3AA; expires=Wed, 07-Mar-2012 14:25:06 GMT; path=/; HttpOnly", + }, + }, + { + line: "ASP.NET_SessionId=foo; path=/; HttpOnly", + cookie: &Cookie{ + Name: "ASP.NET_SessionId", + Value: "foo", + Path: "/", + HttpOnly: true, + Raw: "ASP.NET_SessionId=foo; path=/; HttpOnly", + }, + }, + { + line: "samesitedefault=foo; SameSite", + cookie: &Cookie{ + Name: "samesitedefault", + Value: "foo", + SameSite: SameSiteDefaultMode, + Raw: "samesitedefault=foo; SameSite", + }, + }, + { + line: "samesiteinvalidisdefault=foo; SameSite=invalid", + cookie: &Cookie{ + Name: "samesiteinvalidisdefault", + Value: "foo", + SameSite: SameSiteDefaultMode, + Raw: "samesiteinvalidisdefault=foo; SameSite=invalid", + }, + }, + { + line: "samesitelax=foo; SameSite=Lax", + cookie: &Cookie{ + Name: "samesitelax", + Value: "foo", + SameSite: SameSiteLaxMode, + Raw: "samesitelax=foo; SameSite=Lax", + }, + }, + { + line: "samesitestrict=foo; SameSite=Strict", + cookie: &Cookie{ + Name: "samesitestrict", + Value: "foo", + SameSite: SameSiteStrictMode, + Raw: "samesitestrict=foo; SameSite=Strict", + }, + }, + { + line: "samesitenone=foo; SameSite=None", + cookie: &Cookie{ + Name: "samesitenone", + Value: "foo", + SameSite: SameSiteNoneMode, + Raw: "samesitenone=foo; SameSite=None", + }, + }, + // Make sure we can properly read back the Set-Cookie headers we create + // for values containing spaces or commas: + { + line: `special-1=a z`, + cookie: &Cookie{Name: "special-1", Value: "a z", Raw: `special-1=a z`}, + }, + { + line: `special-2=" z"`, + cookie: &Cookie{Name: "special-2", Value: " z", Raw: `special-2=" z"`}, + }, + { + line: `special-3="a "`, + cookie: &Cookie{Name: "special-3", Value: "a ", Raw: `special-3="a "`}, + }, + { + line: `special-4=" "`, + cookie: &Cookie{Name: "special-4", Value: " ", Raw: `special-4=" "`}, + }, + { + line: `special-5=a,z`, + cookie: &Cookie{Name: "special-5", Value: "a,z", Raw: `special-5=a,z`}, + }, + { + line: `special-6=",z"`, + cookie: &Cookie{Name: "special-6", Value: ",z", Raw: `special-6=",z"`}, + }, + { + line: `special-7=a,`, + cookie: &Cookie{Name: "special-7", Value: "a,", Raw: `special-7=a,`}, + }, + { + line: `special-8=","`, + cookie: &Cookie{Name: "special-8", Value: ",", Raw: `special-8=","`}, + }, + { + line: "", + err: errBlankCookie, + }, + { + line: "whatever", + err: errEqualNotFoundInCookie, + }, + { + line: "=v1", + err: errInvalidCookieName, + }, + { + line: "k1=\\", + err: errInvalidCookieValue, + }, + } + for i, tt := range tests { + gotCookie, gotErr := ParseSetCookie(tt.line) + if !errors.Is(gotErr, tt.err) { + t.Errorf("#%d ParseCookie got error %v, want error %v", i, gotErr, tt.err) + } + if !reflect.DeepEqual(gotCookie, tt.cookie) { + t.Errorf("#%d ParseCookie:\ngot cookie: %s\nwant cookie: %s\n", i, toJSON(gotCookie), toJSON(tt.cookie)) + } + } +}