Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions browser/all/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
3 changes: 3 additions & 0 deletions browser/website/doc.go
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions browser/website/find.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}
64 changes: 64 additions & 0 deletions browser/website/website.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
26 changes: 26 additions & 0 deletions browser/website/website_other.go
Original file line number Diff line number Diff line change
@@ -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
}
77 changes: 69 additions & 8 deletions internal/firefox/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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
}
9 changes: 5 additions & 4 deletions internal/firefox/cookiestore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 15 additions & 11 deletions internal/firefox/firefox.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"

"github.com/browserutils/kooky"
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
17 changes: 15 additions & 2 deletions internal/firefox/sessionstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ var sessionStoreFiles = []string{
type SessionCookieStore struct {
cookies.DefaultCookieStore
Containers map[int]string
containersErr error
profileDir string
resolvedPath string
sessionCookies []sessionStoreCookie
Expand Down Expand Up @@ -69,6 +70,8 @@ func (s *SessionCookieStore) Open() error {
s.profileDir = s.FileNameStr
}

s.initSessionContainersMap()

s.resolvedPath = ``
s.sessionCookies = nil

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -183,5 +195,6 @@ type sessionStoreCookie struct {
}

type sessionStoreOriginAttribs struct {
UserContextID int `json:"userContextId"`
UserContextID int `json:"userContextId"`
PartitionKey string `json:"partitionKey"`
}
Loading