//go:build linux || freebsd // +build linux freebsd package hsts import ( "net/http" "net/url" "os" "path/filepath" "strings" "testing" "time" ) func TestParseHeader(t *testing.T) { tests := []struct { input string wantMaxAge int64 wantIncludeSubdomains bool }{ {"max-age=31536000", 31536000, false}, {"max-age=0", 0, false}, {"max-age=31536000; includeSubDomains", 31536000, true}, {"includeSubDomains; max-age=31536000", 31536000, true}, {"max-age=60; includesubdomains", 60, true}, {"MAX-AGE=120", 120, false}, {"", -1, false}, {"max-age=bad", -1, false}, {"preload; max-age=100", 100, false}, } for _, tt := range tests { age, inc := parseHeader(tt.input) if age != tt.wantMaxAge || inc != tt.wantIncludeSubdomains { t.Errorf("parseHeader(%q) = (%d, %v), want (%d, %v)", tt.input, age, inc, tt.wantMaxAge, tt.wantIncludeSubdomains) } } } func TestCacheApply(t *testing.T) { c := NewCache() c.entries["example.com"] = &Entry{ Host: "example.com", IncludeSubdomains: false, Expires: time.Now().Add(time.Hour), } c.entries["sub.example.com"] = &Entry{ Host: "sub.example.com", IncludeSubdomains: true, Expires: time.Now().Add(time.Hour), } tests := []struct { urlStr string modified bool wantScheme string }{ {"http://example.com/path", true, "https"}, {"https://example.com/path", false, "https"}, {"http://other.com/path", false, "http"}, {"http://sub.example.com/path", true, "https"}, {"http://deep.sub.example.com/path", true, "https"}, {"ftp://example.com/path", false, "ftp"}, } for _, tt := range tests { u, _ := url.Parse(tt.urlStr) got := c.Apply(u) if got != tt.modified { t.Errorf("Apply(%q) modified=%v, want %v", tt.urlStr, got, tt.modified) } if u.Scheme != tt.wantScheme { t.Errorf("Apply(%q) scheme=%q, want %q", tt.urlStr, u.Scheme, tt.wantScheme) } } } func TestCacheUpdate(t *testing.T) { c := NewCache() u, _ := url.Parse("https://example.com/") resp := &http.Response{ Header: http.Header{"Strict-Transport-Security": []string{"max-age=3600"}}, Request: &http.Request{URL: u}, } c.Update(resp) if len(c.entries) != 1 { t.Fatalf("expected 1 entry, got %d", len(c.entries)) } e := c.entries["example.com"] if e == nil { t.Fatal("expected entry for example.com") } if e.IncludeSubdomains { t.Error("expected IncludeSubdomains=false") } if e.Expires.Before(time.Now().Add(59*time.Minute)) || e.Expires.After(time.Now().Add(61*time.Minute)) { t.Errorf("unexpected expires: %v", e.Expires) } } func TestCacheUpdateIgnoresHTTP(t *testing.T) { c := NewCache() u, _ := url.Parse("http://example.com/") resp := &http.Response{ Header: http.Header{"Strict-Transport-Security": []string{"max-age=3600"}}, Request: &http.Request{URL: u}, } c.Update(resp) if len(c.entries) != 0 { t.Errorf("expected 0 entries for HTTP response, got %d", len(c.entries)) } } func TestCacheLoadSave(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "hsts") c := NewCache() c.entries["example.com"] = &Entry{ Host: "example.com", Expires: time.Now().Add(time.Hour), } c.entries["expired.com"] = &Entry{ Host: "expired.com", Expires: time.Now().Add(-time.Hour), } c.path = path if err := c.Save(); err != nil { t.Fatalf("save failed: %v", err) } c2 := NewCache() if err := c2.Load(path); err != nil { t.Fatalf("load failed: %v", err) } if len(c2.entries) != 1 { t.Errorf("expected 1 active entry after load, got %d", len(c2.entries)) } if c2.entries["example.com"] == nil { t.Error("expected example.com entry") } if c2.entries["expired.com"] != nil { t.Error("did not expect expired.com entry") } } func TestCacheSaveRemovesEmpty(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "hsts") // Create a dummy file if err := os.WriteFile(path, []byte("[]"), 0644); err != nil { t.Fatal(err) } c := NewCache() c.path = path if err := c.Save(); err != nil { t.Fatalf("save failed: %v", err) } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Error("expected cache file to be removed when empty") } } func TestCacheSubdomainMatch(t *testing.T) { c := NewCache() c.entries["example.com"] = &Entry{ Host: "example.com", IncludeSubdomains: true, Expires: time.Now().Add(time.Hour), } u, _ := url.Parse("http://a.b.c.example.com/") if !c.Apply(u) { t.Error("expected deep subdomain to match") } if u.Scheme != "https" { t.Errorf("expected https, got %s", u.Scheme) } } func TestCacheExpiredEntry(t *testing.T) { c := NewCache() c.entries["example.com"] = &Entry{ Host: "example.com", Expires: time.Now().Add(-time.Hour), } u, _ := url.Parse("http://example.com/") if c.Apply(u) { t.Error("expected expired entry to not match") } } func TestCacheUpdateOverwrite(t *testing.T) { c := NewCache() u, _ := url.Parse("https://example.com/") resp1 := &http.Response{ Header: http.Header{"Strict-Transport-Security": []string{"max-age=3600"}}, Request: &http.Request{URL: u}, } c.Update(resp1) resp2 := &http.Response{ Header: http.Header{"Strict-Transport-Security": []string{"max-age=7200; includeSubDomains"}}, Request: &http.Request{URL: u}, } c.Update(resp2) e := c.entries["example.com"] if e == nil { t.Fatal("expected entry") } if !e.IncludeSubdomains { t.Error("expected IncludeSubdomains=true after overwrite") } if e.Expires.Before(time.Now().Add(119*time.Minute)) || e.Expires.After(time.Now().Add(121*time.Minute)) { t.Errorf("unexpected expires after overwrite: %v", e.Expires) } } func TestCacheConcurrent(t *testing.T) { c := NewCache() u, _ := url.Parse("https://example.com/") // Concurrent reads and writes should not race for i := 0; i < 100; i++ { go func() { url, _ := url.Parse("http://example.com/") c.Apply(url) }() go func() { resp := &http.Response{ Header: http.Header{"Strict-Transport-Security": []string{"max-age=3600"}}, Request: &http.Request{URL: u}, } c.Update(resp) }() } time.Sleep(50 * time.Millisecond) } func TestDefaultPath(t *testing.T) { home := t.TempDir() os.Setenv("HOME", home) os.Unsetenv("XDG_CONFIG_HOME") path := DefaultCachePath() wantSuffix := filepath.Join(".config", "goget", "hsts") if !strings.HasSuffix(path, wantSuffix) { t.Errorf("default path %q does not end with %q", path, wantSuffix) } } // TestMatchPublicSuffixBoundary is a regression guard for the BACKLOG // entry "HSTS matches beyond public suffix boundary". The previous // implementation walked parent domains one dot at a time without // stopping at the registrable domain (eTLD+1), so a HSTS entry // accidentally stored at a public suffix (e.g. "co.uk") would be // applied to the unrelated registrable domain "example.co.uk", // violating RFC 6797. func TestMatchPublicSuffixBoundary(t *testing.T) { // addEntry is a local test helper that stores a HSTS entry // directly in the private map, bypassing the HTTPS-only Update path. addEntry := func(c *Cache, host string, includeSubdomains bool) { c.entries[host] = &Entry{ Host: host, IncludeSubdomains: includeSubdomains, Expires: time.Now().Add(time.Hour), } } tests := []struct { name string policyHost string policySubdomains bool requestHost string expectUpgrade bool }{ // Legitimate: HSTS on the registrable domain applies to its // subdomains. { name: "subdomain of eTLD+1 is upgraded", policyHost: "example.co.uk", policySubdomains: true, requestHost: "www.example.co.uk", expectUpgrade: true, }, { name: "registrable domain itself is upgraded", policyHost: "example.co.uk", policySubdomains: true, requestHost: "example.co.uk", expectUpgrade: true, }, // Pathological but possible: an HSTS entry was stored on a // public suffix. The matchLocked walk must NOT let it cover // the unrelated registrable domain. { name: "public suffix entry does not upgrade unrelated eTLD+1", policyHost: "co.uk", policySubdomains: true, requestHost: "example.co.uk", expectUpgrade: false, }, { name: "public suffix entry does not upgrade further sibling", policyHost: "uk", policySubdomains: true, requestHost: "example.co.uk", expectUpgrade: false, }, // Same idea at the .com boundary. { name: "TLD entry does not upgrade unrelated .com domain", policyHost: "com", policySubdomains: true, requestHost: "example.com", expectUpgrade: false, }, { name: "TLD entry does not upgrade further sibling", policyHost: "com", policySubdomains: true, requestHost: "anotherexample.com", expectUpgrade: false, }, // Subdomains are not set — no upgrade even on the registrable // domain. { name: "no upgrade without includeSubdomains", policyHost: "example.com", policySubdomains: false, requestHost: "www.example.com", expectUpgrade: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { cache := NewCache() addEntry(cache, tc.policyHost, tc.policySubdomains) u, err := url.Parse("http://" + tc.requestHost + "/") if err != nil { t.Fatalf("parse: %v", err) } upgraded := cache.Apply(u) if upgraded != tc.expectUpgrade { t.Errorf("Apply(%q) returned %v, want %v (final scheme=%q)", tc.requestHost, upgraded, tc.expectUpgrade, u.Scheme) } if tc.expectUpgrade && u.Scheme != "https" { t.Errorf("expected https after upgrade, got %q", u.Scheme) } if !tc.expectUpgrade && u.Scheme != "http" { t.Errorf("expected http (no upgrade), got %q", u.Scheme) } }) } }