diff --git a/deployment/serverless-image-handler.template b/deployment/serverless-image-handler.template index 25277b42e..7250bf18b 100644 --- a/deployment/serverless-image-handler.template +++ b/deployment/serverless-image-handler.template @@ -36,6 +36,11 @@ "Default" : "No", "Type" : "String", "AllowedValues" : [ "Yes", "No" ] + }, + "SignatureKey" : { + "Description" : "If you would like to use signed URLs, please specify a secret signature key here. Signature key will be used to validate base64 payloads.", + "Default" : "", + "Type" : "String" } }, "Metadata": { @@ -391,6 +396,9 @@ "SOURCE_BUCKETS" : { "Ref" : "SourceBuckets" }, + "SIGNATURE_KEY": { + "Ref" : "SignatureKey" + }, "REWRITE_MATCH_PATTERN" : "", "REWRITE_SUBSTITUTION" : "" } @@ -847,6 +855,10 @@ "Description" : "Origin value returned in the Access-Control-Allow-Origin header of image handler API responses.", "Value" : { "Ref" : "CorsOrigin" } }, + "SignatureKey" : { + "Description" : "Key used to sign URLs.", + "Value" : { "Ref" : "SignatureKey" } + }, "LogRetentionPeriod" : { "Description" : "Number of days for event logs from Lambda to be retained in CloudWatch.", "Value" : { "Ref" : "LogRetentionPeriod" } diff --git a/source/image-handler/image-request.js b/source/image-handler/image-request.js index 2a47530da..bf87fd984 100644 --- a/source/image-handler/image-request.js +++ b/source/image-handler/image-request.js @@ -207,7 +207,7 @@ class ImageRequest { parseRequestType(event) { const path = event["path"]; // ---- - const matchDefault = new RegExp(/^(\/?)([0-9a-zA-Z+\/]{4})*(([0-9a-zA-Z+\/]{2}==)|([0-9a-zA-Z+\/]{3}=))?$/); + const matchDefault = new RegExp(/^(\/?)([0-9a-zA-Z+\/]{4})*(([0-9a-zA-Z+\/]{2}==)|([0-9a-zA-Z+\/]{3}=))?(--([0-9a-zA-Z]{1,}))?$/); const matchThumbor = new RegExp(/^(\/?)((fit-in)?|(filters:.+\(.?\))?|(unsafe)?).*(.+jpg|.+png|.+webp|.+tiff|.+jpeg)$/i); const matchCustom = new RegExp(/(\/?)(.*)(jpg|png|webp|tiff|jpeg)/i); const definedEnvironmentVariables = ( @@ -242,7 +242,8 @@ class ImageRequest { if (path !== undefined) { const splitPath = path.split("/"); const encoded = splitPath[splitPath.length - 1]; - const toBuffer = Buffer.from(encoded, 'base64'); + const verifiedPath = this.verifySignature(encoded); + const toBuffer = Buffer.from(verifiedPath, 'base64'); try { // To support European characters, 'ascii' was removed. return JSON.parse(toBuffer.toString()); @@ -297,6 +298,48 @@ class ImageRequest { return null; } + + /** + * Verifies base64 signature, using SIGNATURE_KEY env var. If SIGNATURE_KEY + * isn't provided, it doesn't do anything. Returns base64 path without the + * signature part. + * @param {String} path - base64-encoded URL path. + */ + verifySignature(path) { + const signKey = process.env.SIGNATURE_KEY; + if (signKey === undefined) { + return path; + } + + const crypto = require('crypto'); + const bufferEq = require('buffer-equal-constant-time'); + + const splitPath = path.split("--"); + const base64 = splitPath[0]; + const providedSignature = splitPath[1]; + + if (providedSignature === undefined) { + throw ({ + status: 400, + code: 'DecodeRequest::MissingSignature', + message: 'The signature is missing.' + }); + } + + const signature = crypto.createHmac("sha1", signKey).update(base64).digest("hex"); + const signatureBuffer = Buffer.from(signature.toString('base64')); + const providedSignatureBuffer = Buffer.from(providedSignature); + + if (bufferEq(signatureBuffer, providedSignatureBuffer)) { + return base64; + } + + throw ({ + status: 400, + code: 'DecodeRequest::InvalidSignature', + message: 'The signature you provided could not be verified.' + }); + } } // Exports diff --git a/source/image-handler/package.json b/source/image-handler/package.json index 66201a474..e0742d50a 100644 --- a/source/image-handler/package.json +++ b/source/image-handler/package.json @@ -8,9 +8,10 @@ "version": "0.0.1", "private": true, "dependencies": { - "sharp": "^0.23.4", + "buffer-equal-constant-time": "^1.0.1", "color": "3.1.2", - "color-name": "1.1.4" + "color-name": "1.1.4", + "sharp": "^0.23.4" }, "devDependencies": { "aws-sdk": "^2.437.0", diff --git a/source/image-handler/test/test-image-request.js b/source/image-handler/test/test-image-request.js index 9507dacef..22928fdca 100644 --- a/source/image-handler/test/test-image-request.js +++ b/source/image-handler/test/test-image-request.js @@ -641,6 +641,25 @@ describe('decodeRequest()', function() { }); }); }); + describe('004/signedPath', function() { + it(`Should throw an error if signature is invalid`, function() { + // Arrange + process.env = { + SIGNATURE_KEY : "mySecretKey" + } + const path = 'eyJlZGl0cyI6eyJncmF5c2NhbGUiOiJ0cnVlIiwicm90YXRlIjo5MCwiZmxpcCI6InRydWUifX0--invalid-signature' + // Act + const imageRequest = new ImageRequest(); + // Assert + assert.throws(function() { + imageRequest.decodeRequest(event); + }, Object, { + status: 400, + code: 'DecodeRequest::CannotReadPath', + message: 'The URL path you provided could not be read. Please ensure that it is properly formed according to the solution documentation.' + }); + }); + }); }); // ---------------------------------------------------------------------------- @@ -755,3 +774,86 @@ describe('getOutputFormat()', function () { }); }); }); + +// ---------------------------------------------------------------------------- +// verifySignature() +// ---------------------------------------------------------------------------- + +describe('verifySignature()', function() { + describe('001/noSignatureKey', function() { + it(`Should pass if a valid base64-encoded path has been specified`, function() { + // Arrange + const path = 'eyJidWNrZXQiOiJidWNrZXQtbmFtZS1oZXJlIiwia2V5Ijoia2V5LW5hbWUtaGVyZSJ9' + + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.verifySignature(path); + + // Assert + const expectedResult = 'eyJidWNrZXQiOiJidWNrZXQtbmFtZS1oZXJlIiwia2V5Ijoia2V5LW5hbWUtaGVyZSJ9' + assert.deepEqual(result, expectedResult); + }); + }); + + describe('002/validSignature', function() { + it(`Should pass if a valid signature has been specified`, function() { + // Arrange + process.env = { + SIGNATURE_KEY : "mySecretKey" + } + const path = 'eyJidWNrZXQiOiJidWNrZXQtbmFtZS1oZXJlIiwia2V5Ijoia2V5LW5hbWUtaGVyZSJ9--640b43f6c1fede17c301b23338b4eb4d7d462ce6' + + // Act + const imageRequest = new ImageRequest(); + const result = imageRequest.verifySignature(path); + + // Assert + const expectedResult = 'eyJidWNrZXQiOiJidWNrZXQtbmFtZS1oZXJlIiwia2V5Ijoia2V5LW5hbWUtaGVyZSJ9' + assert.deepEqual(result, expectedResult); + }); + }); + + describe('003/invalidSignature', function() { + it(`Should throw an error if an invalid signature has been specified`, function() { + // Arrange + process.env = { + SIGNATURE_KEY : "mySecretKey" + } + const path = 'eyJlZGl0cyI6eyJncmF5c2NhbGUiOiJ0cnVlIiwicm90YXRlIjo5MCwiZmxpcCI6InRydWUifX0--640b43f6c1fede17c301b23338b4eb4d7d462ce6' + + // Act + const imageRequest = new ImageRequest(); + + // Assert + assert.throws(function() { + imageRequest.verifySignature(path); + }, Object, { + status: 400, + code: 'DecodeRequest::InvalidSignature', + message: 'The signature you provided could not be verified.' + }); + }); + }); + + describe('004/noSignature', function() { + it(`Should throw an error if signature has not been specified`, function() { + // Arrange + process.env = { + SIGNATURE_KEY : "mySecretKey" + } + const path = 'eyJlZGl0cyI6eyJncmF5c2NhbGUiOiJ0cnVlIiwicm90YXRlIjo5MCwiZmxpcCI6InRydWUifX0' + + // Act + const imageRequest = new ImageRequest(); + + // Assert + assert.throws(function() { + imageRequest.verifySignature(path); + }, Object, { + status: 400, + code: 'DecodeRequest::MissingSignature', + message: 'The signature is missing.' + }); + }); + }); +});