From 084ff3fb9dfce2b2af1aa2180410897adbb5df9a Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 6 Jul 2026 15:31:06 +0700 Subject: [PATCH] fix: add region validation, fix presigned URL canonical request (add method, fix headers), normalizeUri decode --- src/utils/s3/auth.ts | 21 ++++++++++++++------- src/utils/s3/xml.ts | 8 ++++++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/utils/s3/auth.ts b/src/utils/s3/auth.ts index 950625d..57befb8 100644 --- a/src/utils/s3/auth.ts +++ b/src/utils/s3/auth.ts @@ -97,7 +97,8 @@ const buildCanonicalRequest = ( const normalizeUri = (uri: string): string => { if (!uri || uri === '') return '/'; - return uri; + // AWS SigV4 requires URI-decoded paths in the canonical request + return decodeURIComponent(uri); }; const buildCanonicalQueryString = (searchParams: URLSearchParams): string => { @@ -144,6 +145,10 @@ export const verifySignature = async ( return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' }; } + if (parsed.region !== region) { + return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' }; + } + const parsedUrl = new URL(url, 'http://localhost'); const canonicalUri = normalizeUri(parsedUrl.pathname); const canonicalQueryString = buildCanonicalQueryString(parsedUrl.searchParams); @@ -164,7 +169,7 @@ export const verifySignature = async ( const amzDate = headers['x-amz-date'] || ''; const dateStamp = parsed.date; - const credentialScope = `${dateStamp}/${parsed.region}/${parsed.service}/${parsed.termination}`; + const credentialScope = `${dateStamp}/${region}/${parsed.service}/${parsed.termination}`; const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${hashedCanonicalRequest}`; @@ -188,6 +193,7 @@ export const verifySignature = async ( export const verifyPresignedUrl = async ( url: string, + method: string, s3AccessKey: string, s3SecretKey: string, region: string, @@ -245,14 +251,15 @@ export const verifyPresignedUrl = async ( } const canonicalQueryString = buildCanonicalQueryString(sortedParams); - const canonicalHeaders = signedHeaders - .split(';') - .map((h) => `${h}:host\n`) + // Build canonical headers for presigned URL — only 'host' is typically signed + const signedHeaderList = signedHeaders.split(';').filter(Boolean); + const canonicalHeaders = signedHeaderList + .map((h) => `${h.toLowerCase()}:${h === 'host' ? parsedUrl.host : ''}\n`) .join(''); - const signedHeadersStr = signedHeaders; + const hashedPayload = 'UNSIGNED-PAYLOAD'; - const canonicalRequest = `${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeadersStr}\n${hashedPayload}`; + const canonicalRequest = `${method}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeaders}\n${hashedPayload}`; const hashedCanonicalRequest = await sha256Hex(canonicalRequest); const credentialScope = `${dateStamp}/${region}/s3/aws4_request`; diff --git a/src/utils/s3/xml.ts b/src/utils/s3/xml.ts index d52c19c..a37a3c5 100644 --- a/src/utils/s3/xml.ts +++ b/src/utils/s3/xml.ts @@ -188,10 +188,14 @@ export const s3ErrorResponse = ( message: string, resource: string, status: number, + requestId: string = '', ): Response => - new Response(s3ErrorXml(code, message, resource, ''), { + new Response(s3ErrorXml(code, message, resource, requestId), { status, - headers: { 'content-type': 'application/xml' }, + headers: { + 'content-type': 'application/xml', + ...(requestId ? { 'x-amz-request-id': requestId } : {}), + }, }); // ─────── DeleteObjects XML parser ───────