carlos / platform Public

Can a granted store bucket carry a CORS policy? Browser-direct upload depends on it Feature Request open

carlos store grant delivers CARLOS_STORE_*, and rastrillo's blobs package binds it - Put/Get/Delete against the delivered endpoint, with presigned GET/PUT minted locally since the platform ships no signing service. That part works today.

rastrillo/rastrillo#11 (the object-storage virus-scanning design) states the model plainly: CARLOS is not in the upload path, apps talk to their bucket directly with the delivered credentials, and the platform never sees the PUT. Browser-direct upload is therefore the intended shape - which makes one thing load-bearing that cannot be determined from the app side.

Questions

  1. Can an app set a CORS policy on its granted bucket? A browser PUT straight to the bucket via a presigned URL needs Access-Control-Allow-Origin, plus the allowed methods and headers, on the bucket itself. Without it the browser refuses the request before sending it, PresignPut cannot be used from a page at all, and every upload has to stream through the instance instead. Do the delivered credentials carry s3:PutBucketCors? Is there a carlos store verb for it? Or is the policy fixed by the platform, and if so, what is in it?
  1. If uploads must proxy, what are the ingress limits? Maximum request body size, and any request-duration cap at the edge. The scheduled-work contract holds an instance awake while a request is open, but a slow multi-megabyte upload is a different shape from a tick, and an app author should know the ceiling before designing around it.
  1. Is presigned-URL support on the S3 bridge exercised for PUT, or only for GET? rastrillo signs both locally with its own SigV4 implementation, so a bridge that only ever sees presigned GETs would not have surfaced a PUT incompatibility yet.

Asking because the answer decides which upload design rastrillo can offer as its default: presign-and-get-out-of-the-way, or proxy-through-handler. The companion discussion is rastrillo/rastrillo#12.

Answers, read off the code rather than from memory. Short version: browser-direct upload does not work today, and the design rastrillo can offer as its default is proxy-through-handler. The gap is real but small, and closing it is a decision about tenant authority that I have not made for you — the two shapes are at the bottom.

1. Can an app set a CORS policy on its granted bucket? No.

carlos store grant mints exactly one inline policy, and this is the whole of it (internal/objstore/aws.go:117-133, storePolicyJSON):

s3:ListBucket on <bucket> s3:GetObject, s3:PutObject, s3:DeleteObject on <bucket>/*

That is the complete action list. No s3:PutBucketCors, no s3:GetBucketCors. It is the only place in the tree that builds a store-grant policy document.

And the platform does not set one either. EnsureBucket (internal/objstore/aws.go:172) calls s3.CreateBucket and nothing else — no PutBucketCors anywhere in objstore, no carlos store sub-verb for it, no console door, and nothing in infra/. A case-insensitive sweep for cors across the repo returns only HTTP-preflight comments in the console and Vary: Origin handling in the edge cache. Unrelated.

So a granted bucket carries no CORS configuration at all, and the credential cannot add one. A browser PUT to a presigned URL fails preflight before it sends a byte, exactly as you suspected. PresignPut is unusable from a page today.

2. If uploads proxy, what are the ingress limits?

Better than I expected, and the answer is "no ceiling of ours, but one edge case".

  • Body size: no cap on the app path. Edge.ServeHTTP hands the request to the proxy/activator with no http.MaxBytesReader anywhere in that path. The 32 MB maxStoreBodyBytes (internal/edge/storedoor.go:47) is the fleet-box deployment-store door, a different path, and does not apply to your instance.
  • Duration: no cap either, deliberately. Both servers set only ReadHeaderTimeout: 10s and no ReadTimeout/WriteTimeout — the comment at internal/edge/edge.go:2334 says why: those would break long-lived proxied WebSockets. Once the headers are read, the edge does not time your body out.
  • An open request does hold the instance awake, and the hibernation sweep cannot take it. The idle sweep requires e.inflight == 0 before it will hibernate (internal/activator/activator.go:973), and lastActive is stamped when the request finishes. So the slow-upload shape is safe in the way the scheduled-work contract is.
  • The one thing that can cut a long upload: build convergence. When a route's live symlink moves, the activator cycles the instance even with a request in flight (internal/activator/activator.go:1002), draining for at most 20s (actStopTimeout). A multi-minute upload crossing a deploy is cut exactly as a restart would cut it. Worth designing for — resumable or chunked, or accept that a deploy can interrupt an in-progress upload.

3. Presigned PUT on the S3 bridge: not exercised, either verb.

The platform never presigns. Every hit for presign in the tree is a comment saying so deliberately — internal/edge/storedoor.go:58 records it as an F8 design decision to stream bytes through the edge rather than redirect to a presigned URL. So nothing in this repo has ever put a presigned GET or PUT through the bridge, and a PUT-specific incompatibility would not have surfaced here. rastrillo's local SigV4 signing is, as far as this repo is concerned, untested against the bridge in both directions.

What would close it — Paul's call, not mine

Two shapes, and they differ in who holds the authority rather than in difficulty:

  1. Widen the grant: add s3:PutBucketCors/s3:GetBucketCors to storePolicyJSON. The app then manages its own bucket's CORS with the credential it already holds. It is three words of diff. It also means an app can open its own bucket to any origin, which is within its own tenancy but is a real widening of what a store grant means.
  2. A platform verb: carlos store cors --app <a> --origin https://..., the platform writing the configuration and the credential staying exactly as narrow as it is. More code, one more door to keep in step, and the allowed origins become a thing the platform can see and audit.

I lean to (2), because the grant is the security boundary and every other widening of it has been a deliberate operator act rather than a capability an app grants itself — but (1) is defensible and much smaller, and the difference only matters if a compromised app credential turning its own bucket world-readable-from-any-origin is a threat worth pricing.

Leaving this open on that decision. Say which and I will build it.

Triaged 2026-09-02 as part of a sweep of the open list; investigation is read-only, no code changed.

Triage, 2026-09-05. No code changed. Recommending option 2, with a sequencing point that was not visible when this was written.

The recommendation: carlos store cors --app <a> --origin https://..., with the grant staying exactly as narrow as it is. The argument in the answer above is the right one — the store grant is the security boundary, and every widening of it so far has been a deliberate operator act rather than a capability an app hands itself. Adding s3:PutBucketCors would be three words of diff and would also let a compromised app credential open its own bucket to any origin, which is a capability nobody asked for and nobody would see.

The sequencing point. storePolicyJSON is being actively rewritten right now on worktree-store-scan-on-demand — discussions #18 and #19 — and the rewrite is adding Deny statements to the same document, including a conditional Deny on s3:PutObject keyed on request tag keys. Adding an Allow to that policy in a separate pass, while Denys are landing in another, is how a policy document ends up with two authors and a gap between them. #18 is precisely the story of a policy property that was correctly enforced against the wrong shape.

So: build the CORS verb after the scan branch lands, not beside it, and have whoever writes it read the finished policy rather than today's. That also means the CORS verb gets the benefit of the contract test that branch adds — a real MinIO, handed the policy verbatim — which is the only reason #18's second door was found before a deployment rather than after one.

Nothing else in the answer needs revisiting: the ingress limits stand (no body cap on the app path, no duration cap, an open request genuinely holds the instance awake, and the one thing that can cut a long upload is a build convergence cycling the instance mid-flight with a 20s drain). Rastrillo should design for that last one — resumable or chunked — regardless of which CORS shape wins, because it is true today and will still be true after.

Blocked on your call between (1) and (2). Everything else is decided.