diff --git a/README.md b/README.md index 5ecb9fa..9b5d656 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ https://developers.cloudflare.com/workers/configuration/secrets/#via-the-dashboa | `REGION` | The region used by the S3 client. | | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REFRESH_TOKEN` | Google API credentials obtained from rclone. | | `ALLOWED_BUCKETS` | Set the buckets allowed, separated by `,`. A directory with the bucket name will be created directly under Google Drive. | +| `PUBLIC_READ_BUCKETS` | *(Optional)* Buckets that allow unauthenticated GET/HEAD access without signature, separated by `,`. Write operations (PUT/POST/DELETE) still require authentication. Must be a subset of `ALLOWED_BUCKETS`. | ### 4. CORS Configuration diff --git a/src/index.ts b/src/index.ts index 27f471b..41c1130 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,9 +22,14 @@ export default { return new Response("Access denied to this bucket", { status: 403 }); } - const isValid = await verifySignature(request, env); - if (!isValid) { - return new Response("Invalid Signature", { status: 403 }); + const isPublicRead = isPublicReadBucket(bucket, env); + const isReadMethod = method === "GET" || method === "HEAD"; + + if (!(isPublicRead && isReadMethod)) { + const isValid = await verifySignature(request, env); + if (!isValid) { + return new Response("Invalid Signature", { status: 403 }); + } } const accessToken = await getAccessToken(env); @@ -135,6 +140,7 @@ interface Env { AUTH_KV: KVNamespace; FOLDER_CACHE: KVNamespace; ALLOWED_BUCKETS?: string; + PUBLIC_READ_BUCKETS?: string; } interface GoogleDriveFile { @@ -169,6 +175,18 @@ function isAllowedBucket(bucket: string, env: Env): boolean { return allowedBuckets.includes(bucket); } +function isPublicReadBucket(bucket: string, env: Env): boolean { + if (!env.PUBLIC_READ_BUCKETS) { + return false; + } + + const publicReadBuckets = env.PUBLIC_READ_BUCKETS.split(",") + .map((b) => b.trim()) + .filter((b) => b); + + return publicReadBuckets.includes(bucket); +} + // ======================================== // Google Drive API Functions // ======================================== diff --git a/test/s3.test.ts b/test/s3.test.ts index 8536a36..675c0cd 100644 --- a/test/s3.test.ts +++ b/test/s3.test.ts @@ -1017,4 +1017,81 @@ describe("S3 API Server with Google Drive Backend", () => { global.fetch = originalFetch; }); + + // ======================================== + // Public Read Tests + // ======================================== + + const PUBLIC_READ_ENV = { + ...ENV, + PUBLIC_READ_BUCKETS: "public-bucket", + ALLOWED_BUCKETS: "test-bucket,empty-bucket,my-bucket,public-bucket", + }; + + // 16. Public read bucket - unsigned GET should succeed + it("should allow unsigned GET on public read bucket", async () => { + const request = new Request(`${endpoint}/public-bucket/test-file.txt`, { + method: "GET", + }); + + const response = await worker.fetch(request, PUBLIC_READ_ENV, CTX); + expect(response.status).toBe(200); + expect(await response.text()).toBe("Hello World"); + }); + + // 17. Public read bucket - unsigned HEAD should succeed + it("should allow unsigned HEAD on public read bucket", async () => { + const request = new Request(`${endpoint}/public-bucket/test-file.txt`, { + method: "HEAD", + }); + + const response = await worker.fetch(request, PUBLIC_READ_ENV, CTX); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/plain"); + expect(response.headers.get("Content-Length")).toBe("11"); + }); + + // 18. Public read bucket - unsigned PUT should be rejected + it("should reject unsigned PUT on public read bucket", async () => { + const request = new Request(`${endpoint}/public-bucket/test-file.txt`, { + method: "PUT", + headers: { "Content-Type": "text/plain" }, + body: "Hello World", + }); + + const response = await worker.fetch(request, PUBLIC_READ_ENV, CTX); + expect(response.status).toBe(403); + }); + + // 19. Public read bucket - unsigned DELETE should be rejected + it("should reject unsigned DELETE on public read bucket", async () => { + const request = new Request(`${endpoint}/public-bucket/test-file.txt`, { + method: "DELETE", + }); + + const response = await worker.fetch(request, PUBLIC_READ_ENV, CTX); + expect(response.status).toBe(403); + }); + + // 20. Non-public bucket - unsigned GET should still be rejected + it("should reject unsigned GET on non-public bucket", async () => { + const request = new Request(`${endpoint}/test-bucket/test-file.txt`, { + method: "GET", + }); + + const response = await worker.fetch(request, PUBLIC_READ_ENV, CTX); + expect(response.status).toBe(403); + }); + + // 21. Public read bucket - unsigned list (GET without key) should succeed + it("should allow unsigned list on public read bucket", async () => { + const request = new Request(`${endpoint}/public-bucket/`, { + method: "GET", + }); + + const response = await worker.fetch(request, PUBLIC_READ_ENV, CTX); + expect(response.status).toBe(200); + const xmlText = await response.text(); + expect(xmlText).toContain("