diff --git a/CHANGELOG.md b/CHANGELOG.md index e779d8f..500c119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ Two version streams move independently: ## [Unreleased] +### Fixed +- **s3cmd `get` compatibility**: intercepted `GetObject` responses now + carry a `Content-Length` header reflecting the decrypted body size. + Previously the proxy streamed decrypted objects without a length, so + Go fell back to chunked transfer encoding and s3cmd 2.4.0 downloaded + an empty file. Downloads now complete with the correct size. + ### Chart - **`chart/1.9.3`** — Dashboard usability + Go runtime panels. diff --git a/s3proxy/internal/router/object.go b/s3proxy/internal/router/object.go index d7be9d6..884af18 100644 --- a/s3proxy/internal/router/object.go +++ b/s3proxy/internal/router/object.go @@ -16,6 +16,7 @@ import ( "net/http" "net/url" "runtime/debug" + "strconv" "strings" "syscall" "time" @@ -157,6 +158,11 @@ func (o object) get(w http.ResponseWriter, r *http.Request) { releaseLargeBuffer(&plaintext) return default: + // The full plaintext is already buffered, so emit an explicit Content-Length + // (the upstream value describes the ciphertext, not the decrypted body) so the + // server uses identity encoding instead of chunked. Some clients (e.g. s3cmd) + // require a Content-Length header on the response. + w.Header().Set("Content-Length", strconv.Itoa(len(plaintext))) w.WriteHeader(http.StatusOK) _, writeErr := w.Write(plaintext) releaseLargeBuffer(&plaintext) diff --git a/s3proxy/internal/router/router_test.go b/s3proxy/internal/router/router_test.go index 2eae233..a445ceb 100644 --- a/s3proxy/internal/router/router_test.go +++ b/s3proxy/internal/router/router_test.go @@ -13,6 +13,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -198,6 +199,35 @@ func TestGetObjectUsesRouterKEK(t *testing.T) { assert.Equal(t, "secret payload", rec.Body.String()) } +func TestGetObjectSetsPlaintextContentLength(t *testing.T) { + keks := newTestKEKs(t, "expected encryption key") + version, curKEK := keks.Current() + client := newEncryptedGetObjectClient(t, curKEK, version, []byte("secret payload")) + router := Router{keks: keks, log: testLogger()} + req := httptest.NewRequest(http.MethodGet, "/bucket/key", nil) + rec := httptest.NewRecorder() + + router.getHandler(req, client, true, "key", "bucket").ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + // The header must reflect the plaintext length, not the +28-byte ciphertext. + assert.Equal(t, strconv.Itoa(len("secret payload")), rec.Result().Header.Get("Content-Length")) +} + +func TestGetObjectSetsZeroContentLengthForEmptyObject(t *testing.T) { + keks := newTestKEKs(t, "expected encryption key") + version, curKEK := keks.Current() + client := newEncryptedGetObjectClient(t, curKEK, version, []byte{}) + router := Router{keks: keks, log: testLogger()} + req := httptest.NewRequest(http.MethodGet, "/bucket/key", nil) + rec := httptest.NewRecorder() + + router.getHandler(req, client, true, "key", "bucket").ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "0", rec.Result().Header.Get("Content-Length")) +} + func TestGetObjectFailsWithWrongRouterKEK(t *testing.T) { storedKeks := newTestKEKs(t, "old encryption key") routerKeks := newTestKEKs(t, "new encryption key")