Skip to content

[High][Security] Split public and private storage access instead of exposing every known S3 key #303

Description

@richardcmckinney

Summary

GET /api/storage/* serves any object whose key is known. It does not enforce object ownership, visibility, prefix policy, or signed read authorization. The default public URL builder points to this route, presigned GET URLs last 48 hours, and storage keys include only 8 UUID characters plus the original sanitized filename.

The proxy path also stores full S3 objects in an unbounded in-memory Map.

Evidence

  • apps/web/src/lib/server/storage/s3.ts:183-190 defaults public URLs to /api/storage/${key}.
  • apps/web/src/routes/api/storage/$.ts:112-178 serves or redirects for any valid key without auth.
  • apps/web/src/lib/server/storage/s3.ts:496-510 uses a default presigned GET expiration of 172800 seconds, or 48 hours.
  • apps/web/src/lib/server/storage/s3.ts:327-335 uses crypto.randomUUID().slice(0, 8) and includes the sanitized original filename in the key.
  • apps/web/src/routes/api/storage/$.ts:6-9 creates an unbounded proxyCache.
  • apps/web/src/routes/api/storage/$.ts:150-153 buffers the full S3 object and stores it in the cache.

Impact

Any leaked, logged, guessed, or shared object key becomes enough to access the file. Private chat attachments, admin uploads, imported assets, and internal objects cannot be meaningfully protected if they share this route. The unbounded proxy cache also creates a memory pressure vector when proxy mode or ?email=1 is used.

Recommended fix

Introduce storage object visibility and ownership. Public assets can continue to use stable public URLs. Private assets should require a short-lived, signed, purpose-scoped read token or authenticated authorization check. Increase object key entropy and stop embedding original filenames in sensitive keys. Add LRU and byte caps to the proxy cache, or stream proxy responses without caching by default.

Acceptance criteria

  • Storage objects are classified as public or private at creation time.
  • Private objects cannot be fetched from /api/storage/* without authorization or a signed read token.
  • Presigned GET TTL is reduced for private content.
  • Storage keys use high-entropy random IDs and avoid exposing original filenames for private objects.
  • Proxy cache has maximum entry count and maximum byte size, with tests for eviction and oversized objects.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions