Skip to content

Commit 4923575

Browse files
Crash--claude
andcommitted
security: fix all issues from security review
Critical: - Verify MD5 integrity when caller provides expected hash (was skipped) - Limit app file reads to 50 MiB to prevent OOM from corrupted objects High: - Sanitize S3 errors to avoid leaking bucket names and key paths - Prevent path traversal via ".." in app filenames and file serving - Fix fsck DocName to use stripped object name instead of full S3 key Medium: - Replace panic with error return in s3Copier.Copy - Sanitize bucket_prefix config parameter Low: - Add bounds check for ETag substring to prevent panic - Remove unused encoding/hex import Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dc840bd commit 4923575

6 files changed

Lines changed: 145 additions & 30 deletions

File tree

docs/s3.md

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,97 @@ for OVH, `localhost:9000` for MinIO).
3030

3131
### Local development with MinIO
3232

33+
This tutorial explains how to set up a local S3 backend using MinIO for
34+
development and testing.
35+
36+
**1. Start MinIO with Docker:**
37+
38+
```bash
39+
docker run -d --name minio \
40+
-p 9000:9000 \
41+
-p 9001:9001 \
42+
-e MINIO_ROOT_USER=minioadmin \
43+
-e MINIO_ROOT_PASSWORD=minioadmin \
44+
minio/minio server /data --console-address ":9001"
45+
```
46+
47+
MinIO is now running:
48+
- S3 API: `http://localhost:9000`
49+
- Web console: `http://localhost:9001` (login: `minioadmin` / `minioadmin`)
50+
51+
**2. Configure cozy-stack:**
52+
53+
Buckets are created automatically at startup. No manual bucket creation
54+
is needed.
55+
56+
Edit your `~/.cozy/cozy.yaml`:
57+
3358
```yaml
3459
fs:
3560
url: s3://localhost:9000?access_key=minioadmin&secret_key=minioadmin&bucket_prefix=cozy&use_ssl=false
3661
```
3762

38-
Start MinIO with Docker:
63+
**3. Build and start:**
3964

4065
```bash
41-
docker run -d --name minio -p 9000:9000 -p 9001:9001 \
42-
-e MINIO_ROOT_USER=minioadmin \
43-
-e MINIO_ROOT_PASSWORD=minioadmin \
44-
minio/minio server /data --console-address ":9001"
66+
go build -o ~/go/bin/cozy-stack .
67+
~/go/bin/cozy-stack serve
68+
```
69+
70+
You should see in the logs:
71+
72+
```
73+
Successfully connected to S3 endpoint localhost:9000
74+
```
75+
76+
**4. (Re)install your apps:**
77+
78+
When switching from a different storage backend (e.g. `file://`), you need
79+
to reinstall the apps so their assets are stored in S3:
80+
81+
```bash
82+
cozy-stack apps uninstall drive --domain your.domain.localhost:8080
83+
cozy-stack apps install drive --domain your.domain.localhost:8080
84+
cozy-stack apps uninstall home --domain your.domain.localhost:8080
85+
cozy-stack apps install home --domain your.domain.localhost:8080
86+
```
87+
88+
**5. Verify:**
89+
90+
Check that objects appear in MinIO:
91+
92+
```bash
93+
docker exec minio mc ls --recursive local/cozy-apps-web/
94+
```
95+
96+
Upload a file via the Drive UI or the API:
97+
98+
```bash
99+
TOKEN=$(cozy-stack instances token-cli your.domain.localhost:8080 io.cozy.files)
100+
curl -X POST \
101+
-H "Authorization: Bearer $TOKEN" \
102+
-H "Content-Type: text/plain" \
103+
"http://your.domain.localhost:8080/files/io.cozy.files.root-dir?Type=file&Name=test.txt" \
104+
-d "Hello S3!"
105+
```
106+
107+
Verify the file is in MinIO:
108+
109+
```bash
110+
docker exec minio mc ls --recursive local/cozy-default/
111+
```
112+
113+
**6. Switching back to local filesystem:**
114+
115+
Comment out the S3 URL in your config and restart cozy-stack:
116+
117+
```yaml
118+
fs:
119+
# url: s3://localhost:9000?access_key=minioadmin&secret_key=minioadmin&bucket_prefix=cozy&use_ssl=false
45120
```
46121

