Skip to content

fix(storage): flip remaining S1 S3 S4 S5 S6 storage contracts - #3417

Merged
bpamiri merged 3 commits into
developfrom
cursor/storage-flips-s1-s3-s6-1280
Aug 25, 2026
Merged

fix(storage): flip remaining S1 S3 S4 S5 S6 storage contracts#3417
bpamiri merged 3 commits into
developfrom
cursor/storage-flips-s1-s3-s6-1280

Conversation

@bpamiri

@bpamiri bpamiri commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Why

Peter flipped the five Storage holds that #3416 left unflipped. Empty and slash-only keys concatenated onto root and get() threw NotFound. LocalDisk url() and signedUrl() left spaces raw while S3 encoded them. S3 delete() returned true after any 2xx. put() never sent an ACL. expiresIn accepted non-positive and over-max values.

This PR flips S1, S3, S4, S5, and S6 only. Desk IDs stay S1–S8. S2, S7, and S8 stay as they landed on develop in #3416.

Scope

  • vendor/wheels/storage/drivers/LocalDisk.cfc
    • $resolve throws Wheels.Storage.InvalidKey when the key is empty or slash-only
    • $joinUrl RFC3986-encodes the key path (+%20, *%2A, %7E~, slashes kept)
    • signedUrl throws Wheels.Storage.InvalidExpiresIn outside 1..604800
  • vendor/wheels/storage/drivers/S3Disk.cfc
    • delete() calls exists() (HEAD) and returns false on a miss
    • put() sends a signed x-amz-acl (publicpublic-read, default/privateprivate)
  • vendor/wheels/storage/S3Signer.cfc
    • signedHeaders() takes optional acl. Empty acl and empty range keep the S8 canonical request
    • presignGetUrl() throws Wheels.Storage.InvalidExpiresIn outside 1..604800
  • vendor/wheels/tests/specs/storage/StorageSpec.cfc
  • vendor/wheels/tests/_assets/storage/S3DiskDeleteStub.cfc — HEAD/DELETE/PUT stub with seed() and lastRequest()
  • changelog.d/storage-hardener-s1-s3-s6.changed.md and storage-hardener-s1-s5.security.md

Out: leading-slash leftover ($objectPath ltrims /, S3Signer does not). Policy leftovers stay closed. S2 resolver, S7 empty signingKey, and S8 header-auth vector are unchanged.

Tradeoffs

expiresIn throws. Clamp would hide a bad caller. AWS SigV4 rejects X-Amz-Expires above 604800. The old expiresIn = -10 expired-URL trick is gone. Expiry is proven with verifySignature and a past epoch.

visibility=public maps to x-amz-acl: public-read. Raw AWS ACL names pass through. The ACL header is signed so S3 does not return SignatureDoesNotMatch.

delete() reuses exists() for the HEAD. A 5xx or connection failure still throws Wheels.Storage.RequestFailed instead of returning false.

Blast Radius

LocalDisk.$resolve("", "/", "///") now throws. Apps that stored an empty key as the root directory fail closed. put("") cannot write the disk root.

S3 delete() of a missing key returns false. Callers that treated every 2xx delete as true will see false on a miss.

Signed URLs with expiresIn <= 0 or > 604800 throw on both disks. expiresIn = 86400 (the AWS query-auth vector) still works.

Desk S1–S8

ID Status Note
S1 PROVEN $resolve("", "/", "///") and put("") throw Wheels.Storage.InvalidKey. No root write.
S2 already on develop (#3416) Symlink-under-root fixture. Resolver still Find("..") only.
S3 PROVEN LocalDisk url("my docs/q3 report.pdf") is /uploads/my%20docs/q3%20report.pdf. signedUrl encodes the same path. Token still verifies against the raw key.
S4 PROVEN Stubbed HEAD 404 → delete("gone.txt") is false. Seeded object deletes and returns true. Second delete is false.
S5 PROVEN Default put() sends signed x-amz-acl: private. visibility=public sends public-read. S8 no-acl vector unchanged.
S6 PROVEN LocalDisk and S3 throw Wheels.Storage.InvalidExpiresIn for 0, -10, and 604801. Bounds 1 and 604800 still sign.
S7 already on develop (#3416) verifySignature with no signingKey returns false.
S8 already on develop (#3416) Official AWS GET Object header-auth vector. Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41.

Verification

Commands actually run:

wheels test --core --ci --filter=storage

Scope: wheels.tests.specs.storage

Red (spec-only HEAD 3f89a9ac11b5f383ba4f3c42b9211c15e6b7e7b3):

32 passed, 7 failed, 1 error(s)

S1 empty-key throw, S3 encode, both S6 expiresIn throws, S4 HEAD-then-false, S5 ACL, and the seeded-delete follow-up all failed against the #3416 pins.

Green (this HEAD):

40 passed (0.05s)

JSON from the same filter (directory=wheels.tests.specs.storage):

totalPass=40
totalFail=0
totalError=0
totalSkipped=0
totalSpecs=40
bundlesDiscovered=1
directoryRejected=false
directoryResolved=wheels.tests.specs.storage

Wheels CLI 4.0.6. Lucee 7.0.0.395. sqlite.

Adobe and BoxLang were not run here.

HEAD 33dd695b28e5ee287e99ecc027d719ecda2f5f22

Base 80d3e0fb4b1b8e670716716aeb2d3e56160668d9 (develop after #3416).

No closer keywords. Do not merge.

Open in Web Open in Cursor 

Empty and slash-only keys must throw InvalidKey. LocalDisk url() and
signedUrl() must encode spaces and reserved characters. S3Disk.delete()
must HEAD then return false when missing. put() must send a signed
x-amz-acl. expiresIn outside 1..604800 must throw on both disks.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Peter Amiri <peter@alurium.com>
LocalDisk $resolve throws InvalidKey for empty and slash-only keys.
url() and signedUrl() encode like S3. S3Disk.delete() HEADs then
returns false on a miss. put() sends a signed x-amz-acl.
expiresIn outside 1..604800 throws InvalidExpiresIn on both disks.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Peter Amiri <peter@alurium.com>
@github-actions github-actions Bot added the docs label Aug 25, 2026
The signedHeaders header list is now built from range and acl.
\$assertSuccess already fails a non-2xx DELETE.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Peter Amiri <peter@alurium.com>
@bpamiri
bpamiri marked this pull request as ready for review August 25, 2026 11:22
@bpamiri
bpamiri merged commit b8065e3 into develop Aug 25, 2026
12 checks passed
@bpamiri
bpamiri deleted the cursor/storage-flips-s1-s3-s6-1280 branch August 25, 2026 11:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants