Skip to content

Commit a404997

Browse files
committed
improve security
1 parent bfc0a1f commit a404997

7 files changed

Lines changed: 103 additions & 13 deletions

File tree

internal/cmd/init.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ var (
3535
initFriends []string
3636
)
3737

38+
const (
39+
// MaxNameLength is the maximum allowed length for friend names
40+
MaxNameLength = 200
41+
// MaxEmailLength is the maximum allowed length for email addresses
42+
MaxEmailLength = 320 // RFC 5321 maximum
43+
// MaxPhoneLength is the maximum allowed length for phone numbers
44+
MaxPhoneLength = 50
45+
)
46+
3847
func init() {
3948
rootCmd.AddCommand(initCmd)
4049
initCmd.Flags().StringVar(&initFrom, "from", "", "Base new project on existing project (copies friends)")
@@ -155,6 +164,9 @@ func runInit(cmd *cobra.Command, args []string) error {
155164
if nameStr == "" {
156165
return fmt.Errorf("name is required")
157166
}
167+
if len(nameStr) > MaxNameLength {
168+
return fmt.Errorf("name too long (max %d characters)", MaxNameLength)
169+
}
158170
friends[i].Name = nameStr
159171

160172
fmt.Print(" Email: ")
@@ -163,11 +175,18 @@ func runInit(cmd *cobra.Command, args []string) error {
163175
if emailStr == "" {
164176
return fmt.Errorf("email is required")
165177
}
178+
if len(emailStr) > MaxEmailLength {
179+
return fmt.Errorf("email too long (max %d characters)", MaxEmailLength)
180+
}
166181
friends[i].Email = emailStr
167182

168183
fmt.Print(" Phone (optional): ")
169184
phoneStr, _ := reader.ReadString('\n')
170-
friends[i].Phone = strings.TrimSpace(phoneStr)
185+
phoneStr = strings.TrimSpace(phoneStr)
186+
if len(phoneStr) > MaxPhoneLength {
187+
return fmt.Errorf("phone too long (max %d characters)", MaxPhoneLength)
188+
}
189+
friends[i].Phone = phoneStr
171190

172191
fmt.Println()
173192
}
@@ -226,9 +245,18 @@ func parseFriendFlags(flags []string) ([]project.Friend, error) {
226245
if friends[i].Name == "" {
227246
return nil, fmt.Errorf("friend name cannot be empty")
228247
}
248+
if len(friends[i].Name) > MaxNameLength {
249+
return nil, fmt.Errorf("friend name too long (max %d characters)", MaxNameLength)
250+
}
229251
if friends[i].Email == "" {
230252
return nil, fmt.Errorf("friend email cannot be empty")
231253
}
254+
if len(friends[i].Email) > MaxEmailLength {
255+
return nil, fmt.Errorf("friend email too long (max %d characters)", MaxEmailLength)
256+
}
257+
if len(friends[i].Phone) > MaxPhoneLength {
258+
return nil, fmt.Errorf("friend phone too long (max %d characters)", MaxPhoneLength)
259+
}
232260
}
233261
return friends, nil
234262
}

internal/crypto/age.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
package crypto
22

33
import (
4+
"errors"
45
"fmt"
56
"io"
67

78
"filippo.io/age"
89
)
910

11+
// ErrEmptyPassphrase is returned when an empty passphrase is provided.
12+
var ErrEmptyPassphrase = errors.New("passphrase cannot be empty")
13+
1014
// Encrypt encrypts data using age with a passphrase (scrypt mode).
1115
// The passphrase is used to derive an encryption key using scrypt.
1216
func Encrypt(dst io.Writer, src io.Reader, passphrase string) error {
17+
if passphrase == "" {
18+
return ErrEmptyPassphrase
19+
}
1320
recipient, err := age.NewScryptRecipient(passphrase)
1421
if err != nil {
1522
return fmt.Errorf("creating recipient: %w", err)
@@ -33,6 +40,9 @@ func Encrypt(dst io.Writer, src io.Reader, passphrase string) error {
3340

3441
// Decrypt decrypts age-encrypted data using a passphrase.
3542
func Decrypt(dst io.Writer, src io.Reader, passphrase string) error {
43+
if passphrase == "" {
44+
return ErrEmptyPassphrase
45+
}
3646
identity, err := age.NewScryptIdentity(passphrase)
3747
if err != nil {
3848
return fmt.Errorf("creating identity: %w", err)

internal/crypto/hash.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package crypto
22

33
import (
44
"crypto/sha256"
5+
"crypto/subtle"
56
"encoding/hex"
67
"fmt"
78
"io"
@@ -37,6 +38,7 @@ func HashFile(path string) (string, error) {
3738
}
3839

3940
// VerifyHash checks if the given hash matches the expected value.
41+
// Uses constant-time comparison to prevent timing attacks.
4042
func VerifyHash(got, expected string) bool {
41-
return got == expected
43+
return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1
4244
}

internal/html/assets/app.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
elements.loadingOverlay.classList.add('hidden');
7272
return;
7373
} catch (e) {
74-
console.error('Embedded WASM failed:', e);
74+
// WASM initialization failed - will show error to user below
7575
}
7676
}
7777
showError(t('error', err.message));
@@ -146,7 +146,7 @@
146146
await parseAndAddShare(content, file.name);
147147
}
148148
} catch (err) {
149-
console.error('Error reading file:', err);
149+
showError(t('error', 'Failed to read file'));
150150
}
151151
}
152152
}
@@ -424,6 +424,16 @@
424424
a.download = 'manifest.tar.gz';
425425
a.click();
426426
URL.revokeObjectURL(url);
427+
428+
// Security: Clear sensitive data from memory after download
429+
clearSensitiveState();
430+
}
431+
432+
// Clear sensitive data from state to minimize memory exposure
433+
function clearSensitiveState() {
434+
state.decryptedArchive = null;
435+
state.manifest = null;
436+
// Note: shares contain metadata but not the actual secret
427437
}
428438

