mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-05-19 18:58:03 +02:00
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user