From 615c308a32d0730c6727039c7957e527d345745f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 03:13:30 +0000 Subject: [PATCH 1/2] test(storage): pin S1-S8 contracts without flipping defaults LocalDisk $resolve keeps empty and slash-only keys as root paths. The symlink-under-root fixture shows Find("..") misses that hop. verifySignature returns false when signingKey is empty. S3Disk.delete() stays true after 2xx. signedHeaders pins the official AWS GET Object header-auth signature via an optional range argument. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- changelog.d/storage-hardener-s1-s8.changed.md | 2 + vendor/wheels/storage/S3Signer.cfc | 23 +++- vendor/wheels/storage/drivers/LocalDisk.cfc | 2 +- vendor/wheels/storage/drivers/S3Disk.cfc | 2 +- .../_assets/storage/S3DiskDeleteStub.cfc | 14 +++ .../tests/specs/storage/StorageSpec.cfc | 111 ++++++++++++++++++ 6 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 changelog.d/storage-hardener-s1-s8.changed.md create mode 100644 vendor/wheels/tests/_assets/storage/S3DiskDeleteStub.cfc diff --git a/changelog.d/storage-hardener-s1-s8.changed.md b/changelog.d/storage-hardener-s1-s8.changed.md new file mode 100644 index 0000000000..8549585ae7 --- /dev/null +++ b/changelog.d/storage-hardener-s1-s8.changed.md @@ -0,0 +1,2 @@ +- `S3Signer.signedHeaders()` accepts an optional `range` argument so the official AWS GET Object header-auth vector can be pinned. The no-range canonical request is unchanged +- LocalDisk `$resolve` and S3Disk `$request` are public so tests can call and override them. `S3Disk.delete()` still returns true after any 2xx diff --git a/vendor/wheels/storage/S3Signer.cfc b/vendor/wheels/storage/S3Signer.cfc index 2a3a3a4a99..1c1e3f5c89 100644 --- a/vendor/wheels/storage/S3Signer.cfc +++ b/vendor/wheels/storage/S3Signer.cfc @@ -119,13 +119,15 @@ component output="false" { * @key Object key. * @payload Request body (binary or string); empty for GET/DELETE/HEAD. * @amzDate Optional deterministic timestamp override. + * @range Optional Range header value (e.g. "bytes=0-9"). Empty keeps the current signed header set. * @return Struct of header name => value to attach to the request. */ public struct function signedHeaders( required string method, required string key, any payload = "", - string amzDate = "" + string amzDate = "", + string range = "" ) { local.amzDate = Len(arguments.amzDate) ? arguments.amzDate : $amzNow(); local.dateStamp = Left(local.amzDate, 8); @@ -143,6 +145,16 @@ component output="false" { & "x-amz-date:" & local.amzDate & Chr(10); local.signedHeaderList = "host;x-amz-content-sha256;x-amz-date"; + if (Len(arguments.range)) { + // Official AWS GET Object header-auth vector includes Range; insert + // in sort order without changing the empty-range canonical request. + local.canonicalHeaders = "host:" & variables.host & Chr(10) + & "range:" & arguments.range & Chr(10) + & "x-amz-content-sha256:" & local.payloadHash & Chr(10) + & "x-amz-date:" & local.amzDate & Chr(10); + local.signedHeaderList = "host;range;x-amz-content-sha256;x-amz-date"; + } + local.canonicalRequest = UCase(arguments.method) & Chr(10) & local.canonicalUri & Chr(10) & "" & Chr(10) @@ -157,6 +169,15 @@ component output="false" { & "SignedHeaders=" & local.signedHeaderList & ", " & "Signature=" & local.signature; + if (Len(arguments.range)) { + return { + "Authorization" = local.authorization, + "x-amz-content-sha256" = local.payloadHash, + "x-amz-date" = local.amzDate, + "Host" = variables.host, + "Range" = arguments.range + }; + } return { "Authorization" = local.authorization, "x-amz-content-sha256" = local.payloadHash, diff --git a/vendor/wheels/storage/drivers/LocalDisk.cfc b/vendor/wheels/storage/drivers/LocalDisk.cfc index c084c52143..62edbb3dee 100644 --- a/vendor/wheels/storage/drivers/LocalDisk.cfc +++ b/vendor/wheels/storage/drivers/LocalDisk.cfc @@ -148,7 +148,7 @@ component implements="wheels.interfaces.StorageDiskInterface" output="false" { return local.diff == 0; } - private string function $resolve(required string key) { + public string function $resolve(required string key) { // Reject traversal — a key must stay inside root. local.clean = Replace(arguments.key, "\", "/", "all"); if (Find("..", local.clean)) { diff --git a/vendor/wheels/storage/drivers/S3Disk.cfc b/vendor/wheels/storage/drivers/S3Disk.cfc index 7edc307bb3..54c19cf630 100644 --- a/vendor/wheels/storage/drivers/S3Disk.cfc +++ b/vendor/wheels/storage/drivers/S3Disk.cfc @@ -140,7 +140,7 @@ component implements="wheels.interfaces.StorageDiskInterface" output="false" { * before being attached one-by-one — never `attributeCollection=arguments`, * which Adobe CF 2023/2025 reject on built-in tags. */ - private struct function $request( + public struct function $request( required string method, required string key, required struct headers, diff --git a/vendor/wheels/tests/_assets/storage/S3DiskDeleteStub.cfc b/vendor/wheels/tests/_assets/storage/S3DiskDeleteStub.cfc new file mode 100644 index 0000000000..abb91d4752 --- /dev/null +++ b/vendor/wheels/tests/_assets/storage/S3DiskDeleteStub.cfc @@ -0,0 +1,14 @@ +component extends="wheels.storage.drivers.S3Disk" { + + public struct function $request( + required string method, + required string key, + required struct headers, + any body = "", + string contentType = "", + boolean getAsBinary = false + ) { + return {statusCode = "204 No Content", fileContent = ""}; + } + +} diff --git a/vendor/wheels/tests/specs/storage/StorageSpec.cfc b/vendor/wheels/tests/specs/storage/StorageSpec.cfc index 44b130f89d..2095d0df9d 100644 --- a/vendor/wheels/tests/specs/storage/StorageSpec.cfc +++ b/vendor/wheels/tests/specs/storage/StorageSpec.cfc @@ -70,6 +70,33 @@ component extends="wheels.WheelsTest" { expect(headers.Authorization).toInclude("Signature="); }); + it("reproduces the AWS-documented SigV4 GET Object header-auth test vector", function() { + // Official example from AWS "Authenticating Requests: Using the + // Authorization Header (AWS Signature Version 4)". Same credentials + // and timestamp as the presigned-GET vector; Range is part of the + // published canonical request. + var signer = new wheels.storage.S3Signer( + accessKeyId = "AKIAIOSFODNN7EXAMPLE", + secretAccessKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + region = "us-east-1", + bucket = "examplebucket", + endpoint = "examplebucket.s3.amazonaws.com" + ); + var headers = signer.signedHeaders( + method = "GET", + key = "test.txt", + payload = "", + amzDate = "20130524T000000Z", + range = "bytes=0-9" + ); + + expect(headers["x-amz-content-sha256"]).toBe( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + expect(headers.Authorization).toInclude("SignedHeaders=host;range;x-amz-content-sha256;x-amz-date"); + expect(headers.Authorization).toInclude("Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"); + }); + }); // ---- LocalDisk -------------------------------------------------- @@ -120,6 +147,47 @@ component extends="wheels.WheelsTest" { }).toThrow("Wheels.Storage.InvalidKey"); }); + it("resolves empty and slash-only keys as root concatenations, not InvalidKey", function() { + var expectedRoot = REReplace(Replace(ctx.root, "\", "/", "all"), "/+$", ""); + expect(disk.$resolve("")).toBe(expectedRoot & "/"); + expect(disk.$resolve("/")).toBe(expectedRoot & "//"); + expect(disk.$resolve("///")).toBe(expectedRoot & "////"); + + expect(function() { + disk.get(""); + }).toThrow("Wheels.Storage.NotFound"); + expect(function() { + disk.get("/"); + }).toThrow("Wheels.Storage.NotFound"); + expect(function() { + disk.get("///"); + }).toThrow("Wheels.Storage.NotFound"); + }); + + it("follows a symlink under root to a file outside (Find('..') does not see the hop)", function() { + // DirectoryDelete follows symlinks, so the link must be removed + // before afterEach wipes the root. Find("..") does not see this hop. + var outside = GetTempDirectory() & "wheels-storage-outside-" & CreateUUID(); + var linkPath = ctx.root & "/link"; + DirectoryCreate(outside); + if (!DirectoryExists(ctx.root)) { + DirectoryCreate(ctx.root); + } + FileWrite(outside & "/secret.txt", CharsetDecode("outside-secret", "utf-8")); + $createSymlink(outside, linkPath); + try { + expect(function() { + disk.exists("link/secret.txt"); + }).notToThrow(type = "Wheels.Storage.InvalidKey"); + expect(ToString(disk.get("link/secret.txt"))).toBe("outside-secret"); + } finally { + $deleteSymlink(linkPath); + if (DirectoryExists(outside)) { + DirectoryDelete(outside, true); + } + } + }); + it("builds a public url from the urlPrefix", function() { expect(disk.url("a/b.png")).toBe("/uploads/a/b.png"); }); @@ -174,6 +242,11 @@ component extends="wheels.WheelsTest" { }).toThrow("Wheels.Storage.MissingSigningKey"); }); + it("verifySignature returns false when no signingKey is configured", function() { + var unsigned = new wheels.storage.drivers.LocalDisk(config = {root = ctx.root, urlPrefix = "/u"}); + expect(unsigned.verifySignature(key = "a.png", expires = 4102444800, signature = "deadbeef")).toBeFalse(); + }); + it("requires a non-empty root", function() { expect(function() { new wheels.storage.drivers.LocalDisk(config = {root = ""}); @@ -271,6 +344,21 @@ component extends="wheels.WheelsTest" { }); + describe("S3Disk delete()", function() { + + it("returns true after a 2xx, including a second delete of the same missing key", function() { + var stubDisk = new wheels.tests._assets.storage.S3DiskDeleteStub(config = { + bucket = "myapp", + region = "us-east-1", + accessKeyId = "AKIAIOSFODNN7EXAMPLE", + secretAccessKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + }); + expect(stubDisk.delete("gone.txt")).toBeTrue(); + expect(stubDisk.delete("gone.txt")).toBeTrue(); + }); + + }); + // ---- StorageManager --------------------------------------------- describe("StorageManager", function() { @@ -332,4 +420,27 @@ component extends="wheels.WheelsTest" { } + function $toPath(required string filePath) { + return CreateObject("java", "java.io.File").init(arguments.filePath).toPath(); + } + + function $createSymlink(required string target, required string link) { + $deleteSymlink(arguments.link); + var pb = CreateObject("java", "java.lang.ProcessBuilder") + .init(["ln", "-s", arguments.target, arguments.link]); + var proc = pb.start(); + proc.waitFor(); + if (proc.exitValue() != 0) { + throw(type = "Wheels.Test.SymlinkError", message = "Failed to create symlink: #arguments.link# -> #arguments.target#"); + } + } + + function $deleteSymlink(required string link) { + var jFiles = CreateObject("java", "java.nio.file.Files"); + var linkPath = $toPath(arguments.link); + if (jFiles.isSymbolicLink(linkPath)) { + jFiles.delete(linkPath); + } + } + } From 8191659e500e093c49336cb9a16e351235317334 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 03:14:52 +0000 Subject: [PATCH 2/2] style(storage): drop narrating comments from S1-S8 pins The range branch, AWS vector call, and symlink fixture already state the contract. The comments restated it. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- vendor/wheels/storage/S3Signer.cfc | 2 -- vendor/wheels/tests/specs/storage/StorageSpec.cfc | 6 ------ 2 files changed, 8 deletions(-) diff --git a/vendor/wheels/storage/S3Signer.cfc b/vendor/wheels/storage/S3Signer.cfc index 1c1e3f5c89..de31eed60a 100644 --- a/vendor/wheels/storage/S3Signer.cfc +++ b/vendor/wheels/storage/S3Signer.cfc @@ -146,8 +146,6 @@ component output="false" { local.signedHeaderList = "host;x-amz-content-sha256;x-amz-date"; if (Len(arguments.range)) { - // Official AWS GET Object header-auth vector includes Range; insert - // in sort order without changing the empty-range canonical request. local.canonicalHeaders = "host:" & variables.host & Chr(10) & "range:" & arguments.range & Chr(10) & "x-amz-content-sha256:" & local.payloadHash & Chr(10) diff --git a/vendor/wheels/tests/specs/storage/StorageSpec.cfc b/vendor/wheels/tests/specs/storage/StorageSpec.cfc index 2095d0df9d..34123e6b4d 100644 --- a/vendor/wheels/tests/specs/storage/StorageSpec.cfc +++ b/vendor/wheels/tests/specs/storage/StorageSpec.cfc @@ -71,10 +71,6 @@ component extends="wheels.WheelsTest" { }); it("reproduces the AWS-documented SigV4 GET Object header-auth test vector", function() { - // Official example from AWS "Authenticating Requests: Using the - // Authorization Header (AWS Signature Version 4)". Same credentials - // and timestamp as the presigned-GET vector; Range is part of the - // published canonical request. var signer = new wheels.storage.S3Signer( accessKeyId = "AKIAIOSFODNN7EXAMPLE", secretAccessKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", @@ -165,8 +161,6 @@ component extends="wheels.WheelsTest" { }); it("follows a symlink under root to a file outside (Find('..') does not see the hop)", function() { - // DirectoryDelete follows symlinks, so the link must be removed - // before afterEach wipes the root. Find("..") does not see this hop. var outside = GetTempDirectory() & "wheels-storage-outside-" & CreateUUID(); var linkPath = ctx.root & "/link"; DirectoryCreate(outside);