Document S3 auth hardening

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-16 10:15:26 +02:00
parent 0f9b461e8e
commit 654a505c0d
6 changed files with 233 additions and 4 deletions

View File

@@ -127,6 +127,9 @@ Required when `FS_AUTH_ENABLED=true`:
- `FS_ROOT_USER` and `FS_ROOT_PASSWORD` define initial credentials - `FS_ROOT_USER` and `FS_ROOT_PASSWORD` define initial credentials
- `ADMIN_API_ENABLED=true` enables `/_admin/v1/*` routes (bootstrap key only) - `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` Reference: `auth/README.md`
Additional docs: Additional docs:

60
api/aws_chunked_test.go Normal file
View File

@@ -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")
}
}

107
api/object_copy_test.go Normal file
View File

@@ -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(), "<CopyObjectResult") {
t.Fatalf("unexpected copy response body: %s", copyRec.Body.String())
}
getReq := httptest.NewRequest(http.MethodGet, "/test-bucket/copied/year=2026/file.parquet", nil)
getRec := httptest.NewRecorder()
handler.router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("unexpected get status after copy: got %d body=%s", getRec.Code, getRec.Body.String())
}
if got := getRec.Body.String(); got != "PAR1copy" {
t.Fatalf("unexpected copied body: got %q", got)
}
}

View File

@@ -94,9 +94,11 @@ For each non-health request:
6. Decrypt stored secret using master key. 6. Decrypt stored secret using master key.
7. Recompute canonical request and expected signature. 7. Recompute canonical request and expected signature.
8. Compare signatures. 8. Compare signatures.
9. Resolve target action from request. 9. Reject signed streaming payload modes that require per-chunk signature verification.
10. Evaluate policy; deny overrides allow. 10. Wrap fixed-size signed payloads so the actual body must match `x-amz-content-sha256`.
11. Store auth result in request context and continue. 11. Resolve target action from request.
12. Evaluate policy; deny overrides allow.
13. Store auth result in request context and continue.
## Authorization Semantics ## Authorization Semantics
Policy evaluator rules: Policy evaluator rules:
@@ -106,6 +108,9 @@ Policy evaluator rules:
- action: `*` or `s3:*` - action: `*` or `s3:*`
- bucket: `*` - bucket: `*`
- prefix: `*` - prefix: `*`
- Object actions apply `prefix` to the object key.
- `ListBucket` applies `prefix` to the requested list `prefix` query value; a scoped list policy such as `prefix=backups/` does not allow an empty-prefix or sibling-prefix bucket listing.
- Multi-object delete is authorized per object key after the XML body is parsed; denied keys are returned as per-key `AccessDenied` errors and are not deleted.
Action resolution includes: Action resolution includes:
- bucket APIs (`CreateBucket`, `ListBucket`, `HeadBucket`, `DeleteBucket`) - bucket APIs (`CreateBucket`, `ListBucket`, `HeadBucket`, `DeleteBucket`)
@@ -137,6 +142,7 @@ Each audit entry includes method, path, remote IP, and request ID (if present).
## Current Scope / Limitations ## Current Scope / Limitations
- No STS/session-token auth yet. - No STS/session-token auth yet.
- Signed aws-chunked streaming payloads are not accepted until per-chunk signature verification is implemented. Unsigned streaming payload modes can still be decoded by the API layer.
- Policy language is intentionally minimal, not full IAM. - Policy language is intentionally minimal, not full IAM.
- No automatic key rotation workflows. - No automatic key rotation workflows.
- No key rotation endpoint for existing users yet. - No key rotation endpoint for existing users yet.

50
auth/sigv4_test.go Normal file
View File

@@ -0,0 +1,50 @@
package auth
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestCanonicalPathEncodesEquals(t *testing.T) {
u := &url.URL{Path: "/test-bucket/jsp-data-raw/year=2026/month=03/day=12/vehicle_positions.parquet"}
got := canonicalPath(u)
want := "/test-bucket/jsp-data-raw/year%3D2026/month%3D03/day%3D12/vehicle_positions.parquet"
if got != want {
t.Fatalf("unexpected canonical path: got %q want %q", got, want)
}
}
func TestCanonicalPathPreservesExistingEscapes(t *testing.T) {
u, err := url.Parse("http://localhost:2600/test-bucket/jsp-data-raw/year%3d2026/file%2Eparquet")
if err != nil {
t.Fatalf("url.Parse failed: %v", err)
}
got := canonicalPath(u)
want := "/test-bucket/jsp-data-raw/year%3D2026/file%2Eparquet"
if got != want {
t.Fatalf("unexpected canonical path: got %q want %q", got, want)
}
}
func TestBuildCanonicalRequestUsesAwsEncodedPath(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://localhost:2600/test-bucket/jsp-data-raw/year=2026/month=03/day=12/vehicle_positions.parquet", nil)
req.Header.Set("x-amz-date", "20260313T120000Z")
req.Header.Set("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
canonical, err := buildCanonicalRequest(req, []string{"host", "x-amz-content-sha256", "x-amz-date"}, "UNSIGNED-PAYLOAD", false)
if err != nil {
t.Fatalf("buildCanonicalRequest failed: %v", err)
}
lines := strings.Split(canonical, "\n")
if len(lines) < 2 {
t.Fatalf("canonical request has unexpected format: %q", canonical)
}
wantPath := "/test-bucket/jsp-data-raw/year%3D2026/month%3D03/day%3D12/vehicle_positions.parquet"
if lines[1] != wantPath {
t.Fatalf("unexpected canonical path line: got %q want %q", lines[1], wantPath)
}
}

View File

@@ -32,12 +32,15 @@ This project is S3-compatible for a focused subset of operations.
### Authentication ### Authentication
- AWS SigV4 header auth - AWS SigV4 header auth
- AWS SigV4 presigned query auth - AWS SigV4 presigned query auth
- `aws-chunked` payload decode for streaming uploads - `aws-chunked` payload decode for unsigned streaming upload modes
- SigV4 payload hash verification for fixed-size signed payloads
## Partially Implemented / Differences ## Partially Implemented / Differences
- Exact parity with AWS S3 error codes/headers is still evolving. - Exact parity with AWS S3 error codes/headers is still evolving.
- Some S3 edge-case behaviors may differ (especially uncommon query/header combinations). - Some S3 edge-case behaviors may differ (especially uncommon query/header combinations).
- Admin API is custom JSON (`/_admin/v1/*`). - Admin API is custom JSON (`/_admin/v1/*`).
- Object and upload-part payloads are limited by `FS_MAX_OBJECT_UPLOAD_BYTES` (default 5 GiB).
- Signed `aws-chunked` payload modes that require per-chunk signature verification are rejected until chunk-signature validation is implemented.
## Not Implemented (Current) ## Not Implemented (Current)
- Bucket versioning - Bucket versioning