net/http/cookiejar: treat localhost as secure origin

For development purposes, browsers treat localhost
as a secure origin regardless of protocol.

Fixes #60997

https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#restrict_access_to_cookies
https://bugzilla.mozilla.org/show_bug.cgi?id=1618113
https://issues.chromium.org/issues/40120372

Change-Id: I6d31df4e055f2872c4b93571c53ae5160923852b
Reviewed-on: https://go-review.googlesource.com/c/go/+/717860
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Mark Freeman <markfreeman@google.com>
Reviewed-by: Damien Neil <dneil@google.com>
This commit is contained in:
Sean Liao 2025-11-04 22:47:42 +00:00
parent f870a1d398
commit 7aa9ca729f
2 changed files with 90 additions and 1 deletions

View file

@ -12,6 +12,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/http/internal/ascii" "net/http/internal/ascii"
"net/netip"
"net/url" "net/url"
"slices" "slices"
"strings" "strings"
@ -120,7 +121,7 @@ func (e *entry) id() string {
// request to host/path. It is the caller's responsibility to check if the // request to host/path. It is the caller's responsibility to check if the
// cookie is expired. // cookie is expired.
func (e *entry) shouldSend(https bool, host, path string) bool { 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. // 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 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. // hasDotSuffix reports whether s ends in "."+suffix.
func hasDotSuffix(s, suffix string) bool { func hasDotSuffix(s, suffix string) bool {
return len(s) > len(suffix) && s[len(s)-len(suffix)-1] == '.' && s[len(s)-len(suffix):] == suffix return len(s) > len(suffix) && s[len(s)-len(suffix)-1] == '.' && s[len(s)-len(suffix):] == suffix

View file

@ -471,6 +471,62 @@ var basicsTests = [...]jarTest{
{"https://www.host.test/some/path", "A=a"}, {"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.", "Explicit path.",
"http://www.host.test/", "http://www.host.test/",