377 lines
9.7 KiB
Go
377 lines
9.7 KiB
Go
//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)
|
|
}
|
|
})
|
|
}
|
|
}
|