Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/storage-hardener-s1-s8.changed.md
Original file line number Diff line number Diff line change
@@ -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
21 changes: 20 additions & 1 deletion vendor/wheels/storage/S3Signer.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -143,6 +145,14 @@ component output="false" {
& "x-amz-date:" & local.amzDate & Chr(10);
local.signedHeaderList = "host;x-amz-content-sha256;x-amz-date";

if (Len(arguments.range)) {
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)
Expand All @@ -157,6 +167,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,
Expand Down
2 changes: 1 addition & 1 deletion vendor/wheels/storage/drivers/LocalDisk.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
2 changes: 1 addition & 1 deletion vendor/wheels/storage/drivers/S3Disk.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions vendor/wheels/tests/_assets/storage/S3DiskDeleteStub.cfc
Original file line number Diff line number Diff line change
@@ -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 = ""};
}

}
105 changes: 105 additions & 0 deletions vendor/wheels/tests/specs/storage/StorageSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,29 @@ component extends="wheels.WheelsTest" {
expect(headers.Authorization).toInclude("Signature=");
});

it("reproduces the AWS-documented SigV4 GET Object header-auth test vector", function() {
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 --------------------------------------------------
Expand Down Expand Up @@ -120,6 +143,45 @@ 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() {
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");
});
Expand Down Expand Up @@ -174,6 +236,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 = ""});
Expand Down Expand Up @@ -271,6 +338,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() {
Expand Down Expand Up @@ -332,4 +414,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);
}
}

}