Skip to content

fix(storage): sign presigned URLs for the host browsers reach, so compose uploads work - #1402

Merged
iammukeshm merged 4 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/storage-presign-public-endpoint
Sep 28, 2026
Merged

iammukeshm merged 4 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/storage-presign-public-endpoint

Conversation

@marcelo-maciel

@marcelo-maciel marcelo-maciel commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1401.

Browsers upload and download files through presigned S3 URLs, and SigV4 binds each URL to the host of the client that signs it. S3StorageService signed with the client it uses for its own I/O, so every URL pointed at Storage:S3:ServiceUrl. In the Docker Compose stack that is http://rustfs:9000, which no browser can resolve, so file uploads from the admin and dashboard failed there.

What changes

  • Storage:S3:PresignServiceUrl (new, optional). It names the host that presigned PUT and GET URLs are signed for. A second IAmazonS3, registered as a keyed singleton (S3StorageService.PresignClientKey), is built by the same helper as the main client, so credentials, region and path style match. The presign protocol follows its scheme. Every other S3 call (put, delete, get, head) stays on ServiceUrl. When the setting is empty, both clients use ServiceUrl and the URLs are the same as before. Signing is offline. Without explicit keys, the only call the presign client makes is the one-time fetch of ambient credentials.
  • Startup validation. The value must be an absolute http(s) URL with no path, query or fragment. It is checked with ValidateOnStart, so a bad value fails at boot and not on the first upload. A path would be signed into every URL and break behind a proxy. The value is trimmed, because Uri.TryCreate accepts surrounding whitespace that the SDK's endpoint parser then rejects.
  • Compose stack.
    • RustFS publishes its S3 API on FSH_S3_PORT (default 9000); the console port stays unpublished.
    • RUSTFS_CORS_ALLOWED_ORIGINS allows FSH_ADMIN_URL and FSH_DASHBOARD_URL, the same way the AppHost does.
    • The API gets Storage__S3__PresignServiceUrl from a new required FSH_S3_PUBLIC_URL.
    • .env.example and the compose README cover a fourth proxy hostname, the Host header rule and a troubleshooting row. fsh new writes FSH_S3_PUBLIC_URL=http://localhost:9000 into the .env it generates.
  • FshWebApplicationFactory registers the keyed presign client next to the main one. .agents/rules/storage.md documents the setting.

Breaking for docker-compose users

Compose refuses to start until FSH_S3_PUBLIC_URL is set in deploy/docker/.env. Host port 9000 must be free, or FSH_S3_PORT must be set to another port. Behind a TLS proxy, the hostname in FSH_S3_PUBLIC_URL must reach that port with the Host header forwarded unchanged; otherwise the store answers 403 SignatureDoesNotMatch. Aspire and the AWS Terraform stack need no change. On AWS S3, or any store whose own endpoint browsers can reach, leave PresignServiceUrl empty. A custom test factory that re-registers the S3 stack must also register the keyed client, as FshWebApplicationFactory does.

This touches src/BuildingBlocks/Storage. The change there is additive: one option, one keyed registration, and the presign call moved to the new client.

Verification

  • Tests on this branch (e7acb495): Framework.Tests passed 257/257 and Integration.Tests passed 765/765 (Testcontainers, Docker).
    • S3PresignEndpointTests (13 cases) cover the endpoint fallback, the scheme, trimming and validation, including a URL with a path, a query and a padded value.
    • PresignServiceUrlTests runs against a real RustFS container. The API talks to it as 127.0.0.1, and presigning is configured for localhost. The test checks that the URL carries localhost and that the bytes round-trip through the presigned PUT and GET.
  • The tests can go red. Each mutant was applied to the source, the tests were run, and the file was restored byte-exact (sha256):
    • Presigning with the I/O client again (the pre-fix behaviour): Framework.Tests failed 5 of 257 and PresignServiceUrlTests failed.
    • Removing the trim, and relaxing the no-path check: Framework.Tests failed with 1 test each.
  • Real browser, compose stack. I walked the admin's Settings > Profile avatar upload with Playwright on Chrome, against a compose stack running this change with RustFS published on localhost:9000:
    • POST /files/upload-url returned 200;
    • the browser's PUT went to http://localhost:9000 and returned 200;
    • POST /files/{id}/finalize returned 200, and the success toast showed.

Not addressed here

After the upload, the admin avatar preview does not render in the compose stack. The Files module stores a durable URL for Public files, built by BuildPublicUrl (PublicBaseUrl, or else ServiceUrl). In compose that is http://rustfs:9000/fsh/..., and the bucket has no anonymous read policy. The compose file says so on purpose. This is how the stack already behaved before this PR, which only fixes the upload itself. The fix needs a design decision: visibility is not encoded in the object key, so a public-read policy on the bucket would expose private files too. I left it out of this PR.

Docs: fullstackhero/docs#254 updates Storage and Files and adds the changelog entry.

…S3:PresignServiceUrl

SigV4 binds a presigned URL to the host of the client that signs it, so every
presigned PUT/GET pointed at Storage:S3:ServiceUrl. When the API reaches the
store on an internal address (compose: http://rustfs:9000) browsers cannot
resolve those URLs and uploads fail.

The optional PresignServiceUrl gives presigning its own keyed IAmazonS3 built
by the same helper (same credentials, region, path style); the presign
protocol follows its scheme. All real I/O stays on ServiceUrl. Empty keeps the
previous URLs. An invalid value fails at startup (ValidateOnStart).
…stack

The api reached RustFS as http://rustfs:9000 and RustFS published no port,
so every presigned upload/download URL handed to the admin and dashboard
pointed at a host the browser cannot resolve: file uploads were broken in the
compose deployment.

- publish the RustFS S3 API on FSH_S3_PORT (default 9000); console stays off
- RUSTFS_CORS_ALLOWED_ORIGINS = admin + dashboard URLs, as the AppHost does
- api: Storage__S3__PresignServiceUrl from the new required FSH_S3_PUBLIC_URL
- .env.example, README (fourth proxy subdomain, Host header, troubleshooting)
- fsh new writes FSH_S3_PUBLIC_URL=http://localhost:9000 into the generated .env
…artup, not on first upload

Uri.TryCreate trims, so " https://s3.example.com " passed startup validation
and then failed every presign in the SDK's endpoint parser. The endpoint is
now trimmed, and a URL with a path, query or fragment is rejected at startup:
the path would be signed into every URL and break behind a proxy. The README
now scopes the "skip the API" claim to presigned transfers, and two comments
that overstated what the presign client does are corrected.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@iammukeshm iammukeshm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving the BuildingBlocks/Storage change: it's additive, and an empty PresignServiceUrl gives byte-identical behaviour. A signing-only second client is the right shape, since SigV4 binds the host and a post-sign rewrite can't work. Validating at startup (trimmed, no path) and the red-able tests are well done.

Two notes, not blockers:

  • Compose now publishes the RustFS S3 port on all interfaces. Access still needs the keys, but it's more surface than before. I'm tracking a README line telling people to firewall it or bind it to the proxy.
  • The public-file preview URL (BuildPublicUrl → rustfs:9000) is still broken in compose, as you called out. I'm opening a follow-up issue so the visibility/bucket-policy decision gets its own discussion.

Thanks, Marcelo.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

File uploads fail in the Docker Compose deployment: presigned URLs point at rustfs:9000

2 participants