Relentless Coding

A Developer’s Blog

Cookies

Under what circumstances cookies are accepted or rejected by user agents and send back to web servers can be a bit confusing. Let’s experiment a bit.

TL;DR

Actually, it is pretty simple:

The origin domain is the domain you’re visiting. An origin domain can set cookies for its own domain and any higher-level domains. A client will send cookies for the origin domain and any higher-level domains. Conversely, a higher-level domain can read nor write cookies for subdomains.

So: example.com can read and write cookies for example.com but not sub.example.com. sub.example.com can read and write cookies for sub.example.com and example.com, but not for its sibling sub2.example.com. Top-level domains (TLDs) are always off-limit for everyone.

Test Drive

We’re going to prove all the cases (and some special ones) to ourselves using our favorite user agent: curl(1)! Please note that I won’t be showing all of the command-line output everywhere to prevent this post from becoming too large.

Setup

  1. Add the following lines to /etc/hosts:

    127.0.0.1	example.com sub1.example.com sub2.sub1.example.com
    127.0.0.1	other.com sub1.other.com sub2.sub1.other.com
    
  2. Create a self-signed certificate. Every curl invocation should specify --cacert <cert> so curl will accept and verify the self-signed certificate.

  3. Run the following Golang program:

    cookie.go
     // expirements with setting cookies
     package main
    
     import (
         "fmt"
         "log"
         "net/http"
     )
    
     var counter = 0
    
     var inc = func() string {
         counter++
         return fmt.Sprintf("%d", counter)
     }
    
     func main() {
         http.HandleFunc("/other-domain", handleOtherDomain)
         http.HandleFunc("/tld", handleTLD)
         http.HandleFunc("/subdomain", handleSubdomain)
         http.HandleFunc("/domainless", handleNoDomain)
         http.HandleFunc("/host-prefix-secure", handleHostPrefixSecure)
         http.HandleFunc("/host-prefix-not-secure", handleHostPrefixNotSecure)
         http.HandleFunc("/secure", handleSecure)
         http.HandleFunc("/not-secure", handleNotSecure)
         go func() {
             log.Fatal(http.ListenAndServeTLS("127.0.0.1:443", "cert.pem", "key.pem", nil))
         }()
         go func() {
             log.Fatal(http.ListenAndServe("127.0.0.1:80", nil))
         }()
    
         select {}
     }
    
     // handleOtherDomain tries to set cookie for different domain
     func handleOtherDomain(w http.ResponseWriter, _ *http.Request) {
         http.SetCookie(w, &http.Cookie{
             Name:   "other",
             Value:  inc(),
             Domain: "other.com",
         })
     }
    
     // handleTLD tries to set a "supercookie" for a TLD
     func handleTLD(w http.ResponseWriter, _ *http.Request) {
         http.SetCookie(w, &http.Cookie{
             Name:   "tld",
             Value:  inc(),
             Domain: "com",
         })
     }
    
     // handleSubdomain sets 3 cookies for parent and 2 subdomains
     func handleSubdomain(w http.ResponseWriter, _ *http.Request) {
         // can be set on parent or any subdomain of parent
         http.SetCookie(w, &http.Cookie{
             Name:   "parent",
             Value:  inc(),
             Domain: "example.com",
         })
    
         // can be set from sub1 domain or any subdomain of sub1
         http.SetCookie(w, &http.Cookie{
             Name:   "sub1",
             Value:  inc(),
             Domain: "sub1.example.com",
         })
    
         // can be set from sub2 domain or any subdomain of sub2
         http.SetCookie(w, &http.Cookie{
             Name:   "sub2",
             Value:  inc(),
             Domain: "sub2.sub1.example.com",
         })
     }
    
     // handleNoDomain set cookie w/ no Domain attribute: can be set on origin
     // domain only and will be sent only to origin domain, not subdomains
     func handleNoDomain(w http.ResponseWriter, r *http.Request) {
         http.SetCookie(w, &http.Cookie{
             Name:  fmt.Sprintf("domainless-%s", r.Host),
             Value: inc(),
         })
     }
    
     // handleHostPrefixSecure sets "domain-locked" cookie: has Secure attr, no
     // Domain and is only accepted when sent over secure connection
     func handleHostPrefixSecure(w http.ResponseWriter, _ *http.Request) {
         http.SetCookie(w, &http.Cookie{
             Name:   "__Host-host",
             Value:  inc(),
             Secure: true,
         })
     }
    
     // handleHostPrefixNotSecure tries to set __Host- cookie w/o Secure attr
     // these "domain-locked" cookies should have Secure attr, no Domain and
     // sent over secure connection
     func handleHostPrefixNotSecure(w http.ResponseWriter, _ *http.Request) {
         http.SetCookie(w, &http.Cookie{
             Name:   "__Host-host",
             Value:  inc(),
             Secure: false,
         })
     }
    
     // handleSecure tries to set secure cookie
     // should only be accepted (and send) by clients when sent over secure connection
     func handleSecure(w http.ResponseWriter, _ *http.Request) {
         http.SetCookie(w, &http.Cookie{
             Name:   "secure",
             Value:  inc(),
             Domain: "example.com",
             Secure: true,
         })
     }
    
     // handleNotSecure sets cookie with name "secure" but w/o Secure attr set
     func handleNotSecure(w http.ResponseWriter, _ *http.Request) {
         http.SetCookie(w, &http.Cookie{
             Name:   "secure",
             Value:  inc(),
             Domain: "example.com",
             Secure: false,
         })
     }
    

