diff --git a/src/aws-signature.ts b/src/aws-signature.ts index 46eaedb..0d217c2 100644 --- a/src/aws-signature.ts +++ b/src/aws-signature.ts @@ -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 { 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() ?? ""; } diff --git a/src/router.ts b/src/router.ts index e498bdd..24d1e58 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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) { diff --git a/src/s3-errors.ts b/src/s3-errors.ts index 59d9bcd..ca42d5a 100644 --- a/src/s3-errors.ts +++ b/src/s3-errors.ts @@ -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 }); } diff --git a/test/s3.test.ts b/test/s3.test.ts index b8fab68..af259a0 100644 --- a/test/s3.test.ts +++ b/test/s3.test.ts @@ -274,7 +274,7 @@ describe("S3 compatibility", () => { expect(await missing.text()).toContain("NoSuchKey"); }); - 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);