feat: add Safari cookie extraction from BinaryCookies format (#566)

* feat: add Safari cookie extraction from BinaryCookies format
* fix: use expiry presence instead of current time for HasExpire
This commit is contained in:
Roger
2026-04-12 01:16:59 +08:00
committed by GitHub
parent 509cdc2468
commit 7bf1759dd9
7 changed files with 284 additions and 4 deletions
+69
View File
@@ -0,0 +1,69 @@
package safari
import (
"fmt"
"os"
"sort"
"github.com/moond4rk/binarycookies"
"github.com/moond4rk/hackbrowserdata/types"
)
func extractCookies(path string) ([]types.CookieEntry, error) {
pages, err := decodeBinaryCookies(path)
if err != nil {
return nil, err
}
var cookies []types.CookieEntry
for _, page := range pages {
for _, c := range page.Cookies {
hasExpire := !c.Expires.IsZero()
cookies = append(cookies, types.CookieEntry{
Host: string(c.Domain),
Path: string(c.Path),
Name: string(c.Name),
Value: string(c.Value),
IsSecure: c.Secure,
IsHTTPOnly: c.HTTPOnly,
HasExpire: hasExpire,
IsPersistent: hasExpire,
ExpireAt: c.Expires,
CreatedAt: c.Creation,
})
}
}
sort.Slice(cookies, func(i, j int) bool {
return cookies[i].CreatedAt.After(cookies[j].CreatedAt)
})
return cookies, nil
}
func countCookies(path string) (int, error) {
pages, err := decodeBinaryCookies(path)
if err != nil {
return 0, err
}
var total int
for _, page := range pages {
total += len(page.Cookies)
}
return total, nil
}
func decodeBinaryCookies(path string) ([]binarycookies.Page, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open cookies file: %w", err)
}
defer f.Close()
jar := binarycookies.New(f)
pages, err := jar.Decode()
if err != nil {
return nil, fmt.Errorf("decode cookies: %w", err)
}
return pages, nil
}
+174
View File
@@ -0,0 +1,174 @@
package safari
import (
"encoding/binary"
"math"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// buildTestBinaryCookies constructs a minimal valid Cookies.binarycookies file
// containing the given cookies. Each cookie is placed in its own page.
func buildTestBinaryCookies(t *testing.T, cookies []testCookie) string {
t.Helper()
var pages [][]byte
for _, c := range cookies {
pages = append(pages, buildPage(c))
}
path := filepath.Join(t.TempDir(), "Cookies.binarycookies")
f, err := os.Create(path)
require.NoError(t, err)
defer f.Close()
// File header: magic + numPages (big-endian)
_, err = f.WriteString("cook")
require.NoError(t, err)
require.NoError(t, binary.Write(f, binary.BigEndian, uint32(len(pages))))
// Page sizes (big-endian)
for _, p := range pages {
require.NoError(t, binary.Write(f, binary.BigEndian, uint32(len(p))))
}
// Page data
for _, p := range pages {
_, err = f.Write(p)
require.NoError(t, err)
}
// Checksum (8 bytes, not validated by decoder)
_, err = f.Write(make([]byte, 8))
require.NoError(t, err)
return path
}
type testCookie struct {
domain, name, path, value string
secure, httpOnly bool
expires, creation float64 // Core Data epoch seconds
}
func buildPage(c testCookie) []byte {
// Cookie string data: domain\0 name\0 path\0 value\0
domain := c.domain + "\x00"
name := c.name + "\x00"
cpath := c.path + "\x00"
value := c.value + "\x00"
// Cookie binary layout (all offsets are from cookie start):
// size(4) + unknown1(4) + flags(4) + unknown2(4)
// + domainOff(4) + nameOff(4) + pathOff(4) + valueOff(4) + commentOff(4)
// + endHeader(4) + expires(8) + creation(8)
// = 56 bytes header, then string data
const headerSize = 56
domainOffset := uint32(headerSize)
nameOffset := domainOffset + uint32(len(domain))
pathOffset := nameOffset + uint32(len(name))
valueOffset := pathOffset + uint32(len(cpath))
cookieSize := valueOffset + uint32(len(value))
var flags uint32
switch {
case c.secure && c.httpOnly:
flags = 0x5
case c.httpOnly:
flags = 0x4
case c.secure:
flags = 0x1
}
// Build cookie bytes (little-endian)
cookie := make([]byte, cookieSize)
binary.LittleEndian.PutUint32(cookie[0:], cookieSize)
// cookie[4:8] = unknown1 (zero)
binary.LittleEndian.PutUint32(cookie[8:], flags)
// cookie[12:16] = unknown2 (zero)
binary.LittleEndian.PutUint32(cookie[16:], domainOffset)
binary.LittleEndian.PutUint32(cookie[20:], nameOffset)
binary.LittleEndian.PutUint32(cookie[24:], pathOffset)
binary.LittleEndian.PutUint32(cookie[28:], valueOffset)
// cookie[32:36] = commentOffset (zero = no comment)
// cookie[36:40] = endHeader marker (zero)
binary.LittleEndian.PutUint64(cookie[40:], math.Float64bits(c.expires))
binary.LittleEndian.PutUint64(cookie[48:], math.Float64bits(c.creation))
copy(cookie[domainOffset:], domain)
copy(cookie[nameOffset:], name)
copy(cookie[pathOffset:], cpath)
copy(cookie[valueOffset:], value)
// Page layout: marker(4) + cookieCount(4) + offsets(4*N) + endMarker(4) + cookies
const pageHeaderSize = 16 // marker + count + 1 offset + end marker
page := make([]byte, pageHeaderSize+len(cookie))
copy(page[0:4], []byte{0x00, 0x00, 0x01, 0x00}) // page start marker
binary.LittleEndian.PutUint32(page[4:], 1) // 1 cookie
binary.LittleEndian.PutUint32(page[8:], pageHeaderSize)
// page[12:16] = page end marker (zero)
copy(page[pageHeaderSize:], cookie)
return page
}
func TestExtractCookies(t *testing.T) {
path := buildTestBinaryCookies(t, []testCookie{
{
domain: ".example.com", name: "session", path: "/", value: "abc123",
secure: true, httpOnly: true,
expires: 2000000000.0, creation: 700000000.0,
},
{
domain: ".go.dev", name: "lang", path: "/", value: "en",
secure: false, httpOnly: false,
expires: 2000000000.0, creation: 750000000.0,
},
})
cookies, err := extractCookies(path)
require.NoError(t, err)
require.Len(t, cookies, 2)
// Sorted by CreatedAt descending (newest first)
assert.Equal(t, ".go.dev", cookies[0].Host)
assert.Equal(t, ".example.com", cookies[1].Host)
// Verify field mapping
c := cookies[1] // .example.com cookie
assert.Equal(t, "session", c.Name)
assert.Equal(t, "abc123", c.Value)
assert.Equal(t, "/", c.Path)
assert.True(t, c.IsSecure)
assert.True(t, c.IsHTTPOnly)
assert.False(t, c.CreatedAt.IsZero())
assert.False(t, c.ExpireAt.IsZero())
}
func TestCountCookies(t *testing.T) {
path := buildTestBinaryCookies(t, []testCookie{
{domain: ".a.com", name: "a", path: "/", value: "1", expires: 2000000000.0, creation: 700000000.0},
{domain: ".b.com", name: "b", path: "/", value: "2", expires: 2000000000.0, creation: 700000000.0},
{domain: ".c.com", name: "c", path: "/", value: "3", expires: 2000000000.0, creation: 700000000.0},
})
count, err := countCookies(path)
require.NoError(t, err)
assert.Equal(t, 3, count)
}
func TestExtractCookies_InvalidFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "bad.binarycookies")
require.NoError(t, os.WriteFile(path, []byte("not a cookies file"), 0o644))
_, err := extractCookies(path)
assert.Error(t, err)
}
func TestExtractCookies_FileNotFound(t *testing.T) {
_, err := extractCookies("/nonexistent/Cookies.binarycookies")
assert.Error(t, err)
}
+4
View File
@@ -108,6 +108,8 @@ func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, p
switch cat {
case types.History:
data.Histories, err = extractHistories(path)
case types.Cookie:
data.Cookies, err = extractCookies(path)
default:
return
}
@@ -123,6 +125,8 @@ func (b *Browser) countCategory(cat types.Category, path string) int {
switch cat {
case types.History:
count, err = countHistories(path)
case types.Cookie:
count, err = countCookies(path)
default:
// Unsupported categories silently return 0.
}
+27 -3
View File
@@ -133,9 +133,17 @@ func TestCountCategory(t *testing.T) {
assert.Equal(t, 1, b.countCategory(types.History, path))
})
t.Run("Cookie", func(t *testing.T) {
path := buildTestBinaryCookies(t, []testCookie{
{domain: ".example.com", name: "a", path: "/", value: "1", expires: 2000000000.0, creation: 700000000.0},
{domain: ".go.dev", name: "b", path: "/", value: "2", expires: 2000000000.0, creation: 700000000.0},
})
b := &Browser{}
assert.Equal(t, 2, b.countCategory(types.Cookie, path))
})
t.Run("UnsupportedCategory", func(t *testing.T) {
b := &Browser{}
assert.Equal(t, 0, b.countCategory(types.Cookie, "unused"))
assert.Equal(t, 0, b.countCategory(types.CreditCard, "unused"))
assert.Equal(t, 0, b.countCategory(types.SessionStorage, "unused"))
})
@@ -160,12 +168,28 @@ func TestExtractCategory(t *testing.T) {
assert.Equal(t, 1, data.Histories[1].VisitCount)
})
t.Run("Cookie", func(t *testing.T) {
path := buildTestBinaryCookies(t, []testCookie{
{
domain: ".example.com", name: "session", path: "/", value: "abc",
secure: true, httpOnly: true, expires: 2000000000.0, creation: 700000000.0,
},
})
b := &Browser{}
data := &types.BrowserData{}
b.extractCategory(data, types.Cookie, path)
require.Len(t, data.Cookies, 1)
assert.Equal(t, ".example.com", data.Cookies[0].Host)
assert.Equal(t, "session", data.Cookies[0].Name)
assert.True(t, data.Cookies[0].IsSecure)
assert.True(t, data.Cookies[0].IsHTTPOnly)
})
t.Run("UnsupportedCategory", func(t *testing.T) {
b := &Browser{}
data := &types.BrowserData{}
b.extractCategory(data, types.Cookie, "unused")
b.extractCategory(data, types.CreditCard, "unused")
assert.Empty(t, data.Cookies)
assert.Empty(t, data.CreditCards)
})
}
+7 -1
View File
@@ -13,11 +13,17 @@ type sourcePath struct {
isDir bool // true for directory targets
}
func file(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel), isDir: false} }
func file(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel)} }
// safariSources defines the Safari file layout.
// Each category maps to one or more candidate paths tried in priority order;
// the first existing path wins.
var safariSources = map[types.Category][]sourcePath{
types.History: {file("History.db")},
types.Cookie: {
// macOS 14+ (containerized Safari)
file("../Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies"),
// macOS ≤13 (traditional path)
file("../Cookies/Cookies.binarycookies"),
},
}