From 5253fb384dd91d59d0a3bdcf0941b4b9fb529204 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 7 Jul 2026 04:17:37 +0700 Subject: [PATCH] fix: verify presigned S3 URLs against public host --- src/routes/s3.ts | 25 ++++--- src/utils/s3/auth.ts | 155 +++++++++++++++++++++++++------------------ test/s3-auth.test.ts | 95 ++++++++++++++++++++++++-- 3 files changed, 194 insertions(+), 81 deletions(-) diff --git a/src/routes/s3.ts b/src/routes/s3.ts index aea45b0..3074e13 100644 --- a/src/routes/s3.ts +++ b/src/routes/s3.ts @@ -160,7 +160,8 @@ export const handleS3Request = async (req: Request): Promise => { } // Standard object operations - if (method === 'GET') return handleGetObject(bucket, key, searchParams, reqId); + if (method === 'GET') + return handleGetObject(bucket, key, searchParams, headers, req.url, reqId); if (method === 'HEAD') return handleHeadObject(bucket, key, reqId); if (method === 'PUT') return handlePutObject(bucket, key, searchParams, headers, req, reqId); if (method === 'DELETE') return handleDeleteObject(bucket, key, reqId); @@ -264,21 +265,23 @@ const handleGetObject = async ( bucket: string, key: string, searchParams: URLSearchParams, + headers: Record, + requestUrl: string, reqId: string, ): Promise => { if (searchParams.has('X-Amz-Signature')) { - const fullUrl = `http://localhost/${bucket}/${key}?${searchParams.toString()}`; - const presignedResult = await verifyPresignedUrl( - fullUrl, - 'GET', - config.s3AccessKey, - config.s3SecretKey, - REGION, - ); + const presignedResult = await verifyPresignedUrl({ + url: requestUrl, + method: 'GET', + headers, + s3AccessKey: config.s3AccessKey, + s3SecretKey: config.s3SecretKey, + region: REGION, + }); if (!presignedResult.isValid) { return s3ErrorResponse( - 'AccessDenied', - 'Request has expired', + presignedResult.errorCode || 'AccessDenied', + 'Presigned URL verification failed', `/${bucket}/${key}`, 403, reqId, diff --git a/src/utils/s3/auth.ts b/src/utils/s3/auth.ts index 56a8532..830c32c 100644 --- a/src/utils/s3/auth.ts +++ b/src/utils/s3/auth.ts @@ -9,6 +9,16 @@ export interface SigV4Result { errorCode?: string; } +export interface VerifyPresignedUrlInput { + url: string; + method: string; + headers: Record; + s3AccessKey: string; + s3SecretKey: string; + region: string; + now?: Date; +} + const SERVICE = 's3'; const TERMINATION = 'aws4_request'; @@ -101,16 +111,26 @@ const normalizeUri = (uri: string): string => { return decodeURIComponent(uri); }; -const buildCanonicalQueryString = (searchParams: URLSearchParams): string => { - const params: string[] = []; - const keys = Array.from(searchParams.keys()).sort(); - for (const key of keys) { - const values = searchParams.getAll(key).sort(); - for (const value of values) { - params.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); - } +const awsEncode = (value: string): string => + encodeURIComponent(value).replace( + /[!'()*]/g, + (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`, + ); + +export const buildCanonicalQueryString = ( + searchParams: URLSearchParams, + excludeKeys: Set = new Set(), +): string => { + const pairs: Array<[string, string]> = []; + for (const [key, value] of searchParams.entries()) { + if (!excludeKeys.has(key)) pairs.push([key, value]); } - return params.join('&'); + pairs.sort(([ak, av], [bk, bv]) => { + const a = `${awsEncode(ak)}=${awsEncode(av)}`; + const b = `${awsEncode(bk)}=${awsEncode(bv)}`; + return a.localeCompare(b); + }); + return pairs.map(([key, value]) => `${awsEncode(key)}=${awsEncode(value)}`).join('&'); }; const getHashedPayload = async ( @@ -191,88 +211,97 @@ export const verifySignature = async ( }; }; -export const verifyPresignedUrl = async ( - url: string, - method: string, - s3AccessKey: string, - s3SecretKey: string, - region: string, -): Promise => { - const parsedUrl = new URL(url); - const queryParams = Object.fromEntries(parsedUrl.searchParams.entries()); +const parseAmzDateUtc = (amzDate: string): Date | null => { + const match = amzDate.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/); + if (!match) return null; + const [, year, month, day, hour, minute, second] = match; + return new Date( + Date.UTC( + Number.parseInt(year, 10), + Number.parseInt(month, 10) - 1, + Number.parseInt(day, 10), + Number.parseInt(hour, 10), + Number.parseInt(minute, 10), + Number.parseInt(second, 10), + ), + ); +}; - const algorithm = queryParams['X-Amz-Algorithm']; - const credential = queryParams['X-Amz-Credential']; - const signedHeaders = queryParams['X-Amz-SignedHeaders']; - const signature = queryParams['X-Amz-Signature']; - const expires = parseInt(queryParams['X-Amz-Expires'] || '0', 10); - const amzDate = queryParams['X-Amz-Date']; +export const verifyPresignedUrl = async ({ + url, + method, + headers, + s3AccessKey, + s3SecretKey, + region, + now = new Date(), +}: VerifyPresignedUrlInput): Promise => { + const parsedUrl = new URL(url); + const searchParams = parsedUrl.searchParams; + + const algorithm = searchParams.get('X-Amz-Algorithm'); + const credential = searchParams.get('X-Amz-Credential'); + const signedHeaders = searchParams.get('X-Amz-SignedHeaders'); + const signature = searchParams.get('X-Amz-Signature'); + const expiresText = searchParams.get('X-Amz-Expires'); + const amzDate = searchParams.get('X-Amz-Date'); if ( - !algorithm || algorithm !== 'AWS4-HMAC-SHA256' || !credential || + !signedHeaders || !signature || - !expires || + !expiresText || !amzDate ) { return { isValid: false, credential: null, errorCode: 'AccessDenied' }; } - // Check expiration - const dateObj = new Date( - parseInt(amzDate.substring(0, 4), 10), - parseInt(amzDate.substring(4, 6), 10) - 1, - parseInt(amzDate.substring(6, 8), 10), - parseInt(amzDate.substring(9, 11), 10), - parseInt(amzDate.substring(11, 13), 10), - parseInt(amzDate.substring(13, 15), 10), - ); - const expiresMs = expires * 1000; - if (Date.now() > dateObj.getTime() + expiresMs) { + const expires = Number.parseInt(expiresText, 10); + const signedAt = parseAmzDateUtc(amzDate); + if (!Number.isFinite(expires) || expires <= 0 || !signedAt) { + return { isValid: false, credential: null, errorCode: 'AccessDenied' }; + } + if (now.getTime() > signedAt.getTime() + expires * 1000) { return { isValid: false, credential: null, errorCode: 'AccessDenied' }; } const credParts = credential.split('/'); - const presignedAccessKey = credParts[0]; - if (presignedAccessKey !== s3AccessKey) { + if (credParts.length !== 5) { + return { isValid: false, credential: null, errorCode: 'AccessDenied' }; + } + const [accessKey, dateStamp, credentialRegion, service, termination] = credParts; + if ( + accessKey !== s3AccessKey || + credentialRegion !== region || + service !== SERVICE || + termination !== TERMINATION + ) { return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' }; } - const dateStamp = credParts[1] || amzDate.substring(0, 8); - const canonicalUri = normalizeUri(parsedUrl.pathname); - - const sortedParams = new URLSearchParams(); - const paramKeys = Object.keys(queryParams).sort(); - for (const key of paramKeys) { - if (key !== 'X-Amz-Signature') { - sortedParams.append(key, queryParams[key]); - } - } - const canonicalQueryString = buildCanonicalQueryString(sortedParams); - - // 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`) + .map((headerName) => { + const lower = headerName.toLowerCase(); + const value = lower === 'host' ? headers.host || parsedUrl.host : headers[lower] || ''; + return `${lower}:${value.trim()}\n`; + }) .join(''); - const hashedPayload = 'UNSIGNED-PAYLOAD'; - - const canonicalRequest = `${method}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeaders}\n${hashedPayload}`; + const canonicalRequest = `${method}\n${normalizeUri(parsedUrl.pathname)}\n${buildCanonicalQueryString(searchParams, new Set(['X-Amz-Signature']))}\n${canonicalHeaders}\n${signedHeaders}\nUNSIGNED-PAYLOAD`; const hashedCanonicalRequest = await sha256Hex(canonicalRequest); - - const credentialScope = `${dateStamp}/${region}/s3/aws4_request`; + const credentialScope = `${dateStamp}/${region}/${SERVICE}/${TERMINATION}`; const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${hashedCanonicalRequest}`; - - const signingKey = await getSigningKey(s3SecretKey, dateStamp, region); - const expectedSignature = await hmacHex(signingKey, stringToSign); + const expectedSignature = await hmacHex( + await getSigningKey(s3SecretKey, dateStamp, region), + stringToSign, + ); if (expectedSignature !== signature) { return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' }; } - - return { isValid: true, credential: null }; + return { isValid: true, credential: { accessKey, date: dateStamp, region, service } }; }; export const isS3Request = (headers: Record): boolean => { diff --git a/test/s3-auth.test.ts b/test/s3-auth.test.ts index af0968a..ef6a8f0 100644 --- a/test/s3-auth.test.ts +++ b/test/s3-auth.test.ts @@ -73,14 +73,95 @@ describe('S3 Auth (SigV4)', () => { }); it('rejects malformed presigned URLs', async () => { - const result = await verifyPresignedUrl( - 'http://localhost/bucket/key', - 'GET', - 'key', - 'secret', - 'us-east-1', - ); + const result = await verifyPresignedUrl({ + url: 'http://localhost/bucket/key', + method: 'GET', + headers: { host: 'localhost' }, + s3AccessKey: 'key', + s3SecretKey: 'secret', + region: 'us-east-1', + }); expect(result.isValid).toBe(false); expect(result.errorCode).toBe('AccessDenied'); }); + + const sha256hex = (data: string): string => { + const h = new Bun.CryptoHasher('sha256'); + h.update(data); + return Array.from(h.digest()) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + }; + + const hmacSha256 = (key: Uint8Array, msg: string): Uint8Array => { + const h = new Bun.CryptoHasher('sha256', key); + h.update(msg); + return h.digest(); + }; + + const signingKey = (secret: string, date: string, region: string): Uint8Array => { + const enc = (s: string) => new TextEncoder().encode(s); + let k = hmacSha256(enc(`AWS4${secret}`), date); + k = hmacSha256(k, region); + k = hmacSha256(k, 's3'); + return hmacSha256(k, 'aws4_request'); + }; + + const hex = (bytes: Uint8Array): string => + Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + it('verifies presigned GET using the public request host', async () => { + const accessKey = 'teleuploader-admin'; + const secret = 'unit-test-secret'; + const host = 'upload.example.test'; + const path = '/bucket/key.txt'; + const amzDate = '20260707T120000Z'; + const dateStamp = '20260707'; + const sp = new URLSearchParams({ + 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', + 'X-Amz-Credential': `${accessKey}/${dateStamp}/us-east-1/s3/aws4_request`, + 'X-Amz-Date': amzDate, + 'X-Amz-Expires': '3600', + 'X-Amz-SignedHeaders': 'host', + }); + const canonicalQs = [...sp.entries()] + .sort(([aKey, aVal], [bKey, bVal]) => `${aKey}=${aVal}`.localeCompare(`${bKey}=${bVal}`)) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + const canonicalRequest = `GET\n${path}\n${canonicalQs}\nhost:${host}\n\nhost\nUNSIGNED-PAYLOAD`; + const hashedCanonical = sha256hex(canonicalRequest); + const scope = `${dateStamp}/us-east-1/s3/aws4_request`; + const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${scope}\n${hashedCanonical}`; + const sig = hex(hmacSha256(signingKey(secret, dateStamp, 'us-east-1'), stringToSign)); + sp.set('X-Amz-Signature', sig); + + const result = await verifyPresignedUrl({ + url: `https://${host}${path}?${sp.toString()}`, + method: 'GET', + headers: { host }, + s3AccessKey: accessKey, + s3SecretKey: secret, + region: 'us-east-1', + now: new Date('2026-07-07T12:05:00Z'), + }); + + expect(result.isValid).toBe(true); + }); + + it('rejects presigned URLs signed for a different host', async () => { + const result = await verifyPresignedUrl({ + url: 'https://wrong.example.test/bucket/key.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=teleuploader-admin%2F20260707%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260707T120000Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=00', + method: 'GET', + headers: { host: 'upload.example.test' }, + s3AccessKey: 'teleuploader-admin', + s3SecretKey: 'unit-test-secret', + region: 'us-east-1', + now: new Date('2026-07-07T12:05:00Z'), + }); + + expect(result.isValid).toBe(false); + expect(result.errorCode).toBe('SignatureDoesNotMatch'); + }); });