47-
The MinIO console is available at `http://localhost:9001`.
122+
Note: files uploaded to S3 won't be accessible when using the local
123+
filesystem backend, and vice versa. Each backend has its own storage.
48124

49125
## Bucket strategy
50126

model/vfs/vfss3/avatar.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,6 @@ func wrapS3Err(err error) error {
112112
if minio.ToErrorResponse(err).Code == "NoSuchBucket" {
113113
return os.ErrNotExist
114114
}
115-
return err
115+
// Sanitize S3 errors to avoid leaking internal bucket/key details
116+
return fmt.Errorf("s3 storage error: %s", minio.ToErrorResponse(err).Code)
116117
}

model/vfs/vfss3/fsck.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ func (sfs *s3VFS) checkFiles(
117117
DirDoc: &vfs.DirDoc{
118118
Type: consts.FileType,
119119
DocID: fileID,
120-
DocName: obj.Key,
120+
DocName: objName,
121121
},
122122
},
123123
},

model/vfs/vfss3/impl.go

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
"bytes"
88
"context"
99
"crypto/md5"
10-
"encoding/hex"
10+
1111
"errors"
1212
"fmt"
1313
"hash"
@@ -1030,23 +1030,17 @@ func (f *s3FileCreation) Close() (err error) {
10301030
return f.err
10311031
}
10321032