Let’s begin with an empty cookie jar:

$ > jar

Let’s get “setting a cookie for another domain” out of the way first:

$ curl -v -b jar -c jar https://example.com/other-domain
... snip ...
> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/2 200 
* skipped cookie with bad tailmatch domain: other.com
< set-cookie: other=other; Domain=other.com
< content-length: 0
< date: Sat, 25 May 2024 12:15:58 GMT

curl informs us that example.com can’t set a cookie for other.com. Check.

$ curl -v -b jar -c jar https://example.com/tld
... snip ...
* cookie 'tld' dropped, domain 'example.com' must not set cookies for 'com'
< set-cookie: tld=tld; Domain=com
... snip ...

We can’t set a cookie for .com or any other TLD. Another check.

$ curl -v -b jar -c jar https://example.com/subdomains
... snip ...
* Added cookie parent="parent" for domain example.com, path /, expire 0
< set-cookie: parent=parent; Domain=example.com
* skipped cookie with bad tailmatch domain: sub1.example.com
< set-cookie: sub1=sub1; Domain=sub1.example.com
* skipped cookie with bad tailmatch domain: sub2.sub1.example.com
< set-cookie: sub2=sub2; Domain=sub2.sub1.example.com
... snip ...

Only the cookie set for itself is accepted. Cookies for subdomains are rejected. Another check.

Let’s examine the cookie jar at this point:

$ tail -n +5 jar | column -t
.example.com  TRUE  /  FALSE  0  parent  parent

We notice that the cookie with name parent was accepted and put in the jar. It’s a cookie that the client will send to subdomains as we’ll see in a bit. This is indicated by the leading dot .example.com and the TRUE in the second column. The jar file format is explained here.

Do User Agents Send Cookies for Parents to Subdomains?

Let’s make a request to sub1.example.com:

$ curl -v -b jar -c jar https://sub1.example.com/subdomains
... snip ...
> GET / HTTP/2
> Host: sub1.example.com
> User-Agent: curl/8.5.0
> Accept: */*
> Cookie: parent=parent
> 
< HTTP/2 200 
* Replaced cookie parent="parent" for domain example.com, path /, expire 0
< set-cookie: parent=parent; Domain=example.com
* Added cookie sub1="sub1" for domain sub1.example.com, path /, expire 0
< set-cookie: sub1=sub1; Domain=sub1.example.com
* skipped cookie with bad tailmatch domain: sub2.sub1.example.com
< set-cookie: sub2=sub2; Domain=sub2.sub1.example.com
  1. The client sends a cookie set on the parent domain to the subdomain.
  2. The client accepts a cookie with the parent domain, replacing the previous cookie, and for the origin domain.
  3. The cookie for a subdomain, sub2.sub1.example.com is rejected.

To really drive this point home, let’s make a request to sub2.sub1.example.com:

$ curl -v -b jar -c jar https://sub2.sub1.example.com/subdomains
> GET / HTTP/2
> Host: sub2.sub1.example.com
> User-Agent: curl/8.5.0
> Accept: */*
> Cookie: sub1=sub1; parent=parent
> 
< HTTP/2 200 
* Replaced cookie parent="parent" for domain example.com, path /, expire 0
< set-cookie: parent=parent; Domain=example.com
* Replaced cookie sub1="sub1" for domain sub1.example.com, path /, expire 0
< set-cookie: sub1=sub1; Domain=sub1.example.com
* Added cookie sub2="sub2" for domain sub2.sub1.example.com, path /, expire 0
< set-cookie: sub2=sub2; Domain=sub2.sub1.example.com