429439
// Utility functions
@@ -459,7 +469,7 @@
459469

460470
function showError(msg) {
461471
alert(msg); // Simple for now, could be a toast
462-
console.error(msg);
472+
// Note: Removed console.error to avoid logging sensitive information
463473
}
464474

465475
// Start

internal/manifest/archive.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import (
1010
"strings"
1111
)
1212

13+
const (
14+
// MaxFileSize is the maximum size of a single file during extraction (100 MB).
15+
MaxFileSize = 100 * 1024 * 1024
16+
// MaxTotalSize is the maximum total size of all extracted files (1 GB).
17+
MaxTotalSize = 1024 * 1024 * 1024
18+
)
19+
1320
// Archive creates a tar.gz archive of the given directory.
1421
// The archive preserves the directory structure relative to the source.
1522
func Archive(w io.Writer, sourceDir string) error {
@@ -104,6 +111,7 @@ func Extract(r io.Reader, destDir string) (string, error) {
104111

105112
tr := tar.NewReader(gzr)
106113
var rootDir string
114+
var totalSize int64
107115

108116
for {
109117
header, err := tr.Next()
@@ -134,6 +142,15 @@ func Extract(r io.Reader, destDir string) (string, error) {
134142
}
135143

136144
case tar.TypeReg:
145+
// Security: enforce file size limit
146+
if header.Size > MaxFileSize {
147+
return "", fmt.Errorf("file exceeds maximum size of %d bytes", MaxFileSize)
148+
}
149+
totalSize += header.Size
150+
if totalSize > MaxTotalSize {
151+
return "", fmt.Errorf("archive exceeds maximum total size of %d bytes", MaxTotalSize)
152+
}
153+
137154
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
138155
return "", fmt.Errorf("creating parent directory: %w", err)
139156
}
@@ -143,11 +160,16 @@ func Extract(r io.Reader, destDir string) (string, error) {
143160
return "", fmt.Errorf("creating file %s: %w", target, err)
144161
}
145162

146-
if _, err := io.Copy(f, tr); err != nil {
147-
f.Close()
163+
// Use LimitReader to enforce size limit during actual copy
164+
limitedReader := io.LimitReader(tr, MaxFileSize+1)
165+
written, err := io.Copy(f, limitedReader)
166+
f.Close()
167+
if err != nil {
148168
return "", fmt.Errorf("writing file %s: %w", target, err)
149169
}
150-
f.Close()
170+
if written > MaxFileSize {
171+
return "", fmt.Errorf("file exceeds maximum size during extraction")
172+
}
151173

152174
default:
153175
// Skip other types (symlinks, etc.) for security

internal/shamir/share.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,11 @@ func ParseShare(content []byte) (*Share, error) {
169169
}
170170

171171
// Verify checks that the share's checksum matches its data.
172+
// Uses constant-time comparison to prevent timing attacks.
172173
func (s *Share) Verify() error {
173174
computed := crypto.HashBytes(s.Data)
174-
if computed != s.Checksum {
175-
return fmt.Errorf("checksum mismatch: expected %s, got %s", s.Checksum, computed)
175+
if !crypto.VerifyHash(computed, s.Checksum) {
176+
return fmt.Errorf("share checksum verification failed")
176177
}
177178
return nil
178179
}

internal/wasm/recover.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ type ExtractedFile struct {
4646
const (
4747
shareBegin = "-----BEGIN REMEMORY SHARE-----"
4848
shareEnd = "-----END REMEMORY SHARE-----"
49+
50+
// MaxFileSize is the maximum size of a single file during extraction (100 MB).
51+
MaxFileSize = 100 * 1024 * 1024
52+
// MaxTotalSize is the maximum total size of all extracted files (1 GB).
53+
MaxTotalSize = 1024 * 1024 * 1024
4954
)
5055

5156
// parseShare extracts a share from text content (which might be a full README.txt).
@@ -206,6 +211,7 @@ func extractTarGz(tarGzData []byte) ([]ExtractedFile, error) {
206211

207212
tr := tar.NewReader(gzr)
208213
var files []ExtractedFile
214+
var totalSize int64
209215

210216
// Regex to detect path traversal
211217
pathTraversal := regexp.MustCompile(`(^|/)\.\.(/|$)`)
@@ -221,17 +227,28 @@ func extractTarGz(tarGzData []byte) ([]ExtractedFile, error) {
221227

222228
// Security: reject path traversal
223229
if pathTraversal.MatchString(header.Name) {
224-
return nil, fmt.Errorf("invalid path in archive: %s", header.Name)
230+
return nil, fmt.Errorf("archive contains invalid path")
225231
}
226232

227233
// Skip directories, only extract regular files
228234
if header.Typeflag != tar.TypeReg {
229235
continue
230236
}
231237

232-
data, err := io.ReadAll(tr)
238+
// Security: enforce file size limits
239+
if header.Size > MaxFileSize {
240+
return nil, fmt.Errorf("file exceeds maximum allowed size")
241+
}
242+
totalSize += header.Size
243+
if totalSize > MaxTotalSize {
244+
return nil, fmt.Errorf("archive exceeds maximum total size")
245+
}
246+
247+
// Use LimitReader for additional safety
248+
limitedReader := io.LimitReader(tr, MaxFileSize)
249+
data, err := io.ReadAll(limitedReader)
233250
if err != nil {
234-
return nil, fmt.Errorf("reading file %s: %w", header.Name, err)
251+
return nil, fmt.Errorf("failed to read file from archive")
235252
}
236253

237254
files = append(files, ExtractedFile{

0 commit comments

Comments
 (0)