mirror of
https://github.com/ferdzo/fs.git
synced 2026-06-04 02:16:46 +00:00
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:
@@ -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:
|
||||
|
||||
60
api/aws_chunked_test.go
Normal file
60
api/aws_chunked_test.go
Normal 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
107
api/object_copy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -94,9 +94,11 @@ For each non-health request:
|
||||
6. Decrypt stored secret using master key.
|
||||
7. Recompute canonical request and expected signature.
|
||||
8. Compare signatures.
|
||||
9. Resolve target action from request.
|
||||
10. Evaluate policy; deny overrides allow.
|
||||
11. Store auth result in request context and continue.
|
||||
9. Reject signed streaming payload modes that require per-chunk signature verification.
|
||||
10. Wrap fixed-size signed payloads so the actual body must match `x-amz-content-sha256`.
|
||||
11. Resolve target action from request.
|
||||
12. Evaluate policy; deny overrides allow.
|
||||
13. Store auth result in request context and continue.
|
||||
|
||||
## Authorization Semantics
|
||||
Policy evaluator rules:
|
||||
@@ -106,6 +108,9 @@ Policy evaluator rules:
|
||||
- action: `*` or `s3:*`
|
||||
- bucket: `*`
|
||||
- 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:
|
||||
- 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
|
||||
- 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.
|
||||
- No automatic key rotation workflows.
|
||||
- No key rotation endpoint for existing users yet.
|
||||
|
||||
50
auth/sigv4_test.go
Normal file
50
auth/sigv4_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -32,12 +32,15 @@ This project is S3-compatible for a focused subset of operations.
|
||||
### Authentication
|
||||
- AWS SigV4 header 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
|
||||
- Exact parity with AWS S3 error codes/headers is still evolving.
|
||||
- Some S3 edge-case behaviors may differ (especially uncommon query/header combinations).
|
||||
- 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)
|
||||
- Bucket versioning
|
||||
|
||||
Reference in New Issue
Block a user