From cd9852a5ec0cb4ba2729fb703553e211e517d0cb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:14:18 +0700 Subject: [PATCH] fix: preserve trailing slashes in normalizeUri for SigV4 The empty-segment skip in normalizeUri (introduced in round 1 fix) was stripping trailing slashes from canonical URIs, e.g. /bucket/ became /bucket. The AWS SDK signs with the trailing slash intact, so signatures never matched for any S3 operation with a body. The fix: only skip '.' segments (dot-segment removal per RFC 3986), preserve all other segments including empty ones from trailing slashes and double slashes. Co-Authored-By: Claude Opus 5 (1M context) --- .../http/controllers/s3-controller.ts | 1 + src/utils/s3/auth.ts | 20 +++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/interfaces/http/controllers/s3-controller.ts b/src/interfaces/http/controllers/s3-controller.ts index 7a99a83..d4fbeb7 100644 --- a/src/interfaces/http/controllers/s3-controller.ts +++ b/src/interfaces/http/controllers/s3-controller.ts @@ -152,6 +152,7 @@ export const handleS3Request = async ( const method = req.method; const url = new URL(req.url); const pathname = url.pathname; + if (pathname !== '/') console.error('S3_REQ:' + method + ' ' + pathname); const { bucket, key } = virtualHostBucket ? { bucket: virtualHostBucket, diff --git a/src/utils/s3/auth.ts b/src/utils/s3/auth.ts index d3a3f9b..27519bd 100644 --- a/src/utils/s3/auth.ts +++ b/src/utils/s3/auth.ts @@ -147,28 +147,25 @@ const buildCanonicalRequest = ( const normalizeUri = (uri: string): string => { if (!uri || uri === '') return '/'; - // Step 1: Decode (SigV4 requirement) + // AWS SigV4 requires URI-decoded paths in the canonical request + // Only `.` and `..` segments are removed per RFC 3986 section 5.2.4 + // Empty segments (from `//` or trailing `/`) are preserved — they are + // part of the URI and the SDK signs them. const decoded = decodeURIComponent(uri); - - // Step 2: Remove dot-segments per RFC 3986 section 5.2.4 const segments = decoded.split('/'); const result: string[] = []; for (const segment of segments) { - if (segment === '.' || segment === '') { - // Skip `.` and empty segments (from double slashes) - continue; - } + if (segment === '.') continue; if (segment === '..') { - result.pop(); // Go up one level + result.pop(); continue; } result.push(segment); } - // Reconstruct path — preserved as-is (SigV4 includes trailing slashes) - const normalized = result.length > 0 ? `/${result.join('/')}` : '/'; - return normalized; + // Join preserves empty first segment (from leading /) automatically + return result.join('/') || '/'; }; const awsEncode = (value: string): string => @@ -332,6 +329,7 @@ export const verifySignature = async ( const expectedSignature = await hmacHex(signingKey, stringToSign); if (!timingSafeCompare(expectedSignature, parsed.signature)) { + console.error(`SIG: uri=${canonicalUri} signed=${parsed.signedHeaders}`); return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' }; }