From 8483e050c439754d3dea4850693d705ff0a8c2e6 Mon Sep 17 00:00:00 2001 From: Fabrizio Date: Thu, 13 Jun 2024 11:26:33 +0200 Subject: [PATCH] fix: GET presigned urls (#501) --- src/storage/protocols/s3/signature-v4.ts | 337 +++++++++++------------ src/test/s3-protocol.test.ts | 20 ++ 2 files changed, 178 insertions(+), 179 deletions(-) diff --git a/src/storage/protocols/s3/signature-v4.ts b/src/storage/protocols/s3/signature-v4.ts index 169cd731..d65c92e2 100644 --- a/src/storage/protocols/s3/signature-v4.ts +++ b/src/storage/protocols/s3/signature-v4.ts @@ -74,7 +74,6 @@ export class SignatureV4 { static parseAuthorizationHeader(headers: Record) { const clientSignature = headers.authorization - if (typeof clientSignature !== 'string') { throw ERRORS.InvalidSignature('Missing authorization header') } @@ -84,15 +83,7 @@ export class SignatureV4 { throw ERRORS.InvalidSignature('Unsupported authorization type') } - const params = clientSignature - .replace('AWS4-HMAC-SHA256 ', '') - .split(',') - .reduce((values, value) => { - const [k, v] = value.split('=') - values.set(k.trim(), v) - return values - }, new Map()) - + const params = this.extractClientSignature(clientSignature) const credentialPart = params.get('Credential') const signedHeadersPart = params.get('SignedHeaders') const signature = params.get('Signature') @@ -105,7 +96,6 @@ export class SignatureV4 { } const signedHeaders = signedHeadersPart?.split(';') || [] - const credentialsPart = credentialPart?.split('/') || [] if (credentialsPart.length !== 5) { @@ -113,14 +103,8 @@ export class SignatureV4 { } const [accessKey, shortDate, region, service] = credentialsPart - return { - credentials: { - accessKey, - shortDate, - region, - service, - }, + credentials: { accessKey, shortDate, region, service }, signedHeaders, signature, longDate, @@ -138,53 +122,22 @@ export class SignatureV4 { const sessionToken = query['X-Amz-Security-Token'] const expires = query['X-Amz-Expires'] - if (!validateTypeOfStrings(credentialPart, signedHeaders, signature)) { + if (!validateTypeOfStrings(credentialPart, signedHeaders, signature, longDate)) { throw ERRORS.InvalidSignature('Invalid signature format') } if (expires) { - const expiresSec = parseInt(expires, 10) - if (isNaN(expiresSec) || expiresSec < 0) { - throw ERRORS.InvalidSignature('Invalid expiration') - } - - if (typeof longDate !== 'string') { - throw ERRORS.InvalidSignature('Invalid date') - } - - const isoLongDate = longDate.replace( - /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, - '$1-$2-$3T$4:$5:$6Z' - ) - - const expirationDate = new Date(isoLongDate) - - if (isNaN(expirationDate.getTime())) { - throw ERRORS.InvalidSignature('Invalid date') - } - expirationDate.setSeconds(expirationDate.getSeconds() + expiresSec) - - const isExpired = expirationDate < new Date() - if (isExpired) { - throw ERRORS.ExpiredSignature() - } + this.checkExpiration(longDate, expires) } const credentialsPart = credentialPart.split('/') - if (credentialsPart.length !== 5) { throw ERRORS.InvalidSignature('Invalid credentials') } const [accessKey, shortDate, region, service] = credentialsPart - return { - credentials: { - accessKey, - shortDate, - region, - service, - }, + credentials: { accessKey, shortDate, region, service }, signedHeaders: signedHeaders.split(';'), signature, longDate, @@ -193,73 +146,70 @@ export class SignatureV4 { } } + protected static checkExpiration(longDate: string, expires: string) { + const expiresSec = parseInt(expires, 10) + if (isNaN(expiresSec) || expiresSec < 0) { + throw ERRORS.InvalidSignature('Invalid expiration') + } + + const isoLongDate = longDate.replace( + /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, + '$1-$2-$3T$4:$5:$6Z' + ) + const requestDate = new Date(isoLongDate) + const expirationDate = new Date(requestDate.getTime() + expiresSec * 1000) + const isExpired = expirationDate < new Date() + + if (isExpired) { + throw ERRORS.ExpiredSignature() + } + } + + protected static extractClientSignature(clientSignature: string) { + return clientSignature + .replace('AWS4-HMAC-SHA256 ', '') + .split(',') + .reduce((values, value) => { + const [k, v] = value.split('=') + values.set(k.trim(), v) + return values + }, new Map()) + } + verify(request: SignatureRequest) { const { clientSignature, serverSignature } = this.sign(request) - // Compare the computed signature with the provided signature return crypto.timingSafeEqual(Buffer.from(clientSignature), Buffer.from(serverSignature)) } sign(request: SignatureRequest) { - if (request.clientSignature.credentials.accessKey !== this.serverCredentials.accessKey) { - throw ERRORS.AccessDenied('Invalid Access Key') - } - - // Ensure the region and service match the expected values - if ( - this.enforceRegion && - request.clientSignature.credentials.region !== this.serverCredentials.region - ) { - throw ERRORS.AccessDenied('Invalid Region') - } + const clientSignature = request.clientSignature + const serverCredentials = this.serverCredentials - if (request.clientSignature.credentials.service !== this.serverCredentials.service) { - throw ERRORS.AccessDenied('Invalid Service') - } + this.validateCredentials(clientSignature.credentials) - const longDate = request.clientSignature.longDate + const longDate = clientSignature.longDate if (!longDate) { - throw ERRORS.AccessDenied('No date header provided') + throw ERRORS.AccessDenied('No date provided') } - // When enforcing region is false, we allow the region to be: - // - auto - // - us-east-1 - // - the region set in the env - if ( - !this.enforceRegion && - !['auto', 'us-east-1', this.serverCredentials.region, ''].includes( - request.clientSignature.credentials.region - ) - ) { - throw ERRORS.AccessDenied('Invalid Region') - } - - const selectedRegion = this.enforceRegion - ? this.serverCredentials.region - : request.clientSignature.credentials.region - - // Construct the Canonical Request and String to Sign - const canonicalRequest = this.constructCanonicalRequest( - request, - request.clientSignature.signedHeaders - ) + const selectedRegion = this.getSelectedRegion(clientSignature.credentials.region) + const canonicalRequest = this.constructCanonicalRequest(request, clientSignature.signedHeaders) const stringToSign = this.constructStringToSign( longDate, - request.clientSignature.credentials.shortDate, + clientSignature.credentials.shortDate, selectedRegion, - this.serverCredentials.service, + serverCredentials.service, canonicalRequest ) - const signingKey = this.signingKey( - this.serverCredentials.secretKey, - request.clientSignature.credentials.shortDate, + serverCredentials.secretKey, + clientSignature.credentials.shortDate, selectedRegion, - this.serverCredentials.service + serverCredentials.service ) return { - clientSignature: request.clientSignature.signature, + clientSignature: clientSignature.signature, serverSignature: this.hmac(signingKey, stringToSign).toString('hex'), } } @@ -267,28 +217,123 @@ export class SignatureV4 { getPayloadHash(request: SignatureRequest) { const body = request.body + // For presigned URLs and GET requests, use UNSIGNED-PAYLOAD + if (request.query && request.query['X-Amz-Signature'] && request.method === 'GET') { + return 'UNSIGNED-PAYLOAD' + } + + // If contentSha is provided, use it if (request.clientSignature.contentSha) { return request.clientSignature.contentSha } - const contentLenght = parseInt(this.getHeader(request, 'content-length') || '0', 10) - let payloadHash = '' + // If the body is undefined, use the hash of an empty string + if (body == undefined) { + return 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + } - if (body === undefined && contentLenght === 0) { - payloadHash = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' - } else if (typeof body === 'string' || ArrayBuffer.isView(body)) { - payloadHash = crypto + // Calculate the SHA256 hash of the body + if (typeof body === 'string' || ArrayBuffer.isView(body)) { + return crypto .createHash('sha256') - .update(typeof body === 'string' ? JSON.stringify(body) : Buffer.from(body.buffer)) + .update(typeof body === 'string' ? body : Buffer.from(body.buffer)) .digest('hex') - } else { - payloadHash = 'UNSIGNED-PAYLOAD' } - return payloadHash + // Default to UNSIGNED-PAYLOAD if body is not a string or ArrayBuffer + return 'UNSIGNED-PAYLOAD' + } + + protected constructCanonicalRequest(request: SignatureRequest, signedHeaders: string[]) { + const method = request.method + const canonicalUri = new URL(`http://localhost:8080${request.prefix || ''}${request.url}`) + .pathname + const canonicalQueryString = this.constructCanonicalQueryString(request.query || {}) + const canonicalHeaders = this.constructCanonicalHeaders(request, signedHeaders) + const signedHeadersString = signedHeaders.sort().join(';') + const payloadHash = this.getPayloadHash(request) + + return `${method}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeadersString}\n${payloadHash}` + } + + protected constructCanonicalQueryString(query: Record) { + return Object.keys(query) + .filter((key) => !(key in ALWAYS_UNSIGNABLE_QUERY_PARAMS)) + .sort() + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(query[key] as string)}`) + .join('&') } - constructStringToSign( + protected constructCanonicalHeaders(request: SignatureRequest, signedHeaders: string[]) { + return ( + signedHeaders + .filter( + (header) => + request.headers[header] !== undefined && + !(header.toLowerCase() in ALWAYS_UNSIGNABLE_HEADERS) + ) + .sort() + .map((header) => { + if (header === 'host') { + return this.getHostHeader(request) + } + return `${header}:${this.getHeader(request, header)}` + }) + .join('\n') + '\n' + ) + } + + protected getHostHeader(request: SignatureRequest) { + if (this.allowForwardedHeader) { + const forwarded = this.getHeader(request, 'forwarded') + if (forwarded) { + const extractedHost = /host="?([^";]+)/.exec(forwarded)?.[1] + if (extractedHost) { + return `host:${extractedHost.toLowerCase()}` + } + } + } + + if (this.nonCanonicalForwardedHost) { + const xForwardedHost = this.getHeader(request, this.nonCanonicalForwardedHost.toLowerCase()) + if (xForwardedHost) { + return `host:${xForwardedHost.toLowerCase()}` + } + } + + const xForwardedHost = this.getHeader(request, 'x-forwarded-host') + if (xForwardedHost) { + return `host:${xForwardedHost.toLowerCase()}` + } + + return `host:${this.getHeader(request, 'host')}` + } + + protected validateCredentials(credentials: Credentials) { + if (credentials.accessKey !== this.serverCredentials.accessKey) { + throw ERRORS.AccessDenied('Invalid Access Key') + } + + if (this.enforceRegion && credentials.region !== this.serverCredentials.region) { + throw ERRORS.AccessDenied('Invalid Region') + } + + if (credentials.service !== this.serverCredentials.service) { + throw ERRORS.AccessDenied('Invalid Service') + } + } + + protected getSelectedRegion(clientRegion: string) { + if ( + !this.enforceRegion && + ['auto', 'us-east-1', this.serverCredentials.region, ''].includes(clientRegion) + ) { + return clientRegion + } + return this.serverCredentials.region + } + + protected constructStringToSign( date: string, dateStamp: string, region: string, @@ -305,84 +350,20 @@ export class SignatureV4 { return `${algorithm}\n${date}\n${credentialScope}\n${hashedCanonicalRequest}` } - hmac(key: string | Buffer, data: string): Buffer { - return crypto.createHmac('sha256', key).update(data).digest() - } - protected signingKey( key: string, dateStamp: string, regionName: string, serviceName: string ): Buffer { - const kDate = this.hmac('AWS4' + key, dateStamp) + const kDate = this.hmac(`AWS4${key}`, dateStamp) const kRegion = this.hmac(kDate, regionName) const kService = this.hmac(kRegion, serviceName) return this.hmac(kService, 'aws4_request') } - protected constructCanonicalRequest(request: SignatureRequest, signedHeaders: string[]) { - const method = request.method - const canonicalUri = new URL(`http://localhost:8080${request.prefix || ''}${request.url}`) - .pathname - - const canonicalQueryString = Object.keys((request.query as object) || {}) - .filter((key) => !(key in ALWAYS_UNSIGNABLE_QUERY_PARAMS)) - .sort() - .map( - (key) => - `${encodeURIComponent(key)}=${encodeURIComponent((request.query as any)[key] as string)}` - ) - .join('&') - - const canonicalHeaders = - signedHeaders - .filter( - (header) => - request.headers[header] !== undefined && - !(header.toLowerCase() in ALWAYS_UNSIGNABLE_HEADERS) - ) - .sort() - .map((header) => { - if (header === 'host') { - if (this.allowForwardedHeader) { - const forwarded = this.getHeader(request, 'forwarded') - if (forwarded) { - const extractedHost = /host="?([^";]+)/.exec(forwarded)?.[1] - if (extractedHost) { - return `host:${extractedHost.toLowerCase()}` - } - } - } - - if (this.nonCanonicalForwardedHost) { - const xForwardedHost = this.getHeader( - request, - this.nonCanonicalForwardedHost.toLowerCase() - ) - - if (xForwardedHost) { - return `host:${xForwardedHost.toLowerCase()}` - } - } - - const xForwardedHost = this.getHeader(request, 'x-forwarded-host') - if (xForwardedHost) { - return `host:${xForwardedHost.toLowerCase()}` - } - } - - return `${header.toLowerCase()}:${ - (request.headers[header.toLowerCase()] || '') as string - }` - }) - .join('\n') + '\n' - - const signedHeadersString = signedHeaders.sort().join(';') - - const payloadHash = this.getPayloadHash(request) - - return `${method}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeadersString}\n${payloadHash}` + protected hmac(key: string | Buffer, data: string): Buffer { + return crypto.createHmac('sha256', key).update(data).digest() } protected getHeader(request: SignatureRequest, name: string) { @@ -395,7 +376,5 @@ export class SignatureV4 { } function validateTypeOfStrings(...values: any[]) { - return values.every((value) => { - return typeof value === 'string' - }) + return values.every((value) => typeof value === 'string') } diff --git a/src/test/s3-protocol.test.ts b/src/test/s3-protocol.test.ts index 3a450110..a383d932 100644 --- a/src/test/s3-protocol.test.ts +++ b/src/test/s3-protocol.test.ts @@ -1128,6 +1128,26 @@ describe('S3 Protocol', () => { expect(resp.ok).toBeTruthy() }) + + it('can fetch an asset via presigned URL', async () => { + const bucket = await createBucket(client) + const key = 'test-1.jpg' + + await uploadFile(client, bucket, key, 2) + + const getUrl = await getSignedUrl( + client, + new GetObjectCommand({ + Bucket: bucket, + Key: key, + }), + { expiresIn: 100 } + ) + + const resp = await fetch(getUrl) + + expect(resp.ok).toBeTruthy() + }) }) }) })