diff --git a/.gitignore b/.gitignore index a51a7b7..e2391bb 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,13 @@ go.work.sum *.code-workspace .idea/ +# Build artifacts +/wasm +/website + +# Block markdown files by default +*.md +# Whitelist +!/NOTES.md +!README.md + diff --git a/Makefile b/Makefile index f88396f..9cfc26e 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,11 @@ kooky: ${SRC} test: @env GOWORK=off go test -count=1 -timeout=30s ./... | grep -v '^? ' +.PHONY: host-testwebsite +host-testwebsite: + @env GOWORK=off go generate ./internal/testcmd/website/ + @env GOWORK=off go run ./internal/testcmd/website/ -tls + .PHONY: clean clean: @rm -f -- kooky kooky.exe kooky.test diff --git a/browser/all/import.go b/browser/all/import.go index bfd7a70..315abf7 100644 --- a/browser/all/import.go +++ b/browser/all/import.go @@ -18,4 +18,5 @@ import ( _ "github.com/browserutils/kooky/browser/safari" _ "github.com/browserutils/kooky/browser/uzbl" _ "github.com/browserutils/kooky/browser/w3m" + _ "github.com/browserutils/kooky/browser/website" ) diff --git a/browser/website/doc.go b/browser/website/doc.go new file mode 100644 index 0000000..6057e2a --- /dev/null +++ b/browser/website/doc.go @@ -0,0 +1,3 @@ +// Package website reads cookies from the browser environment +// using the Cookie Store API with a fallback to document.cookie. +package website diff --git a/browser/website/find.go b/browser/website/find.go new file mode 100644 index 0000000..7a615ff --- /dev/null +++ b/browser/website/find.go @@ -0,0 +1,26 @@ +//go:build js + +package website + +import ( + "github.com/browserutils/kooky" + "github.com/browserutils/kooky/internal/cookies" +) + +type websiteFinder struct{} + +var _ kooky.CookieStoreFinder = (*websiteFinder)(nil) + +func init() { + kooky.RegisterFinder(browserName, &websiteFinder{}) +} + +func (f *websiteFinder) FindCookieStores() kooky.CookieStoreSeq { + return func(yield func(kooky.CookieStore, error) bool) { + s := newStore() + st := &cookies.CookieJar{CookieStore: s} + if !yield(st, nil) { + return + } + } +} diff --git a/browser/website/website.go b/browser/website/website.go new file mode 100644 index 0000000..0b0b8d7 --- /dev/null +++ b/browser/website/website.go @@ -0,0 +1,64 @@ +//go:build js + +package website + +import ( + "context" + "errors" + + "github.com/browserutils/kooky" + "github.com/browserutils/kooky/internal/cookies" + "github.com/browserutils/kooky/internal/iterx" + "github.com/browserutils/kooky/internal/website" +) + +const browserName = `website` + +type websiteCookieStore struct { + cookies.DefaultCookieStore +} + +var _ cookies.CookieStore = (*websiteCookieStore)(nil) + +func newStore() *websiteCookieStore { + s := &websiteCookieStore{} + s.BrowserStr = browserName + s.IsDefaultProfileBool = true + return s +} + +func ReadCookies(ctx context.Context, filters ...kooky.Filter) ([]*kooky.Cookie, error) { + return cookies.ReadCookiesClose(newStore(), filters...).ReadAllCookies(ctx) +} + +func TraverseCookies(filters ...kooky.Filter) kooky.CookieSeq { + return cookies.ReadCookiesClose(newStore(), filters...) +} + +// CookieStore has to be closed with CookieStore.Close() after use. +func CookieStore(filters ...kooky.Filter) (kooky.CookieStore, error) { + return cookieStoreFunc(filters...) +} + +func cookieStoreFunc(filters ...kooky.Filter) (*cookies.CookieJar, error) { + return cookies.NewCookieJar(newStore(), filters...), nil +} + +func (s *websiteCookieStore) TraverseCookies(filters ...kooky.Filter) kooky.CookieSeq { + if s == nil { + return iterx.ErrCookieSeq(errors.New(`cookie store is nil`)) + } + return func(yield func(*kooky.Cookie, error) bool) { + for c, err := range website.TraverseCookies(s, nil, filters...) { + if err != nil { + if !yield(nil, err) { + return + } + continue + } + if !yield(c.Cookie, nil) { + return + } + } + } +} diff --git a/browser/website/website_other.go b/browser/website/website_other.go new file mode 100644 index 0000000..8f38c1e --- /dev/null +++ b/browser/website/website_other.go @@ -0,0 +1,26 @@ +//go:build !js + +package website + +import ( + "context" + "errors" + + "github.com/browserutils/kooky" + "github.com/browserutils/kooky/internal/iterx" +) + +var errUnsupportedPlatform = errors.New(`website: cookie access requires js or wasm platform`) + +func ReadCookies(_ context.Context, _ ...kooky.Filter) ([]*kooky.Cookie, error) { + return nil, errUnsupportedPlatform +} + +func TraverseCookies(_ ...kooky.Filter) kooky.CookieSeq { + return iterx.ErrCookieSeq(errUnsupportedPlatform) +} + +// CookieStore has to be closed with CookieStore.Close() after use. +func CookieStore(_ ...kooky.Filter) (kooky.CookieStore, error) { + return nil, errUnsupportedPlatform +} diff --git a/internal/firefox/container.go b/internal/firefox/container.go index 4ab6d14..29aa5f1 100644 --- a/internal/firefox/container.go +++ b/internal/firefox/container.go @@ -2,20 +2,59 @@ package firefox import ( "encoding/json" + "io" + "os" + "path/filepath" "strings" ) // for the official "Firefox Multi-Account Containers" addon -func (s *CookieStore) initContainersMap() error { +// defaultContainerLabels maps Firefox l10nId values to English labels +// for the four built-in container types. +// Default containers use l10nId (localization) instead of a plain name field; +// Firefox localizes these per locale at runtime, but we use the English labels +// as a reasonable fallback since we have no access to the l10n system. +var defaultContainerLabels = map[string]string{ + `user-context-personal`: `Personal`, + `user-context-work`: `Work`, + `user-context-banking`: `Banking`, + `user-context-shopping`: `Shopping`, +} + +func (s *CookieStore) initContainersMap() { if s.Containers != nil || s.contFile == nil { - return nil + return } + s.Containers, s.containersErr = parseContainersJSON(s.contFile) +} +func (s *SessionCookieStore) initSessionContainersMap() { + if s.Containers != nil { + return + } + contFileName := filepath.Join(s.profileDir, `containers.json`) + f, err := os.Open(contFileName) + if err != nil { + return + } + defer f.Close() + s.Containers, s.containersErr = parseContainersJSON(f) +} + +// parseContainersJSON reads the containers.json file written by Firefox +// (and the "Firefox Multi-Account Containers" addon). +// +// The file lists container identities. Default containers (Personal, Work, +// Banking, Shopping) use an l10nId field for localized display; custom +// containers (e.g. Facebook from Mozilla's Facebook Container extension) +// use a plain name field. Internal containers (thumbnail, webextStorageLocal) +// are identified by a "userContextIdInternal." name prefix and are skipped. +func parseContainersJSON(r io.Reader) (map[int]string, error) { conts := &containers{} - err := json.NewDecoder(s.contFile).Decode(conts) + err := json.NewDecoder(r).Decode(conts) if err != nil { - return err + return nil, err } contMap := make(map[int]string) @@ -27,11 +66,13 @@ func (s *CookieStore) initContainersMap() error { name = `` } } + // fall back to l10nId for default containers (Personal, Work, Banking, Shopping) + if name == `` && cont.L10nID != nil { + name = defaultContainerLabels[*cont.L10nID] + } contMap[cont.UserContextID] = name } - s.Containers = contMap - - return nil + return contMap, nil } type containers struct { @@ -49,4 +90,24 @@ type containers struct { Version int `json:"version"` } -// TODO names of default container +// parseOriginAttributes parses the originAttributes column from moz_cookies. +// +// Format: "^key1=value1&key2=value2" (leading ^ is stripped). +// +// Known attributes: +// - userContextId: container tab identity (1=Personal, 2=Work, 3=Banking, 4=Shopping by default) +// - partitionKey: CHIPS (Cookies Having Independent Partitioned State) top-level site, +// e.g. "%28https%2Cexample.com%29" (URL-encoded "(https,example.com)") +// - firstPartyDomain: First-Party Isolation (FPI, used by Tor Browser / privacy.firstparty.isolate) +// - privateBrowsingId: private browsing session (not persisted to disk) +// - geckoViewSessionContextId: Android GeckoView embedding context +func parseOriginAttributes(s string) map[string]string { + s = strings.TrimPrefix(s, `^`) + attrs := make(map[string]string) + for _, part := range strings.Split(s, `&`) { + if k, v, ok := strings.Cut(part, `=`); ok && len(k) > 0 { + attrs[k] = v + } + } + return attrs +} diff --git a/internal/firefox/cookiestore.go b/internal/firefox/cookiestore.go index e0462d0..14b3313 100644 --- a/internal/firefox/cookiestore.go +++ b/internal/firefox/cookiestore.go @@ -12,10 +12,11 @@ import ( type CookieStore struct { cookies.DefaultCookieStore - Database *sqlite3.DbFile - Containers map[int]string - dbFile *os.File - contFile *os.File + Database *sqlite3.DbFile + Containers map[int]string + containersErr error + dbFile *os.File + contFile *os.File } var _ cookies.CookieStore = (*CookieStore)(nil) diff --git a/internal/firefox/firefox.go b/internal/firefox/firefox.go index 007b926..1b5c148 100644 --- a/internal/firefox/firefox.go +++ b/internal/firefox/firefox.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "strconv" - "strings" "time" "github.com/browserutils/kooky" @@ -23,7 +22,7 @@ func (s *CookieStore) TraverseCookies(filters ...kooky.Filter) kooky.CookieSeq { return iterx.ErrCookieSeq(errors.New(`database is nil`)) } - _ = s.initContainersMap() + s.initContainersMap() visitor := func(yield func(*kooky.Cookie, error) bool) func(rowId *int64, row utils.TableRow) error { return func(rowId *int64, row utils.TableRow) error { @@ -90,21 +89,21 @@ func (s *CookieStore) TraverseCookies(filters ...kooky.Filter) kooky.CookieSeq { return err } - // Container - if s.Containers != nil { - ucidStr, _ := row.String(`originAttributes`) - prefixContextID := `^userContextId=` - if len(ucidStr) > 0 && strings.HasPrefix(ucidStr, prefixContextID) { - ucidStr = strings.TrimPrefix(ucidStr, prefixContextID) + // Container and Partitioned + if origAttr, _ := row.String(`originAttributes`); len(origAttr) > 0 { + attrs := parseOriginAttributes(origAttr) + if ucidStr, ok := attrs[`userContextId`]; ok && s.Containers != nil { cookie.Container = ucidStr ucid, err := strconv.Atoi(ucidStr) if err == nil { - contName, okContName := s.Containers[ucid] - if okContName && len(contName) > 0 { - cookie.Container += `|` + contName + if contName, ok := s.Containers[ucid]; ok && len(contName) > 0 { + cookie.Container = contName } } } + if _, ok := attrs[`partitionKey`]; ok { + cookie.Partitioned = true + } } cookie.Browser = s @@ -116,6 +115,11 @@ func (s *CookieStore) TraverseCookies(filters ...kooky.Filter) kooky.CookieSeq { } } seq := func(yield func(*kooky.Cookie, error) bool) { + if s.containersErr != nil { + if !yield(nil, s.containersErr) { + return + } + } err := utils.VisitTableRows(s.Database, `moz_cookies`, map[string]string{}, visitor(yield)) if err != nil && !errors.Is(err, iterx.ErrYieldEnd) { yield(nil, err) diff --git a/internal/firefox/sessionstore.go b/internal/firefox/sessionstore.go index 3e43076..2a72cc3 100644 --- a/internal/firefox/sessionstore.go +++ b/internal/firefox/sessionstore.go @@ -29,6 +29,7 @@ var sessionStoreFiles = []string{ type SessionCookieStore struct { cookies.DefaultCookieStore Containers map[int]string + containersErr error profileDir string resolvedPath string sessionCookies []sessionStoreCookie @@ -69,6 +70,8 @@ func (s *SessionCookieStore) Open() error { s.profileDir = s.FileNameStr } + s.initSessionContainersMap() + s.resolvedPath = `` s.sessionCookies = nil @@ -125,6 +128,11 @@ func (s *SessionCookieStore) TraverseCookies(filters ...kooky.Filter) kooky.Cook } return func(yield func(*kooky.Cookie, error) bool) { + if s.containersErr != nil { + if !yield(nil, s.containersErr) { + return + } + } for _, sc := range s.sessionCookies { cookie := &kooky.Cookie{} cookie.Name = sc.Name @@ -154,9 +162,13 @@ func (s *SessionCookieStore) TraverseCookies(filters ...kooky.Filter) kooky.Cook ucidStr := strconv.Itoa(sc.OriginAttributes.UserContextID) cookie.Container = ucidStr if contName, ok := s.Containers[sc.OriginAttributes.UserContextID]; ok && len(contName) > 0 { - cookie.Container += `|` + contName + cookie.Container = contName } } + // CHIPS partitioned cookie + if len(sc.OriginAttributes.PartitionKey) > 0 { + cookie.Partitioned = true + } if !iterx.CookieFilterYield(context.Background(), cookie, nil, yield, filters...) { return @@ -183,5 +195,6 @@ type sessionStoreCookie struct { } type sessionStoreOriginAttribs struct { - UserContextID int `json:"userContextId"` + UserContextID int `json:"userContextId"` + PartitionKey string `json:"partitionKey"` } diff --git a/internal/testcmd/website/gen.go b/internal/testcmd/website/gen.go new file mode 100644 index 0000000..98dd7b1 --- /dev/null +++ b/internal/testcmd/website/gen.go @@ -0,0 +1,67 @@ +//go:build ignore + +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "gen: %v\n", err) + os.Exit(1) + } +} + +func run() error { + // build wasm binary + buildCmd := exec.Command("go", "build", "-o", filepath.Join("static", "main.wasm"), "./wasm/") + buildCmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm") + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + if err := buildCmd.Run(); err != nil { + return fmt.Errorf("go build wasm: %w", err) + } + + // locate wasm_exec.js in GOROOT + goroot := runtime.GOROOT() + candidates := []string{ + filepath.Join(goroot, "lib", "wasm", "wasm_exec.js"), // Go 1.24+? + filepath.Join(goroot, "misc", "wasm", "wasm_exec.js"), // older Go + } + var src string + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + src = c + break + } + } + if src == "" { + return fmt.Errorf("wasm_exec.js not found in GOROOT (%s); tried:\n %s", goroot, strings.Join(candidates, "\n ")) + } + + // copy wasm_exec.js + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(filepath.Join("static", "wasm_exec.js")) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + // Close explicitly; deferred Close will be a harmless no-op on already-closed file. + return out.Close() +} diff --git a/internal/testcmd/website/generate.go b/internal/testcmd/website/generate.go new file mode 100644 index 0000000..c945dff --- /dev/null +++ b/internal/testcmd/website/generate.go @@ -0,0 +1,3 @@ +//go:generate go run gen.go + +package main diff --git a/internal/testcmd/website/main.go b/internal/testcmd/website/main.go new file mode 100644 index 0000000..6558ef0 --- /dev/null +++ b/internal/testcmd/website/main.go @@ -0,0 +1,160 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "embed" + "encoding/pem" + "flag" + "fmt" + "io/fs" + "math/big" + "net" + "net/http" + "os" + "time" +) + +//go:embed static +var staticFiles embed.FS + +func main() { + addr := flag.String("addr", "localhost:8080", "listen address") + useTLS := flag.Bool("tls", false, "enable HTTPS with a self-signed certificate") + flag.Parse() + + staticFS, err := fs.Sub(staticFiles, "static") + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + fileServer := http.FileServer(http.FS(staticFS)) + + testCookies := buildTestCookies() + + mux := http.NewServeMux() + + // serve static assets at their exact paths + mux.Handle("/main.wasm", fileServer) + mux.Handle("/wasm_exec.js", fileServer) + + // report endpoint for browser-side comparison + mux.Handle("/api/report", handleReport(testCookies)) + + // serve index.html on all other paths so the site works at any path + indexHTML, err := fs.ReadFile(staticFS, "index.html") + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + for _, c := range testCookies { + http.SetCookie(w, c) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(indexHTML) + }) + + ln, err := net.Listen("tcp", *addr) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + scheme := "http" + if *useTLS { + scheme = "https" + tlsCfg, err := selfSignedTLSConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "tls: %v\n", err) + os.Exit(1) + } + ln = tls.NewListener(ln, tlsCfg) + } + fmt.Printf("%s://%s/sub/path\n", scheme, ln.Addr()) + + if err := http.Serve(ln, mux); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func buildTestCookies() []*http.Cookie { + expires := time.Now().Add(24 * time.Hour) + + return []*http.Cookie{ + // basic + {Name: "basic", Value: "plain", Path: "/"}, + + // with expiry + {Name: "with_expires", Value: "has_expiry", Path: "/", Expires: expires}, + + // secure + {Name: "secure_only", Value: "sec", Path: "/", Secure: true}, + + // httponly (invisible to JS — document.cookie and Cookie Store API cannot see this) + {Name: "http_only", Value: "hidden", Path: "/", HttpOnly: true}, + + // samesite variations + {Name: "ss_lax", Value: "lax_val", Path: "/", SameSite: http.SameSiteLaxMode}, + {Name: "ss_strict", Value: "strict_val", Path: "/", SameSite: http.SameSiteStrictMode}, + {Name: "ss_none", Value: "none_val", Path: "/", Secure: true, SameSite: http.SameSiteNoneMode}, + + // path scoped + {Name: "path_scoped", Value: "sub", Path: "/sub/path/"}, + {Name: "path_root", Value: "root", Path: "/"}, + + // combined attributes + {Name: "combo", Value: "all_attrs", Path: "/", Secure: true, SameSite: http.SameSiteLaxMode, Expires: expires}, + + // long value + {Name: "long_value", Value: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", Path: "/"}, + + // partitioned + {Name: "partitioned", Value: "chip", Path: "/", Secure: true, SameSite: http.SameSiteNoneMode, Partitioned: true}, + + // special characters in value + {Name: "special_chars", Value: "hello%20world%26more%3Dstuff", Path: "/"}, + } +} + +func selfSignedTLSConfig() (*tls.Config, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return nil, err + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return nil, err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return nil, err + } + + return &tls.Config{Certificates: []tls.Certificate{cert}}, nil +} diff --git a/internal/testcmd/website/report.go b/internal/testcmd/website/report.go new file mode 100644 index 0000000..01e4904 --- /dev/null +++ b/internal/testcmd/website/report.go @@ -0,0 +1,174 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/browserutils/kooky/internal/website" +) + +func handleReport(sentCookies []*http.Cookie) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + var report website.CookieReport + if err := json.Unmarshal(body, &report); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + printReport(report, sentCookies) + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + } +} + +func printReport(report website.CookieReport, sent []*http.Cookie) { + // derive expected defaults from the browser's URL + u, _ := url.Parse(report.URL) + isHTTPS := u != nil && u.Scheme == "https" + hostname := "" + if u != nil { + hostname = u.Hostname() + } + + sep := strings.Repeat("─", 72) + fmt.Println(sep) + fmt.Printf("Browser: %s\n", report.UserAgent) + fmt.Printf("URL: %s\n", report.URL) + fmt.Printf("API: %s\n", report.API) + fmt.Printf("Received: %d cookie(s) Sent: %d cookie(s)\n", len(report.Cookies), len(sent)) + fmt.Println(sep) + + // index received cookies by name + received := make(map[string]website.ReportCookie, len(report.Cookies)) + for _, c := range report.Cookies { + received[c.Name] = c + } + + nOK := 0 + for _, s := range sent { + rc, found := received[s.Name] + if !found { + var reasons []string + if s.HttpOnly { + reasons = append(reasons, "HttpOnly=true") + } + if s.Secure && !isHTTPS { + reasons = append(reasons, "Secure=true (no HTTPS)") + } + if s.Path != "/" { + reasons = append(reasons, fmt.Sprintf("Path=%q", s.Path)) + } + if len(reasons) == 0 { + reasons = append(reasons, "unknown") + } + fmt.Printf(" MISSING %s %s\n", s.Name, strings.Join(reasons, ", ")) + continue + } + + isDerived := rc.Derived != nil + var diffs []string + + if rc.Value != s.Value { + diffs = append(diffs, fmt.Sprintf("Value: %q (expected %q)", rc.Value, s.Value)) + } + + // Domain: empty in Set-Cookie means host-only; browser fills from hostname + expectDomain := s.Domain + if expectDomain == "" { + expectDomain = hostname + } + if rc.Domain != expectDomain { + d := fmt.Sprintf("Domain: %q (expected %q)", rc.Domain, expectDomain) + if isDerived && rc.Derived.Domain { + d += " [derived from location]" + } + diffs = append(diffs, d) + } + + if rc.Path != s.Path { + d := fmt.Sprintf("Path: %q (expected %q)", rc.Path, s.Path) + if isDerived && rc.Derived.Path { + d += " [derived from location, actual path unknown]" + } + diffs = append(diffs, d) + } + + // Secure: Cookie Store API returns actual value; + // document.cookie cannot read it, inferred from protocol. + if rc.Secure != s.Secure { + d := fmt.Sprintf("Secure: %t (expected %t)", rc.Secure, s.Secure) + if isDerived && rc.Derived.Secure { + d += " [derived from protocol]" + } + diffs = append(diffs, d) + } + + // SameSite: browsers default to Lax when not set + sentSS := website.SameSiteString(s.SameSite) + expectSS := sentSS + if expectSS == "" { + expectSS = "Lax" // browser default + } + if rc.SameSite != "" && rc.SameSite != expectSS { + diffs = append(diffs, fmt.Sprintf("SameSite: %q (expected %q)", rc.SameSite, expectSS)) + } + + // Expires + if !s.Expires.IsZero() && rc.Expires != "" { + gotExp, err := time.Parse(website.ExpiresFormat, rc.Expires) + if err == nil { + diff := s.Expires.UTC().Sub(gotExp.UTC()) + if diff < -2*time.Second || diff > 2*time.Second { + diffs = append(diffs, fmt.Sprintf("Expires: %q (expected %q)", rc.Expires, s.Expires.UTC().Format(time.RFC3339))) + } + } + } + if s.Expires.IsZero() && rc.Expires != "" { + diffs = append(diffs, fmt.Sprintf("Expires: %q (expected session)", rc.Expires)) + } + + if len(diffs) == 0 { + nOK++ + continue + } + fmt.Printf(" DIFF %s\n", s.Name) + for _, d := range diffs { + fmt.Printf(" %s\n", d) + } + } + + // unexpected cookies from the browser + for _, rc := range report.Cookies { + found := false + for _, s := range sent { + if s.Name == rc.Name { + found = true + break + } + } + if !found { + fmt.Printf(" EXTRA %s\n", rc.Name) + } + } + + if nOK > 0 { + fmt.Printf(" OK %d cookie(s)\n", nOK) + } + fmt.Println(sep) +} + diff --git a/internal/testcmd/website/static/.gitignore b/internal/testcmd/website/static/.gitignore new file mode 100644 index 0000000..d6f0eaf --- /dev/null +++ b/internal/testcmd/website/static/.gitignore @@ -0,0 +1,2 @@ +main.wasm +wasm_exec.js diff --git a/internal/testcmd/website/static/index.html b/internal/testcmd/website/static/index.html new file mode 100644 index 0000000..ad518fb --- /dev/null +++ b/internal/testcmd/website/static/index.html @@ -0,0 +1,167 @@ + + + +
+ +