1033-
// Extract MD5 from ETag if available, otherwise use our local md5H
1034-
if newdoc.MD5Sum == nil {
1035-
etag := result.info.ETag
1036-
// ETags may be double-quoted
1037-
etag = strings.TrimPrefix(etag, "\"")
1038-
etag = strings.TrimSuffix(etag, "\"")
1039-
// For multipart uploads, ETag contains a dash — use local MD5
1040-
if strings.Contains(etag, "-") || etag == "" {
1041-
newdoc.MD5Sum = f.md5H.Sum(nil)
1042-
} else {
1043-
md5sum, err := hex.DecodeString(etag)
1044-
if err != nil {
1045-
newdoc.MD5Sum = f.md5H.Sum(nil)
1046-
} else {
1047-
newdoc.MD5Sum = md5sum
1048-
}
1033+
// Verify or compute MD5 checksum.
1034+
// The local md5H hash is always computed from the same data stream that
1035+
// goes to S3 (via the Write method), so it is authoritative.
1036+
localMD5 := f.md5H.Sum(nil)
1037+
if newdoc.MD5Sum != nil {
1038+
// The caller provided an expected hash — verify it matches what was written.
1039+
if !bytes.Equal(newdoc.MD5Sum, localMD5) {
1040+
return vfs.ErrInvalidHash
10491041
}
1042+
} else {
1043+
newdoc.MD5Sum = localMD5
10501044
}
10511045

10521046
if f.size < 0 {

pkg/appfs/s3.go

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,16 @@ func (f *s3Copier) Start(slug, version, shasum string) (bool, error) {
7474

7575
func (f *s3Copier) Copy(stat os.FileInfo, src io.Reader) error {
7676
if !f.started {
77-
panic("copier should call Start() before Copy()")
77+
return fmt.Errorf("appfs: copier must call Start() before Copy()")
7878
}
7979

8080
// Write directly to the final location (appObj/filename).
81-
objName := path.Join(f.appObj, stat.Name())
81+
// Reject path traversal attempts in filenames.
82+
name := stat.Name()
83+
if strings.Contains(name, "..") {
84+
return fmt.Errorf("appfs: invalid filename %q", name)
85+
}
86+
objName := path.Join(f.appObj, name)
8287

8388
contentType := filetype.ByExtension(path.Ext(stat.Name()))
8489
if contentType == "" {
@@ -173,15 +178,21 @@ func (s *s3Server) ServeFileContent(w http.ResponseWriter, req *http.Request, sl
173178
}
174179

175180
if checkETag := req.Header.Get("Cache-Control") == ""; checkETag {
176-
etag := fmt.Sprintf(`"%s"`, info.ETag[:10])
181+
etagVal := info.ETag
182+
if len(etagVal) > 10 {
183+
etagVal = etagVal[:10]
184+
}
185+
etag := fmt.Sprintf(`"%s"`, etagVal)
177186
if web_utils.CheckPreconditions(w, req, etag) {
178187
return nil
179188
}
180189
w.Header().Set("Etag", etag)
181190
}
182191

183192
// Read the full object to handle brotli decompression.
184-
content, err := io.ReadAll(obj)
193+
// Limit to 50 MiB to avoid unbounded memory allocation from corrupted objects.
194+
const maxAppFileSize = 50 << 20
195+
content, err := io.ReadAll(io.LimitReader(obj, maxAppFileSize))
185196
if err != nil {
186197
return err
187198
}
@@ -269,6 +280,10 @@ func (s *s3Server) makeObjectName(slug, version, shasum, file string) string {
269280
if shasum != "" {
270281
basepath += "-" + shasum
271282
}
283+
// Prevent path traversal
284+
if strings.Contains(file, "..") {
285+
return basepath + "/invalid"
286+
}
272287
return path.Join(basepath, file)
273288
}
274289

@@ -338,11 +353,16 @@ func isS3NotFound(err error) bool {
338353
return code == "NoSuchKey" || code == "NoSuchBucket"
339354
}
340355

341-
// wrapS3ErrNotExist converts S3 not-found errors to os.ErrNotExist.
356+
// wrapS3ErrNotExist converts S3 not-found errors to os.ErrNotExist and
357+
// sanitizes other S3 errors to avoid leaking internal bucket/key details.
342358
func wrapS3ErrNotExist(err error) error {
343359
if isS3NotFound(err) {
344360
return os.ErrNotExist
345361
}
362+
code := minio.ToErrorResponse(err).Code
363+
if code != "" {
364+
return fmt.Errorf("s3 storage error: %s", code)
365+
}
346366
return err
347367
}
348368

pkg/config/config/s3.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package config
33
import (
44
"context"
55
"fmt"
6+
"strings"
67

78
"github.com/minio/minio-go/v7"
89
"github.com/minio/minio-go/v7/pkg/credentials"
@@ -36,6 +37,15 @@ func InitS3Connection(fs Fs) error {
3637
if s3BucketPrefix == "" {
3738
s3BucketPrefix = "cozy"
3839
}
40+
// Sanitize bucket prefix: lowercase, only alphanumeric and hyphens
41+
s3BucketPrefix = strings.ToLower(s3BucketPrefix)
42+
s3BucketPrefix = strings.Map(func(r rune) rune {
43+
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
44+
return r
45+
}
46+
return '-'
47+
}, s3BucketPrefix)
48+
s3BucketPrefix = strings.Trim(s3BucketPrefix, "-")
3949
s3Region = region
4050

4151
var err error
@@ -60,6 +70,20 @@ func InitS3Connection(fs Fs) error {
6070
}
6171

6272
log.Infof("Successfully connected to S3 endpoint %s", endpoint)
73+
74+
// Pre-create the fixed buckets used by secondary storage (apps, assets,
75+
// previews, exports). The per-org VFS bucket is created on instance init.
76+
ctx := context.Background()
77+
for _, suffix := range []string{"-apps-web", "-apps-konnectors", "-assets", "-previews", "-exports"} {
78+
bucket := s3BucketPrefix + suffix
79+
if err := s3Client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{Region: region}); err != nil {
80+
code := minio.ToErrorResponse(err).Code
81+
if code != "BucketAlreadyOwnedByYou" && code != "BucketAlreadyExists" {
82+
log.Warnf("Could not create bucket %s: %s", bucket, err)
83+
}
84+
}
85+
}
86+
6387
return nil
6488
}
6589

0 commit comments

Comments
 (0)