fix: correct SigV4 verification for GetObject and prevent response compression corruption

This commit is contained in:
2026-08-16 20:17:34 +07:00
parent 27d8fae0f0
commit b7c9b46ca5
4 changed files with 49 additions and 9 deletions
+22 -5
View File
@@ -34,6 +34,26 @@ async function getSigningKey(secret: string, date: string, region: string, servi
return await hmacSha256(kService, "aws4_request");
}
/**
* aws-sdk-go-v2 (used by rclone/AWS CLI v2) signs Accept-Encoding as "gzip" specifically for
* GetObject — it wants a compressed transfer of the object body — but as "identity" for every
* other operation (HeadObject, ListObjectsV2, PutObject, multipart list/parts), since those
* don't return arbitrary object data. Confirmed by capturing rclone's own raw outgoing requests:
* HEAD and GET-without-key send "identity"; GET-with-key (GetObject) sends "gzip". Cloudflare's
* edge always rewrites the incoming header before the Worker sees it, so the literal value can
* never be read back — this replicates what the client actually signed instead.
*
* This is a best-effort fallback: some proxies between the client and this Worker (including
* Cloudflare's own edge) can still rewrite Accept-Encoding in ways clients don't anticipate,
* which is why rclone/aws-sdk-go-v2 also expose `--s3-sign-accept-encoding=false` to drop this
* header from what's signed entirely — see the "Accept-Encoding" note in the README.
*/
function isGetObjectRequest(method: string, url: URL): boolean {
if (method !== "GET") return false;
if (url.searchParams.has("uploadId") || url.searchParams.has("uploads")) return false;
return url.pathname.split("/").filter(Boolean).length > 1;
}
async function createCanonicalRequest(request: Request, isQueryAuth: boolean): Promise<string> {
const url = new URL(request.url);
@@ -41,6 +61,7 @@ async function createCanonicalRequest(request: Request, isQueryAuth: boolean): P
const canonicalUri = url.pathname || "/";
const params = Array.from(url.searchParams.entries())
// "X-Amz-Signature" is stripped from presigned URLs since it's the signature itself.
.filter(([key]) => key !== "X-Amz-Signature")
.sort(([a], [b]) => {
if (a < b) return -1;
@@ -71,11 +92,7 @@ async function createCanonicalRequest(request: Request, isQueryAuth: boolean): P
headerValue += `:${port}`;
}
} else if (headerName === "accept-encoding") {
// Cloudflare's edge rewrites the incoming Accept-Encoding value before the Worker
// sees it, so the literal value can never be recovered here. S3 SDKs that sign this
// header (aws-sdk-go, used by rclone/mc) always set it to "identity" beforehand, since
// S3 doesn't support transparent content-encoding on object bodies.
headerValue = "identity";
headerValue = isGetObjectRequest(method, url) ? "gzip" : "identity";
} else {
headerValue = request.headers.get(headerName)?.trim() ?? "";
}
+3 -3
View File
@@ -12,7 +12,7 @@ function etag(file: { id: string; md5Checksum?: string }): string {
}
function xmlResponse(body: string, status = 200): Response {
return new Response(body, { status, headers: { "Content-Type": "application/xml" } });
return new Response(body, { status, headers: { "Content-Type": "application/xml", "Cache-Control": "no-transform" } });
}
function multipartStub(env: Env, uploadId: string) {
@@ -202,7 +202,7 @@ export async function dispatch(request: Request, env: Env, accessToken: string,
const headers = new Headers({
"Content-Type": file.contentType,
"Content-Length": file.contentLength ?? file.size.toString(),
"Cache-Control": "s-maxage=300, no-store",
"Cache-Control": "s-maxage=300, no-store, no-transform",
"Accept-Ranges": "bytes",
ETag: `"${etag(file)}"`,
});
@@ -219,7 +219,7 @@ export async function dispatch(request: Request, env: Env, accessToken: string,
if (!key) return new Response(null, { status: 200 });
try {
const metadata = await getFileMetadata(accessToken, bucket, key, env);
const headers = new Headers({ "Content-Type": metadata.mimeType, "Content-Length": metadata.size.toString(), "Accept-Ranges": "bytes", ETag: `"${etag(metadata)}"` });
const headers = new Headers({ "Content-Type": metadata.mimeType, "Content-Length": metadata.size.toString(), "Cache-Control": "no-transform", "Accept-Ranges": "bytes", ETag: `"${etag(metadata)}"` });
if (metadata.modifiedTime) headers.set("Last-Modified", new Date(metadata.modifiedTime).toUTCString());
return new Response(null, { status: 200, headers });
} catch (error) {
+1
View File
@@ -36,5 +36,6 @@ export function s3Error(code: S3ErrorCode, status: number, message = DEFAULT_MES
const responseHeaders = new Headers(headers);
responseHeaders.set("Content-Type", "application/xml");
responseHeaders.set("x-amz-request-id", requestId);
responseHeaders.set("Cache-Control", "no-transform");
return new Response(body, { status, headers: responseHeaders });
}
+23 -1
View File
@@ -274,7 +274,7 @@ describe("S3 compatibility", () => {
expect(await missing.text()).toContain("<Code>NoSuchKey</Code>");
});
it("verifies signatures that include Accept-Encoding even when the delivered value differs (Cloudflare rewrites it in transit)", async () => {
it("verifies PUT signatures with Accept-Encoding: identity even when Cloudflare rewrites the delivered value", async () => {
const original = await signed("/test-bucket/ae.txt", { method: "PUT", body: "hello", headers: { "accept-encoding": "identity" } });
const rewrittenHeaders = new Headers(original.headers);
rewrittenHeaders.set("accept-encoding", "gzip, deflate, br");
@@ -284,6 +284,28 @@ describe("S3 compatibility", () => {
expect(response.status).toBe(200);
});
it("verifies GetObject signatures with Accept-Encoding: gzip (aws-sdk-go-v2/rclone signs gzip only for GetObject, identity elsewhere)", async () => {
await worker.fetch(await signed("/test-bucket/ae-get.txt", { method: "PUT", body: "hello" }), ENV, CTX);
const original = await signed("/test-bucket/ae-get.txt", { method: "GET", headers: { "accept-encoding": "gzip" } });
const rewrittenHeaders = new Headers(original.headers);
rewrittenHeaders.set("accept-encoding", "gzip, br");
const mutated = new Request(original, { headers: rewrittenHeaders });
const response = await worker.fetch(mutated, ENV, CTX);
expect(response.status).toBe(200);
expect(await response.text()).toBe("hello");
});
it("verifies signatures for requests carrying the aws-sdk-go x-id tracing param (rclone/AWS CLI v2 GetObject)", async () => {
// aws-sdk-go-v2 (used by rclone) signs the x-id param as part of the request by default
// (opt.UseXID defaults to true) — it's part of the canonical query string, not appended
// afterward. Confirmed against rclone's own request dumps.
await worker.fetch(await signed("/test-bucket/getid.txt", { method: "PUT", body: "hello" }), ENV, CTX);
const response = await worker.fetch(await signed("/test-bucket/getid.txt?x-id=GetObject", { method: "GET" }), ENV, CTX);
expect(response.status).toBe(200);
expect(await response.text()).toBe("hello");
});
it("rejects expired presigned URLs and accepts unexpired ones", async () => {
const expired = await worker.fetch(await presigned("/test-bucket/file.txt", { method: "GET" }, { datetime: "20200101T000000Z", expires: "60" }), ENV, CTX);
expect(expired.status).toBe(403);