mirror of
https://github.com/Nezumi-2711/google-drive-s3.git
synced 2026-09-22 13:38:30 +00:00
WIP
This commit is contained in:
+4
-1
@@ -6,14 +6,17 @@
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev",
|
||||
"start": "wrangler dev",
|
||||
"test": "vitest",
|
||||
"test": "vitest --run",
|
||||
"cf-typegen": "wrangler types",
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.965.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.965.0",
|
||||
"@biomejs/biome": "^2.3.11",
|
||||
"@cloudflare/vitest-pool-workers": "^0.8.19",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"typescript": "^5.5.2",
|
||||
"vitest": "~3.2.0",
|
||||
"wrangler": "^4.57.0"
|
||||
|
||||
Generated
+1261
-2
File diff suppressed because it is too large
Load Diff
+39
-50
@@ -27,7 +27,8 @@ export default {
|
||||
// return new Response(stream, { status: 200 });
|
||||
return new Response(`Verified ${method} for ${url.pathname}`, { status: 200 });
|
||||
} else if (method === "DELETE") {
|
||||
return new Response(`Verified ${method} for ${url.pathname}`, { status: 204 });
|
||||
// 204 No Content はボディを持てない
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
return new Response("Method not allowed", { status: 405 });
|
||||
@@ -46,6 +47,7 @@ interface Env {
|
||||
}
|
||||
|
||||
async function verifySignature(request: Request, env: Env): Promise<boolean> {
|
||||
const start = performance.now();
|
||||
const url = new URL(request.url);
|
||||
const headers = request.headers;
|
||||
|
||||
@@ -65,9 +67,7 @@ async function verifySignature(request: Request, env: Env): Promise<boolean> {
|
||||
}
|
||||
|
||||
// 日時情報の取得
|
||||
const datetime = (isQueryAuth
|
||||
? url.searchParams.get("X-Amz-Date")
|
||||
: headers.get("x-amz-date")) ?? "";
|
||||
const datetime = (isQueryAuth ? url.searchParams.get("X-Amz-Date") : headers.get("x-amz-date")) ?? "";
|
||||
|
||||
if (!datetime) return false;
|
||||
|
||||
@@ -79,12 +79,7 @@ async function verifySignature(request: Request, env: Env): Promise<boolean> {
|
||||
|
||||
// String to Sign の生成
|
||||
const credentialScope = `${date}/${env.REGION}/s3/aws4_request`;
|
||||
const stringToSign = [
|
||||
"AWS4-HMAC-SHA256",
|
||||
datetime,
|
||||
credentialScope,
|
||||
hashedCanonicalRequest
|
||||
].join("\n");
|
||||
const stringToSign = ["AWS4-HMAC-SHA256", datetime, credentialScope, hashedCanonicalRequest].join("\n");
|
||||
|
||||
// 署名の計算
|
||||
const signingKey = await getSigningKey(env.SECRET_KEY, date, env.REGION, "s3");
|
||||
@@ -101,6 +96,20 @@ async function verifySignature(request: Request, env: Env): Promise<boolean> {
|
||||
expectedSignature = match ? match[1] : "";
|
||||
}
|
||||
|
||||
// デバッグ用
|
||||
if (signatureHex !== expectedSignature) {
|
||||
console.log("Signature mismatch!");
|
||||
console.log("Canonical Request:", canonicalRequest);
|
||||
console.log("String to Sign:", stringToSign);
|
||||
console.log("Computed:", signatureHex);
|
||||
console.log("Expected:", expectedSignature);
|
||||
}
|
||||
|
||||
const end = performance.now();
|
||||
const cpuUsed = end - start;
|
||||
|
||||
console.debug(`CPU time used: ${cpuUsed}ms`);
|
||||
|
||||
return signatureHex === expectedSignature;
|
||||
}
|
||||
|
||||
@@ -114,9 +123,15 @@ async function createCanonicalRequest(request: Request, isQueryAuth: boolean): P
|
||||
const canonicalUri = url.pathname || "/";
|
||||
|
||||
// Canonical Query String
|
||||
// AWS署名バージョン4では、パラメータ名の大文字小文字を区別してソート
|
||||
const params = Array.from(url.searchParams.entries())
|
||||
.filter(([key]) => key !== "X-Amz-Signature") // 署名自体は除外
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.sort(([a], [b]) => {
|
||||
// バイナリソート(大文字小文字を区別)
|
||||
if (a < b) return -1;
|
||||
if (a > b) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map(([key, val]) => `${encodeRFC3986(key)}=${encodeRFC3986(val)}`)
|
||||
.join("&");
|
||||
|
||||
@@ -132,20 +147,17 @@ async function createCanonicalRequest(request: Request, isQueryAuth: boolean): P
|
||||
|
||||
// Canonical Headers の生成
|
||||
const canonicalHeaders = signedHeadersList
|
||||
.map(h => {
|
||||
.map((h) => {
|
||||
const headerName = h.toLowerCase();
|
||||
let headerValue = "";
|
||||
|
||||
if (headerName === "host") {
|
||||
// ホストヘッダーはポート番号を除外 (標準ポートの場合)
|
||||
const host = url.hostname;
|
||||
// ホストヘッダーをそのまま使用(URLのhostnameを使用)
|
||||
headerValue = url.hostname;
|
||||
// 非標準ポートの場合はポート番号を追加
|
||||
const port = url.port;
|
||||
if ((url.protocol === "https:" && port === "443") ||
|
||||
(url.protocol === "http:" && port === "80") ||
|
||||
!port) {
|
||||
headerValue = host;
|
||||
} else {
|
||||
headerValue = `${host}:${port}`;
|
||||
if (port && !((url.protocol === "https:" && port === "443") || (url.protocol === "http:" && port === "80"))) {
|
||||
headerValue += `:${port}`;
|
||||
}
|
||||
} else {
|
||||
headerValue = request.headers.get(headerName)?.trim() ?? "";
|
||||
@@ -158,49 +170,26 @@ async function createCanonicalRequest(request: Request, isQueryAuth: boolean): P
|
||||
const signedHeaders = signedHeadersList.join(";");
|
||||
|
||||
// Payload Hash
|
||||
const payloadHash = request.headers.get("x-amz-content-sha256") ??
|
||||
(isQueryAuth ? "UNSIGNED-PAYLOAD" : "UNSIGNED-PAYLOAD");
|
||||
const payloadHash = request.headers.get("x-amz-content-sha256") ?? (isQueryAuth ? "UNSIGNED-PAYLOAD" : "UNSIGNED-PAYLOAD");
|
||||
|
||||
return [
|
||||
method,
|
||||
canonicalUri,
|
||||
params,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
payloadHash
|
||||
].join("\n");
|
||||
return [method, canonicalUri, params, canonicalHeaders, signedHeaders, payloadHash].join("\n");
|
||||
}
|
||||
|
||||
// RFC3986に準拠したURLエンコーディング
|
||||
function encodeRFC3986(str: string): string {
|
||||
return encodeURIComponent(str)
|
||||
.replace(/[!'()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
return encodeURIComponent(str).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
}
|
||||
|
||||
async function getSigningKey(
|
||||
secret: string,
|
||||
date: string,
|
||||
region: string,
|
||||
service: string
|
||||
): Promise<ArrayBuffer> {
|
||||
async function getSigningKey(secret: string, date: string, region: string, service: string): Promise<ArrayBuffer> {
|
||||
const kDate = await hmacSha256("AWS4" + secret, date);
|
||||
const kRegion = await hmacSha256(kDate, region);
|
||||
const kService = await hmacSha256(kRegion, service);
|
||||
return await hmacSha256(kService, "aws4_request");
|
||||
}
|
||||
|
||||
async function hmacSha256(
|
||||
key: string | ArrayBuffer,
|
||||
data: string
|
||||
): Promise<ArrayBuffer> {
|
||||
async function hmacSha256(key: string | ArrayBuffer, data: string): Promise<ArrayBuffer> {
|
||||
const keyData = typeof key === "string" ? new TextEncoder().encode(key) : key;
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyData,
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
const cryptoKey = await crypto.subtle.importKey("raw", keyData, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
||||
return await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(data));
|
||||
}
|
||||
|
||||
@@ -211,6 +200,6 @@ async function sha256(data: string): Promise<string> {
|
||||
|
||||
function bufToHex(buf: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map(b => b.toString(16).padStart(2, "0"))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { env, createExecutionContext, waitOnExecutionContext, SELF } from "cloudflare:test";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import worker from "../src/index";
|
||||
|
||||
// For now, you'll need to do something like this to get a correctly-typed
|
||||
// `Request` to pass to `worker.fetch()`.
|
||||
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;
|
||||
|
||||
describe("Hello World worker", () => {
|
||||
it("responds with Hello World! (unit style)", async () => {
|
||||
const request = new IncomingRequest("http://example.com");
|
||||
// Create an empty context to pass to `worker.fetch()`.
|
||||
const ctx = createExecutionContext();
|
||||
const response = await worker.fetch(request, env, ctx);
|
||||
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
|
||||
await waitOnExecutionContext(ctx);
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
||||
});
|
||||
|
||||
it("responds with Hello World! (integration style)", async () => {
|
||||
const response = await SELF.fetch("https://example.com");
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
||||
});
|
||||
});
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { AwsClient } from "aws4fetch";
|
||||
import worker from "../src/index";
|
||||
|
||||
const ENV = {
|
||||
ACCESS_KEY: "test-access-key",
|
||||
SECRET_KEY: "test-secret-key",
|
||||
REGION: "auto",
|
||||
};
|
||||
|
||||
const CTX = {
|
||||
waitUntil: vi.fn(),
|
||||
passThroughOnException: vi.fn(),
|
||||
};
|
||||
|
||||
describe("S3 API Server Authentication", () => {
|
||||
const endpoint = "https://s3-api.example.com";
|
||||
|
||||
it("should verify requests from aws4fetch (Header Auth)", async () => {
|
||||
const aws4 = new AwsClient({
|
||||
accessKeyId: ENV.ACCESS_KEY,
|
||||
secretAccessKey: ENV.SECRET_KEY,
|
||||
region: ENV.REGION,
|
||||
service: "s3",
|
||||
});
|
||||
|
||||
const requestUrl = `${endpoint}/test-bucket/test-file.txt`;
|
||||
const signedReq = await aws4.sign(requestUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"x-amz-content-sha256": "UNSIGNED-PAYLOAD",
|
||||
},
|
||||
body: "hello world",
|
||||
});
|
||||
|
||||
const response = await worker.fetch(signedReq, ENV, CTX);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toContain("Verified PUT");
|
||||
});
|
||||
|
||||
it("should verify presigned URLs from @aws-sdk/s3-request-presigner", async () => {
|
||||
const s3 = new S3Client({
|
||||
region: ENV.REGION,
|
||||
credentials: {
|
||||
accessKeyId: ENV.ACCESS_KEY,
|
||||
secretAccessKey: ENV.SECRET_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: "my-bucket",
|
||||
Key: "test.png",
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(s3, command, { expiresIn: 3600 });
|
||||
|
||||
console.log("Presigned URL:", url);
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
const testUrl = new URL(endpoint);
|
||||
testUrl.hostname = parsedUrl.hostname;
|
||||
testUrl.pathname = parsedUrl.pathname;
|
||||
testUrl.search = parsedUrl.search;
|
||||
|
||||
const request = new Request(testUrl.toString(), { method: "GET" });
|
||||
const response = await worker.fetch(request, ENV, CTX);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toContain("Verified GET");
|
||||
});
|
||||
|
||||
it("should verify requests from aws4fetch with query parameters", async () => {
|
||||
const aws4 = new AwsClient({
|
||||
accessKeyId: ENV.ACCESS_KEY,
|
||||
secretAccessKey: ENV.SECRET_KEY,
|
||||
region: ENV.REGION,
|
||||
service: "s3",
|
||||
});
|
||||
|
||||
const requestUrl = `${endpoint}/test-bucket/aws4-fetch-test`;
|
||||
const signedReq = await aws4.sign(requestUrl, {
|
||||
method: "GET",
|
||||
aws: { signQuery: true }, // クエリパラメータ署名
|
||||
});
|
||||
|
||||
const response = await worker.fetch(signedReq, ENV, CTX);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should reject invalid signatures", async () => {
|
||||
const request = new Request(`${endpoint}/hack`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: "AWS4-HMAC-SHA256 Credential=bad/20260108/auto/s3/aws4_request, SignedHeaders=host, Signature=wrong",
|
||||
"x-amz-date": "20260108T000000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await worker.fetch(request, ENV, CTX);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
// 5. DELETE メソッドのテスト
|
||||
it("should verify DELETE requests", async () => {
|
||||
const aws4 = new AwsClient({
|
||||
accessKeyId: ENV.ACCESS_KEY,
|
||||
secretAccessKey: ENV.SECRET_KEY,
|
||||
region: ENV.REGION,
|
||||
service: "s3",
|
||||
});
|
||||
|
||||
const requestUrl = `${endpoint}/test-bucket/file-to-delete.txt`;
|
||||
const signedReq = await aws4.sign(requestUrl, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"x-amz-content-sha256": "UNSIGNED-PAYLOAD",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await worker.fetch(signedReq, ENV, CTX);
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
});
|
||||
+3
-7
@@ -1,11 +1,7 @@
|
||||
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
|
||||
import { configDefaults, defineConfig } from "vitest/config";
|
||||
|
||||
export default defineWorkersConfig({
|
||||
export default defineConfig({
|
||||
test: {
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: "./wrangler.jsonc" },
|
||||
},
|
||||
},
|
||||
exclude: [...configDefaults.exclude, ".pnpm-wrangler/**", ".pnpm-store/**", "node_modules/**"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user