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

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