Now the jar contains:

$ tail -n +5 jar | column -t
.sub2.sub1.example.com  TRUE  /  FALSE  0  sub2    sub2
.sub1.example.com       TRUE  /  FALSE  0  sub1    sub1
.example.com            TRUE  /  FALSE  0  parent  parent

Are Subdomain Cookies Send to the Parent Domain?

What cookies are send to example.com?

$ curl -v -b jar -c jar https://example.com
> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
> Cookie: parent=parent
... snip ...

Only the ones set for the origin domain, not for any subdomains.

Special Cases

A cookie without a Domain attribute will be accepted for the origin domain only. It will not be sent with requests to subdomains.

$ # empty jar
$ > jar
$ curl -v -b jar -c jar https://example.com/domainless
> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/2 200 
* Added cookie domainless-example.com="domainless-example.com" for domain example.com, path /, expire 0
< set-cookie: domainless-example.com=domainless-example.com

Check the jar:

$ tail -n +5 jar | column -t
example.com  FALSE  /  FALSE  0  domainless-example.com  domainless-example.com

Notice the missing dot in the cookie jar and the FALSE in the second column. This cookie is not send to subdomains:

$ curl -v -b jar -c jar https://sub1.example.com
> GET / HTTP/2
> Host: sub1.example.com
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/2 200 
* Added cookie domainless-sub1.example.com="domainless-sub1.example.com" for domain sub1.example.com, path /, expire 0
< set-cookie: domainless-sub1.example.com=domainless-sub1.example.com
... snip ...

Mozilla writes that there are certain prefixes that are respected by HTTP user agents. Setting a cookie that has a name starting with __Host- will be accepted only if (1) the Secure attribute has been set, (2) it was sent from a secure origin, (3) the Domain attribute is missing and Path is set to /:

$ curl -v -b jar -c jar https://example.com/host-prefix-secure
> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/2 200 
* Added cookie __Host-host="__Host-host" for domain example.com, path /, expire 0
< set-cookie: __Host-host=__Host-host; Secure

Let’s see what happens when it is sent over an insecure channel:

$ # empty jar first
$ > jar
$ curl -v -b jar -c jar http://example.com/host-prefix-secure
> GET / HTTP/1.1
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Set-Cookie: __Host-host=__Host-host; Secure
... snip ...

The jar stays empty:

$ tail -n +5 jar | column -t
$

Sending over secure channel but not setting Secure attribute:

$ curl -v -b jar -c jar https://example.com/host-prefix-not-secure
> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/2 200 
< set-cookie: __Host-host=__Host-host
... snip ...
$ tail -n +5 jar | column -t
$

Another prefix is __Secure-. Cookies sent with this prefix are only accepted when it’s marked Secure and send over a secure channel. So it’s weaker than __Host-.

The reason these prefixes are used is that normally a server can’t be sure that a cookie was set from a secure origin or even what exact origin domain set the cookie. Remember that subdomains can set cookies for parent domains. A subdomain can insecurely set a cookie that the parent domain also sets with Secure and therefore expects to be secured.

The Secure Attribute

Clients reject cookies marked Secure if received over an insecure channel:

$ curl -v -b jar -c jar http://example.com
> GET / HTTP/1.1
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Set-Cookie: secure=secure; Domain=example.com; Secure
$ tail -n +5 jar | column -t
$

Over a secure channel, this cookie is accepted. However, if a subdomain overrides this cookie and leaves out the Secure attribute:

$ curl -v -b jar -c jar https://sub1.example.com/not-secure
> GET / HTTP/2
> Host: sub1.example.com
> User-Agent: curl/8.5.0
> Accept: */*
> Cookie: secure=secure
> 
< HTTP/2 200 
* Replaced cookie secure="secure" for domain example.com, path /, expire 0
< set-cookie: secure=secure; Domain=example.com
$ tail -n +5 jar | column -t
.example.com  TRUE  /  FALSE  0  secure  secure

Notice that the 4th field is FALSE, indicating this cookie is not Secure. Now, when we make a request to the parent domain:

$ curl -v -b jar -c jar https://example.com
> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
> Cookie: secure=secure
> 
< HTTP/2 200 
... snip ...

So that’s why __Host- and __Secure- prefixes exist.

Source Code

You can get the source code used in this post from Source Hut: https://git.sr.ht/~neftas/cookie.