From afc126ee82c26fbce46f67d4d44b6a8c8c010838 Mon Sep 17 00:00:00 2001 From: nexryai <61890205+nexryai@users.noreply.github.com> Date: Thu, 8 Jan 2026 00:38:24 +0000 Subject: [PATCH] WIP --- src/index.ts | 166 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 130 insertions(+), 36 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6b58d10..94ebf82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,10 @@ /** - * Welcome to Cloudflare Workers! This is your first worker. - * - * - Run `npm run dev` in your terminal to start a development server - * - Open a browser tab at http://localhost:8787/ to see your worker in action - * - Run `npm run deploy` to publish your worker - * - * Bind resources to your worker in `wrangler.jsonc`. After adding bindings, a type definition for the - * `Env` object can be regenerated with `npm run cf-typegen`. - * - * Learn more at https://developers.cloudflare.com/workers/ + * S3-Compatible API Server on Cloudflare Workers + * Supports both Header-based and Presigned URL authentication */ export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { try { const isValid = await verifySignature(request, env); if (!isValid) { @@ -22,10 +14,26 @@ export default { const url = new URL(request.url); const method = request.method; - // メモリ節約のためにReadableStream - return new Response(`Verified ${method} for ${url.pathname}`, { status: 200 }); + // ここでバックエンドストレージとの接続処理を実装 + // 例: R2, KV, または外部ストレージへのプロキシ + + if (method === "PUT" || method === "POST") { + // ストリーミングアップロード処理 + // await uploadToBackend(request.body, url.pathname); + return new Response(`Verified ${method} for ${url.pathname}`, { status: 200 }); + } else if (method === "GET") { + // ストリーミングダウンロード処理 + // const stream = await downloadFromBackend(url.pathname); + // 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 }); + } + + return new Response("Method not allowed", { status: 405 }); } catch (e) { const error = e as Error; + console.error("Error:", error); return new Response(error.message, { status: 500 }); } }, @@ -41,29 +49,54 @@ async function verifySignature(request: Request, env: Env): Promise { const url = new URL(request.url); const headers = request.headers; + // Query-based auth (Presigned URL) かどうか判定 const isQueryAuth = url.searchParams.has("X-Amz-Algorithm"); - const authHeader = headers.get("Authorization") ?? ""; - const algorithm = isQueryAuth ? url.searchParams.get("X-Amz-Algorithm") : authHeader.split(" ")[0]; + + let algorithm: string; + if (isQueryAuth) { + algorithm = url.searchParams.get("X-Amz-Algorithm") ?? ""; + } else { + const authHeader = headers.get("Authorization") ?? ""; + algorithm = authHeader.split(" ")[0]; + } - if (!algorithm || !algorithm.includes("AWS4-HMAC-SHA256")) return false; + if (!algorithm || !algorithm.includes("AWS4-HMAC-SHA256")) { + return false; + } - 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; + const date = datetime.substring(0, 8); + // Canonical Request の生成 const canonicalRequest = await createCanonicalRequest(request, isQueryAuth); const hashedCanonicalRequest = await sha256(canonicalRequest); + // 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"); const signature = await hmacSha256(signingKey, stringToSign); const signatureHex = bufToHex(signature); + // 期待される署名の取得 let expectedSignature = ""; if (isQueryAuth) { expectedSignature = url.searchParams.get("X-Amz-Signature") ?? ""; } else { + const authHeader = headers.get("Authorization") ?? ""; const match = authHeader.match(/Signature=([a-f0-9]+)/); expectedSignature = match ? match[1] : ""; } @@ -73,40 +106,101 @@ async function verifySignature(request: Request, env: Env): Promise { async function createCanonicalRequest(request: Request, isQueryAuth: boolean): Promise { const url = new URL(request.url); - - const searchParams = Array.from(url.searchParams.entries()) - .filter(([key]) => key !== "X-Amz-Signature") + + // HTTPメソッド + const method = request.method; + + // Canonical URI (パス部分) + const canonicalUri = url.pathname || "/"; + + // Canonical Query String + const params = Array.from(url.searchParams.entries()) + .filter(([key]) => key !== "X-Amz-Signature") // 署名自体は除外 .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, val]) => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`) + .map(([key, val]) => `${encodeRFC3986(key)}=${encodeRFC3986(val)}`) .join("&"); - - let signedHeadersList: string[] = []; + + // Signed Headers の取得 + let signedHeadersList: string[]; if (isQueryAuth) { signedHeadersList = (url.searchParams.get("X-Amz-SignedHeaders") ?? "host").split(";"); } else { const authHeader = request.headers.get("Authorization") ?? ""; - const match = authHeader.match(/SignedHeaders=([^,]+)/); + const match = authHeader.match(/SignedHeaders=([^,\s]+)/); signedHeadersList = match ? match[1].split(";") : ["host"]; } - - const canonicalHeaders = signedHeadersList.map((h) => `${h}:${request.headers.get(h)?.trim() ?? ""}\n`).join(""); - + + // Canonical Headers の生成 + const canonicalHeaders = signedHeadersList + .map(h => { + const headerName = h.toLowerCase(); + let headerValue = ""; + + if (headerName === "host") { + // ホストヘッダーはポート番号を除外 (標準ポートの場合) + const host = url.hostname; + const port = url.port; + if ((url.protocol === "https:" && port === "443") || + (url.protocol === "http:" && port === "80") || + !port) { + headerValue = host; + } else { + headerValue = `${host}:${port}`; + } + } else { + headerValue = request.headers.get(headerName)?.trim() ?? ""; + } + + return `${headerName}:${headerValue}\n`; + }) + .join(""); + const signedHeaders = signedHeadersList.join(";"); - const payloadHash = request.headers.get("x-amz-content-sha256") ?? "UNSIGNED-PAYLOAD"; - - return [request.method, url.pathname, searchParams, canonicalHeaders, signedHeaders, payloadHash].join("\n"); + + // Payload Hash + const payloadHash = request.headers.get("x-amz-content-sha256") ?? + (isQueryAuth ? "UNSIGNED-PAYLOAD" : "UNSIGNED-PAYLOAD"); + + return [ + method, + canonicalUri, + params, + canonicalHeaders, + signedHeaders, + payloadHash + ].join("\n"); } -async function getSigningKey(secret: string, date: string, region: string, service: string): Promise { +// RFC3986に準拠したURLエンコーディング +function encodeRFC3986(str: string): string { + return encodeURIComponent(str) + .replace(/[!'()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); +} + +async function getSigningKey( + secret: string, + date: string, + region: string, + service: string +): Promise { 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 { +async function hmacSha256( + key: string | ArrayBuffer, + data: string +): Promise { 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)); } @@ -117,6 +211,6 @@ async function sha256(data: string): Promise { 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(""); -} +} \ No newline at end of file