diff --git a/src/net/http/cookiejar/jar.go b/src/net/http/cookiejar/jar.go index edf14d03ad3..db6bcddb268 100644 --- a/src/net/http/cookiejar/jar.go +++ b/src/net/http/cookiejar/jar.go @@ -12,6 +12,7 @@ import ( "net" "net/http" "net/http/internal/ascii" + "net/netip" "net/url" "slices" "strings" @@ -120,7 +121,7 @@ func (e *entry) id() string { // 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) + return e.domainMatch(host) && e.pathMatch(path) && e.secureMatch(https) } // domainMatch checks whether e's Domain allows sending e back to host. @@ -148,6 +149,38 @@ func (e *entry) pathMatch(requestPath string) bool { return false } +// secureMatch checks whether a cookie should be sent based on the protocol +// and the Secure flag. Localhost is considered a secure origin regardless +// of protocol, matching browser behavior. +func (e *entry) secureMatch(https bool) bool { + if !e.Secure { + // Cookies not marked secure are always sent. + return true + } + // Everything below is about cookies marked secure. + if https { + // HTTPS request matches secure cookies. + return true + } + // Consider localhost to be secure like browsers. + if isLocalhost(e.Domain) { + return true + } + ip, err := netip.ParseAddr(e.Domain) + if err == nil && ip.IsLoopback() { + return true + } + return false +} + +func isLocalhost(host string) bool { + host = strings.TrimSuffix(host, ".") + if idx := strings.LastIndex(host, "."); idx >= 0 { + host = host[idx+1:] + } + return ascii.EqualFold(host, "localhost") +} + // 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 diff --git a/src/net/http/cookiejar/jar_test.go b/src/net/http/cookiejar/jar_test.go index 509560170a5..feedd6d0e94 100644 --- a/src/net/http/cookiejar/jar_test.go +++ b/src/net/http/cookiejar/jar_test.go @@ -471,6 +471,62 @@ var basicsTests = [...]jarTest{ {"https://www.host.test/some/path", "A=a"}, }, }, + { + "Secure cookies are sent for localhost", + "http://localhost:8910/", + []string{"A=a; secure"}, + "A=a", + []query{ + {"http://localhost:8910", "A=a"}, + {"http://localhost:8910/", "A=a"}, + {"http://localhost:8910/some/path", "A=a"}, + {"https://localhost:8910", "A=a"}, + {"https://localhost:8910/", "A=a"}, + {"https://localhost:8910/some/path", "A=a"}, + }, + }, + { + "Secure cookies are sent for localhost (tld)", + "http://example.LOCALHOST:8910/", + []string{"A=a; secure"}, + "A=a", + []query{ + {"http://example.LOCALHOST:8910", "A=a"}, + {"http://example.LOCALHOST:8910/", "A=a"}, + {"http://example.LOCALHOST:8910/some/path", "A=a"}, + {"https://example.LOCALHOST:8910", "A=a"}, + {"https://example.LOCALHOST:8910/", "A=a"}, + {"https://example.LOCALHOST:8910/some/path", "A=a"}, + }, + }, + { + "Secure cookies are sent for localhost (ipv6)", + "http://[::1]:8910/", + []string{"A=a; secure"}, + "A=a", + []query{ + {"http://[::1]:8910", "A=a"}, + {"http://[::1]:8910/", "A=a"}, + {"http://[::1]:8910/some/path", "A=a"}, + {"https://[::1]:8910", "A=a"}, + {"https://[::1]:8910/", "A=a"}, + {"https://[::1]:8910/some/path", "A=a"}, + }, + }, + { + "Localhost only if it's a segment", + "http://notlocalhost/", + []string{"A=a; secure"}, + "A=a", + []query{ + {"http://notlocalhost", ""}, + {"http://notlocalhost/", ""}, + {"http://notlocalhost/some/path", ""}, + {"https://notlocalhost", "A=a"}, + {"https://notlocalhost/", "A=a"}, + {"https://notlocalhost/some/path", "A=a"}, + }, + }, { "Explicit path.", "http://www.host.test/",