From 654a505c0d4e387be5f1d8ecf750a59b2a12b1bd Mon Sep 17 00:00:00 2001 From: Andrej Mickov Date: Sat, 16 May 2026 10:15:26 +0200 Subject: [PATCH] Document S3 auth hardening Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 3 ++ api/aws_chunked_test.go | 60 ++++++++++++++++++++++ api/object_copy_test.go | 107 +++++++++++++++++++++++++++++++++++++++ auth/README.md | 12 +++-- auth/sigv4_test.go | 50 ++++++++++++++++++ docs/s3-compatibility.md | 5 +- 6 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 api/aws_chunked_test.go create mode 100644 api/object_copy_test.go create mode 100644 auth/sigv4_test.go diff --git a/README.md b/README.md index 7e2a21c..05d9655 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,9 @@ Required when `FS_AUTH_ENABLED=true`: - `FS_ROOT_USER` and `FS_ROOT_PASSWORD` define initial credentials - `ADMIN_API_ENABLED=true` enables `/_admin/v1/*` routes (bootstrap key only) +Upload limits: +- `FS_MAX_OBJECT_UPLOAD_BYTES` limits object PUT payloads, multipart upload parts, and completed multipart object size (default 5 GiB). + Reference: `auth/README.md` Additional docs: diff --git a/api/aws_chunked_test.go b/api/aws_chunked_test.go new file mode 100644 index 0000000..870b830 --- /dev/null +++ b/api/aws_chunked_test.go @@ -0,0 +1,60 @@ +package api + +import ( + "io" + "net/http" + "strings" + "testing" +) + +func TestShouldDecodeAWSChunkedPayloadUnsignedTrailerMode(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodPut, "http://example.com/b/k", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER") + if !shouldDecodeAWSChunkedPayload(req) { + t.Fatalf("expected shouldDecodeAWSChunkedPayload to return true for STREAMING-UNSIGNED-PAYLOAD-TRAILER") + } +} + +func TestAWSChunkedReaderPassThroughForPlainPayload(t *testing.T) { + t.Parallel() + + plain := "PAR1\x00\x01\x02\x03binary-without-aws-chunked-header" + reader := newAWSChunkedDecodingReader(strings.NewReader(plain)) + defer reader.Close() + + out, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read failed: %v", err) + } + if string(out) != plain { + t.Fatalf("unexpected passthrough result: got %q want %q", string(out), plain) + } +} + +func TestAWSChunkedReaderDecodesChunkedPayload(t *testing.T) { + t.Parallel() + + encoded := "" + + "4\r\nWiki\r\n" + + "5\r\npedia\r\n" + + "0\r\n" + + "\r\n" + + "x-amz-checksum-crc32:xxxx\r\n" + + "\r\n" + + reader := newAWSChunkedDecodingReader(strings.NewReader(encoded)) + defer reader.Close() + + out, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read failed: %v", err) + } + if string(out) != "Wikipedia" { + t.Fatalf("decoded payload mismatch: got %q want %q", string(out), "Wikipedia") + } +} diff --git a/api/object_copy_test.go b/api/object_copy_test.go new file mode 100644 index 0000000..50e116e --- /dev/null +++ b/api/object_copy_test.go @@ -0,0 +1,107 @@ +package api + +import ( + "bytes" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "fs/logging" + "fs/metadata" + "fs/service" + "fs/storage" +) + +func newTestObjectHandler(t *testing.T) (*Handler, *service.ObjectService) { + t.Helper() + + root := t.TempDir() + md, err := metadata.NewMetadataHandler(filepath.Join(root, "metadata.db")) + if err != nil { + t.Fatalf("new metadata handler: %v", err) + } + blob, err := storage.NewBlobStore(root, 1024) + if err != nil { + t.Fatalf("new blob store: %v", err) + } + svc := service.NewObjectService(md, blob, time.Hour) + t.Cleanup(func() { + _ = svc.Close() + }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := NewHandler(svc, logger, logging.Config{}, nil, false) + handler.setupRoutes() + return handler, svc +} + +func TestPutObjectStoresDecodedKey(t *testing.T) { + handler, svc := newTestObjectHandler(t) + if err := svc.CreateBucket("test-bucket"); err != nil { + t.Fatalf("create bucket: %v", err) + } + + req := httptest.NewRequest(http.MethodPut, "/test-bucket/jsp-data-raw/vehicle_positions/year%3D2026/month%3D03/day%3D12/file.parquet", bytes.NewReader([]byte("PAR1data"))) + rec := httptest.NewRecorder() + handler.router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: got %d body=%s", rec.Code, rec.Body.String()) + } + + _, err := svc.HeadObject("test-bucket", "jsp-data-raw/vehicle_positions/year=2026/month=03/day=12/file.parquet") + if err != nil { + t.Fatalf("head decoded key: %v", err) + } + + getReq := httptest.NewRequest(http.MethodGet, "/test-bucket/jsp-data-raw/vehicle_positions/year=2026/month=03/day=12/file.parquet", nil) + getRec := httptest.NewRecorder() + handler.router.ServeHTTP(getRec, getReq) + if getRec.Code != http.StatusOK { + t.Fatalf("unexpected get status: got %d body=%s", getRec.Code, getRec.Body.String()) + } + if got := getRec.Body.String(); got != "PAR1data" { + t.Fatalf("unexpected get body: got %q", got) + } +} + +func TestCopyObjectCopiesCanonicalObject(t *testing.T) { + handler, svc := newTestObjectHandler(t) + if err := svc.CreateBucket("test-bucket"); err != nil { + t.Fatalf("create bucket: %v", err) + } + + putReq := httptest.NewRequest(http.MethodPut, "/test-bucket/source/year%3D2026/file.parquet", bytes.NewReader([]byte("PAR1copy"))) + putRec := httptest.NewRecorder() + handler.router.ServeHTTP(putRec, putReq) + if putRec.Code != http.StatusOK { + t.Fatalf("unexpected put status: got %d body=%s", putRec.Code, putRec.Body.String()) + } + + copyReq := httptest.NewRequest(http.MethodPut, "/test-bucket/copied/year=2026/file.parquet", http.NoBody) + copyReq.Header.Set("x-amz-copy-source", "/test-bucket/source/year%3D2026/file.parquet") + copyRec := httptest.NewRecorder() + handler.router.ServeHTTP(copyRec, copyReq) + + if copyRec.Code != http.StatusOK { + t.Fatalf("unexpected copy status: got %d body=%s", copyRec.Code, copyRec.Body.String()) + } + if !strings.Contains(copyRec.Body.String(), "