// Copyright 2012 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. // Package cookiejar implements an in-memory RFC 6265-compliant http.CookieJar. package cookiejar import ( "cmp" "errors" "fmt" "net" "net/http" "net/http/internal/ascii" "net/url" "slices" "strings" "sync" "time" ) // PublicSuffixList provides the public suffix of a domain. For example: // - the public suffix of "example.com" is "com", // - the public suffix of "foo1.foo2.foo3.co.uk" is "co.uk", and // - the public suffix of "bar.pvt.k12.ma.us" is "pvt.k12.ma.us". // // Implementations of PublicSuffixList must be safe for concurrent use by // multiple goroutines. // // An implementation that always returns "" is valid and may be useful for // testing but it is not secure: it means that the HTTP server for foo.com can // set a cookie for bar.com. // // A public suffix list implementation is in the package // golang.org/x/net/publicsuffix. type PublicSuffixList interface { // PublicSuffix returns the public suffix of domain. // // TODO: specify which of the caller and callee is responsible for IP // addresses, for leading and trailing dots, for case sensitivity, and // for IDN/Punycode. PublicSuffix(domain string) string // String returns a description of the source of this public suffix // list. The description will typically contain something like a time // stamp or version number. String() string } // Options are the options for creating a new Jar. type Options struct { // PublicSuffixList is the public suffix list that determines whether // an HTTP server can set a cookie for a domain. // // A nil value is valid and may be useful for testing but it is not // secure: it means that the HTTP server for foo.co.uk can set a cookie // for bar.co.uk. PublicSuffixList PublicSuffixList } // Jar implements the http.CookieJar interface from the net/http package. type Jar struct { psList PublicSuffixList // mu locks the remaining fields. mu sync.Mutex // entries is a set of entries, keyed by their eTLD+1 and subkeyed by // their name/domain/path. entries map[string]map[string]entry // nextSeqNum is the next sequence number assigned to a new cookie // created SetCookies. nextSeqNum uint64 } // New returns a new cookie jar. A nil [*Options] is equivalent to a zero // Options. func New(o *Options) (*Jar, error) { jar := &Jar{ entries: make(map[string]map[string]entry), } if o != nil { jar.psList = o.PublicSuffixList } return jar, nil } // entry is the internal representation of a cookie. // // This struct type is not used outside of this package per se, but the exported // fields are those of RFC 6265. type entry struct { Name string Value string Quoted bool Domain string Path string SameSite string Secure bool HttpOnly bool Persistent bool HostOnly bool Expires time.Time Creation time.Time LastAccess time.Time // seqNum is a sequence number so that Cookies returns cookies in a // deterministic order, even for cookies that have equal Path length and // equal Creation time. This simplifies testing. seqNum uint64 } // id returns the domain;path;name triple of e as an id. func (e *entry) id() string { return fmt.Sprintf("%s;%s;%s", e.Domain, e.Path, e.Name) } // shouldSend determines whether e's cookie qualifies to be included in a // request to host/path. It is the caller's responsibility to check if the // cookie is expired. func (e *entry) shouldSend(https bool, host, path string) bool { return e.domainMatch(host) && e.pathMatch(path) && (https || !e.Secure) } // domainMatch checks whether e's Domain allows sending e back to host. // It differs from "domain-match" of RFC 6265 section 5.1.3 because we treat // a cookie with an IP address in the Domain always as a host cookie. func (e *entry) domainMatch(host string) bool { if e.Domain == host { return true } return !e.HostOnly && hasDotSuffix(host, e.Domain) } // pathMatch implements "path-match" according to RFC 6265 section 5.1.4. func (e *entry) pathMatch(requestPath string) bool { if requestPath == e.Path { return true } if strings.HasPrefix(requestPath, e.Path) { if e.Path[len(e.Path)-1] == '/' { return true // The "/any/" matches "/any/path" case. } else if requestPath[len(e.Path)] == '/' { return true // The "/any" matches "/any/path" case. } } return false } // hasDotSuffix reports whether s ends in "."+suffix. func hasDotSuffix(s, suffix string) bool { return len(s) > len(suffix) && s[len(s)-len(suffix)-1] == '.' && s[len(s)-len(suffix):] == suffix } // Cookies implements the Cookies method of the [http.CookieJar] interface. // // It returns an empty slice if the URL's scheme is not HTTP or HTTPS. func (j *Jar) Cookies(u *url.URL) (cookies []*http.Cookie) { return j.cookies(u, time.Now()) } // cookies is like Cookies but takes the current time as a parameter. func (j *Jar) cookies(u *url.URL, now time.Time) (cookies []*http.Cookie) { if u.Scheme != "http" && u.Scheme != "https" { return cookies } host, err := canonicalHost(u.Host) if err != nil { return cookies } key := jarKey(host, j.psList) j.mu.Lock() defer j.mu.Unlock() submap := j.entries[key] if submap == nil { return cookies } https := u.Scheme == "https" path := u.Path if path == "" { path = "/" } modified := false var selected []entry for id, e := range submap { if e.Persistent && !e.Expires.After(now) { delete(submap, id) modified = true continue } if !e.shouldSend(https, host, path) { continue } e.LastAccess = now submap[id] = e selected = append(selected, e) modified = true } if modified { if len(submap) == 0 { delete(j.entries, key) } else { j.entries[key] = submap } } // sort according to RFC 6265 section 5.4 point 2: by longest // path and then by earliest creation time. slices.SortFunc(selected, func(a, b entry) int { if r := cmp.Compare(b.Path, a.Path); r != 0 { return r } if r := a.Creation.Compare(b.Creation); r != 0 { return r } return cmp.Compare(a.seqNum, b.seqNum) }) for _, e := range selected { cookies = append(cookies, &http.Cookie{Name: e.Name, Value: e.Value, Quoted: e.Quoted}) } return cookies } // SetCookies implements the SetCookies method of the [http.CookieJar] interface. // // It does nothing if the URL's scheme is not HTTP or HTTPS. func (j *Jar) SetCookies(u *url.URL, cookies []*http.Cookie) { j.setCookies(u, cookies, time.Now()) } // setCookies is like SetCookies but takes the current time as parameter. func (j *Jar) setCookies(u *url.URL, cookies []*http.Cookie, now time.Time) { if len(cookies) == 0 { return } if u.Scheme != "http" && u.Scheme != "https" { return } host, err := canonicalHost(u.Host) if err != nil { return } key := jarKey(host, j.psList) defPath := defaultPath(u.Path) j.mu.Lock() defer j.mu.Unlock() submap := j.entries[key] modified := false for _, cookie := range cookies { e, remove, err := j.newEntry(cookie, now, defPath, host) if err != nil { continue } id := e.id() if remove { if submap != nil { if _, ok := submap[id]; ok { delete(submap, id) modified = true } } continue } if submap == nil { submap = make(map[string]entry) } if old, ok := submap[id]; ok { e.Creation = old.Creation e.seqNum = old.seqNum } else { e.Creation = now e.seqNum = j.nextSeqNum j.nextSeqNum++ } e.LastAccess = now submap[id] = e modified = true } if modified { if len(submap) == 0 { delete(j.entries, key) } else { j.entries[key] = submap } } } // canonicalHost strips port from host if present and returns the canonicalized // host name. func canonicalHost(host string) (string, error) { var err error if hasPort(host) { host, _, err = net.SplitHostPort(host) if err != nil { return "", err } } // Strip trailing dot from fully qualified domain names. host = strings.TrimSuffix(host, ".") encoded, err := toASCII(host) if err != nil { return "", err } // We know this is ascii, no need to check. lower, _ := ascii.ToLower(encoded) return lower, nil } // hasPort reports whether host contains a port number. host may be a host // name, an IPv4 or an IPv6 address. func hasPort(host string) bool { colons := strings.Count(host, ":") if colons == 0 { return false } if colons == 1 { return true } return host[0] == '[' && strings.Contains(host, "]:") } // jarKey returns the key to use for a jar. func jarKey(host string, psl PublicSuffixList) string { if isIP(host) { return host } var i int if psl == nil { i = strings.LastIndex(host, ".") if i <= 0 { return host } } else { suffix := psl.PublicSuffix(host) if suffix == host { return host } i = len(host) - len(suffix) if i <= 0 || host[i-1] != '.' { // The provided public suffix list psl is broken. // Storing cookies under host is a safe stopgap. return host } // Only len(suffix) is used to determine the jar key from // here on, so it is okay if psl.PublicSuffix("www.buggy.psl") // returns "com" as the jar key is generated from host. } prevDot := strings.LastIndex(host[:i-1], ".") return host[prevDot+1:] } // isIP reports whether host is an IP address. func isIP(host string) bool { if strings.ContainsAny(host, ":%") { // Probable IPv6 address. // Hostnames can't contain : or %, so this is definitely not a valid host. // Treating it as an IP is the more conservative option, and avoids the risk // of interpreting ::1%.www.example.com as a subdomain of www.example.com. return true } return net.ParseIP(host) != nil } // defaultPath returns the directory part of a URL's path according to // RFC 6265 section 5.1.4. func defaultPath(path string) string { if len(path) == 0 || path[0] != '/' { return "/" // Path is empty or malformed. } i := strings.LastIndex(path, "/") // Path starts with "/", so i != -1. if i == 0 { return "/" // Path has the form "/abc". } return path[:i] // Path is either of form "/abc/xyz" or "/abc/xyz/". } // newEntry creates an entry from an http.Cookie c. now is the current time and // is compared to c.Expires to determine deletion of c. defPath and host are the // default-path and the canonical host name of the URL c was received from. // // remove records whether the jar should delete this cookie, as it has already // expired with respect to now. In this case, e may be incomplete, but it will // be valid to call e.id (which depends on e's Name, Domain and Path). // // A malformed c.Domain will result in an error. func (j *Jar) newEntry(c *http.Cookie, now time.Time, defPath, host string) (e entry, remove bool, err error) { e.Name = c.Name if c.Path == "" || c.Path[0] != '/' { e.Path = defPath } else { e.Path = c.Path } e.Domain, e.HostOnly, err = j.domainAndType(host, c.Domain) if err != nil { return e, false, err } // MaxAge takes precedence over Expires. if c.MaxAge < 0 { return e, true, nil } else if c.MaxAge > 0 { e.Expires = now.Add(time.Duration(c.MaxAge) * time.Second) e.Persistent = true } else { if c.Expires.IsZero() { e.Expires = endOfTime e.Persistent = false } else { if !c.Expires.After(now) { return e, true, nil } e.Expires = c.Expires e.Persistent = true } } e.Value = c.Value e.Quoted = c.Quoted e.Secure = c.Secure e.HttpOnly = c.HttpOnly switch c.SameSite { case http.SameSiteDefaultMode: e.SameSite = "SameSite" case http.SameSiteStrictMode: e.SameSite = "SameSite=Strict" case http.SameSiteLaxMode: e.SameSite = "SameSite=Lax" } return e, false, nil } var ( errIllegalDomain = errors.New("cookiejar: illegal cookie domain attribute") errMalformedDomain = errors.New("cookiejar: malformed cookie domain attribute") ) // endOfTime is the time when session (non-persistent) cookies expire. // This instant is representable in most date/time formats (not just // Go's time.Time) and should be far enough in the future. var endOfTime = time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC) // domainAndType determines the cookie's domain and hostOnly attribute. func (j *Jar) domainAndType(host, domain string) (string, bool, error) { if domain == "" { // No domain attribute in the SetCookie header indicates a // host cookie. return host, true, nil } if isIP(host) { // RFC 6265 is not super clear here, a sensible interpretation // is that cookies with an IP address in the domain-attribute // are allowed. // RFC 6265 section 5.2.3 mandates to strip an optional leading // dot in the domain-attribute before processing the cookie. // // Most browsers don't do that for IP addresses, only curl // (version 7.54) and IE (version 11) do not reject a // Set-Cookie: a=1; domain=.127.0.0.1 // This leading dot is optional and serves only as hint for // humans to indicate that a cookie with "domain=.bbc.co.uk" // would be sent to every subdomain of bbc.co.uk. // It just doesn't make sense on IP addresses. // The other processing and validation steps in RFC 6265 just // collapse to: if host != domain { return "", false, errIllegalDomain } // According to RFC 6265 such cookies should be treated as // domain cookies. // As there are no subdomains of an IP address the treatment // according to RFC 6265 would be exactly the same as that of // a host-only cookie. Contemporary browsers (and curl) do // allows such cookies but treat them as host-only cookies. // So do we as it just doesn't make sense to label them as // domain cookies when there is no domain; the whole notion of // domain cookies requires a domain name to be well defined. return host, true, nil } // From here on: If the cookie is valid, it is a domain cookie (with // the one exception of a public suffix below). // See RFC 6265 section 5.2.3. if domain[0] == '.' { domain = domain[1:] } if len(domain) == 0 || domain[0] == '.' { // Received either "Domain=." or "Domain=..some.thing", // both are illegal. return "", false, errMalformedDomain } domain, isASCII := ascii.ToLower(domain) if !isASCII { // Received non-ASCII domain, e.g. "perché.com" instead of "xn--perch-fsa.com" return "", false, errMalformedDomain } if domain[len(domain)-1] == '.' { // We received stuff like "Domain=www.example.com.". // Browsers do handle such stuff (actually differently) but // RFC 6265 seems to be clear here (e.g. section 4.1.2.3) in // requiring a reject. 4.1.2.3 is not normative, but // "Domain Matching" (5.1.3) and "Canonicalized Host Names" // (5.1.2) are. return "", false, errMalformedDomain } // See RFC 6265 section 5.3 #5. if j.psList != nil { if ps := j.psList.PublicSuffix(domain); ps != "" && !hasDotSuffix(domain, ps) { if host == domain { // This is the one exception in which a cookie // with a domain attribute is a host cookie. return host, true, nil } return "", false, errIllegalDomain } } // The domain must domain-match host: www.mycompany.com cannot // set cookies for .ourcompetitors.com. if host != domain && !hasDotSuffix(host, domain) { return "", false, errIllegalDomain } return domain, false, nil }