mirror of
https://github.com/Nezumi-2711/google-drive-s3.git
synced 2026-09-22 13:38:30 +00:00
fix: correct SigV4 verification for GetObject and prevent response compression corruption
This commit is contained in:
+22
-5
@@ -34,6 +34,26 @@ async function getSigningKey(secret: string, date: string, region: string, servi
|
|||||||
return await hmacSha256(kService, "aws4_request");
|
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> {
|
async function createCanonicalRequest(request: Request, isQueryAuth: boolean): Promise<string> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
@@ -41,6 +61,7 @@ async function createCanonicalRequest(request: Request, isQueryAuth: boolean): P
|
|||||||
const canonicalUri = url.pathname || "/";
|
const canonicalUri = url.pathname || "/";
|
||||||
|
|
||||||
const params = Array.from(url.searchParams.entries())
|
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")
|
.filter(([key]) => key !== "X-Amz-Signature")
|
||||||
.sort(([a], [b]) => {
|
.sort(([a], [b]) => {
|
||||||
if (a < b) return -1;
|
if (a < b) return -1;
|
||||||
@@ -71,11 +92,7 @@ async function createCanonicalRequest(request: Request, isQueryAuth: boolean): P
|
|||||||
headerValue += `:${port}`;
|
headerValue += `:${port}`;
|
||||||
}
|
}
|
||||||
} else if (headerName === "accept-encoding") {
|
} else if (headerName === "accept-encoding") {
|
||||||
// Cloudflare's edge rewrites the incoming Accept-Encoding value before the Worker
|
headerValue = isGetObjectRequest(method, url) ? "gzip" : "identity";
|
||||||
// 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";
|
|
||||||
} else {
|
} else {
|
||||||
headerValue = request.headers.get(headerName)?.trim() ?? "";
|
headerValue = request.headers.get(headerName)?.trim() ?? "";
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -12,7 +12,7 @@ function etag(file: { id: string; md5Checksum?: string }): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function xmlResponse(body: string, status = 200): Response {
|
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) {
|
function multipartStub(env: Env, uploadId: string) {
|
||||||
@@ -202,7 +202,7 @@ export async function dispatch(request: Request, env: Env, accessToken: string,
|
|||||||
const headers = new Headers({
|
const headers = new Headers({
|
||||||
"Content-Type": file.contentType,
|
"Content-Type": file.contentType,
|
||||||
"Content-Length": file.contentLength ?? file.size.toString(),
|
"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",
|
"Accept-Ranges": "bytes",
|
||||||
ETag: `"${etag(file)}"`,
|
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 });
|
if (!key) return new Response(null, { status: 200 });
|
||||||
try {
|
try {
|
||||||
const metadata = await getFileMetadata(accessToken, bucket, key, env);
|
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());
|
if (metadata.modifiedTime) headers.set("Last-Modified", new Date(metadata.modifiedTime).toUTCString());
|
||||||
return new Response(null, { status: 200, headers });
|
return new Response(null, { status: 200, headers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -36,5 +36,6 @@ export function s3Error(code: S3ErrorCode, status: number, message = DEFAULT_MES
|
|||||||
const responseHeaders = new Headers(headers);
|
const responseHeaders = new Headers(headers);
|
||||||
responseHeaders.set("Content-Type", "application/xml");
|
responseHeaders.set("Content-Type", "application/xml");
|
||||||
responseHeaders.set("x-amz-request-id", requestId);
|
responseHeaders.set("x-amz-request-id", requestId);
|
||||||
|
responseHeaders.set("Cache-Control", "no-transform");
|
||||||
return new Response(body, { status, headers: responseHeaders });
|
return new Response(body, { status, headers: responseHeaders });
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -274,7 +274,7 @@ describe("S3 compatibility", () => {
|
|||||||
expect(await missing.text()).toContain("<Code>NoSuchKey</Code>");
|
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 original = await signed("/test-bucket/ae.txt", { method: "PUT", body: "hello", headers: { "accept-encoding": "identity" } });
|
||||||
const rewrittenHeaders = new Headers(original.headers);
|
const rewrittenHeaders = new Headers(original.headers);
|
||||||
rewrittenHeaders.set("accept-encoding", "gzip, deflate, br");
|
rewrittenHeaders.set("accept-encoding", "gzip, deflate, br");
|
||||||
@@ -284,6 +284,28 @@ describe("S3 compatibility", () => {
|
|||||||
expect(response.status).toBe(200);
|
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 () => {
|
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);
|
const expired = await worker.fetch(await presigned("/test-bucket/file.txt", { method: "GET" }, { datetime: "20200101T000000Z", expires: "60" }), ENV, CTX);
|
||||||
expect(expired.status).toBe(403);
|
expect(expired.status).toBe(403);
|
||||||
|
|||||||
Reference in New Issue
Block a user