Skip to content
Merged
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
6 changes: 4 additions & 2 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ type MessageDetails struct {


func NewWAClient(storeDir string) (*WAClient, error) {
// Create store directory
if err := os.MkdirAll(storeDir, 0755); err != nil {
// 0700: this directory holds whatsapp.db, which contains the linked-device
// session keys. Anyone who can read it can impersonate the account on
// WhatsApp, so it must not be world-readable.
if err := os.MkdirAll(storeDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create store directory: %v", err)
}

Expand Down
6 changes: 5 additions & 1 deletion internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,11 @@ func (a *App) downloadMediaAndPersist(ctx context.Context, info store.MessageDow
if err != nil {
return "", 0, time.Time{}, err
}
if err := os.MkdirAll(filepath.Dir(finalPath), 0755); err != nil {
// 0700 to match the store: by default this resolves under store/media, and
// downloaded media is as private as the messages that carried it. The files
// themselves are already created 0600 by CreateTemp+Rename; without this the
// directory was the only part left world-readable.
if err := os.MkdirAll(filepath.Dir(finalPath), 0700); err != nil {
return "", 0, time.Time{}, fmt.Errorf("failed to create destination directory: %w", err)
}

Expand Down
5 changes: 3 additions & 2 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,10 @@ type ListChatsParams struct {
}

func NewMessageStore(dbPath string) (*MessageStore, error) {
// Create directory if it doesn't exist
// 0700: messages.db holds the full plaintext message archive — contents,
// contact names and phone numbers. Not world-readable.
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("failed to create directory: %v", err)
}

Expand Down
18 changes: 18 additions & 0 deletions internal/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ func TestNewMessageStore(t *testing.T) {
assert.NoError(t, err)
}

// messages.db holds the full plaintext archive — message bodies, contact names
// and phone numbers. The directory holding it must not be readable by other
// local users.
func TestNewMessageStoreCreatesPrivateDirectory(t *testing.T) {
tmpDir := t.TempDir()
storeDir := filepath.Join(tmpDir, "store")
dbPath := filepath.Join(storeDir, "messages.db")

store, err := NewMessageStore(dbPath)
require.NoError(t, err)
defer store.Close()

info, err := os.Stat(storeDir)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0700), info.Mode().Perm(),
"store directory must be owner-only; it holds the plaintext message archive")
}

func TestStoreChat(t *testing.T) {
store := setupTestDB(t)

Expand Down
28 changes: 28 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,29 @@ func extractGlobalFlags(args []string) (string, []string) {
return storeDir, remaining
}

// checkStorePresence rejects commands that would otherwise silently create a
// second, empty store.
//
// --store defaults to "./store", resolved against the CURRENT WORKING
// DIRECTORY, and the store/client constructors create it unconditionally. So
// running any command from the wrong directory creates an empty store rather
// than reporting the mistake — and every later command run from there reads
// that empty store and reports zero messages, which is indistinguishable from
// data loss. An `auth` against such a directory is worse still: it leaves real
// session keys somewhere nobody is tracking.
//
// `auth` is exempt because first-time setup legitimately has nothing to point
// at yet.
func checkStorePresence(command, absStoreDir string) error {
if command == "auth" {
return nil
}
if _, err := os.Stat(absStoreDir); os.IsNotExist(err) {
return fmt.Errorf("store does not exist: %s (run 'auth' to create it, or pass --store)", absStoreDir)
}
return nil
}

func exitJSON(msg string) {
fmt.Fprintf(os.Stderr, `{"success":false,"data":null,"error":"%s"}`+"\n", msg)
os.Exit(1)
Expand Down Expand Up @@ -130,6 +153,11 @@ func main() {
fmt.Fprintf(os.Stderr, `{"success":false,"data":null,"error":"invalid store path: %v"}`+"\n", err)
os.Exit(1)
}

if err := checkStorePresence(command, absStoreDir); err != nil {
exitJSON(err.Error())
}

app, err := commands.NewApp(absStoreDir, version)
if err != nil {
fmt.Fprintf(os.Stderr, `{"success":false,"data":null,"error":"Failed to initialize: %v"}
Expand Down
72 changes: 72 additions & 0 deletions store_presence_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package main

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
)

// A missing store directory must be reported, not created. `--store` defaults to
// a CWD-relative "./store", so without this guard running any command from the
// wrong directory produces an empty store that reads as zero messages —
// indistinguishable from data loss.
func TestCheckStorePresence(t *testing.T) {
existing := t.TempDir()
missing := filepath.Join(existing, "does-not-exist")

tests := []struct {
name string
command string
storeDir string
wantError bool
}{
{
name: "read command against a missing store is rejected",
command: "chats",
storeDir: missing,
wantError: true,
},
{
name: "sync against a missing store is rejected",
command: "sync",
storeDir: missing,
wantError: true,
},
{
name: "auth may create a store that does not exist yet",
command: "auth",
storeDir: missing,
wantError: false,
},
{
name: "read command against an existing store is allowed",
command: "chats",
storeDir: existing,
wantError: false,
},
{
name: "auth against an existing store is allowed",
command: "auth",
storeDir: existing,
wantError: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := checkStorePresence(tt.command, tt.storeDir)
if tt.wantError {
require.Error(t, err)
require.Contains(t, err.Error(), "store does not exist")
} else {
require.NoError(t, err)
}
})
}

// The guard must not have created anything as a side effect.
_, err := os.Stat(missing)
require.True(t, os.IsNotExist(err), "checkStorePresence must not create the store directory")
}