diff --git a/source/image-handler/image-request.ts b/source/image-handler/image-request.ts index f17b25e44..9672bdb00 100644 --- a/source/image-handler/image-request.ts +++ b/source/image-handler/image-request.ts @@ -96,6 +96,7 @@ export class ImageRequest { public async setup(event: ImageHandlerEvent): Promise { try { await this.validateRequestSignature(event); + this.validateRequestExpires(event); let imageRequestInfo: ImageRequestInfo = {}; @@ -464,6 +465,19 @@ export class ImageRequest { } } + /** + * Creates a query string similar to API Gateway 2.0 payload's $.rawQueryString + * @param queryStringParameters Request's query parameters + * @returns URL encoded queryString + */ + private recreateQueryString(queryStringParameters: ImageHandlerEvent['queryStringParameters']): string { + return Object + .entries(queryStringParameters) + .filter(([key]) => key !== 'signature') + .map(([key, value]) => [key, value].join('=')) + .join('&'); + } + /** * Validates the request's signature. * @param event Lambda request body. @@ -485,11 +499,14 @@ export class ImageRequest { ); } + const queryString = this.recreateQueryString(queryStringParameters); + try { const { signature } = queryStringParameters; const secret = JSON.parse(await this.secretProvider.getSecret(SECRETS_MANAGER)); const key = secret[SECRET_KEY]; - const hash = createHmac("sha256", key).update(path).digest("hex"); + const stringToSign = queryString !== '' ? [path, queryString].join('?') : path; + const hash = createHmac('sha256', key).update(stringToSign).digest('hex'); // Signature should be made with the full path. if (signature !== hash) { @@ -509,4 +526,30 @@ export class ImageRequest { } } } + + private validateRequestExpires(event: ImageHandlerEvent): void { + try { + const { queryStringParameters } = event; + const expires = queryStringParameters?.expires; + if (expires !== undefined) { + const parsedDate = new Date(expires); + if (isNaN(parsedDate.getTime())) { + throw new ImageHandlerError(StatusCodes.BAD_REQUEST, 'ImageRequestExpiryFormat', 'Request has invalid expiry date.'); + } + const now = new Date(); + if (now > parsedDate) { + throw new ImageHandlerError(StatusCodes.FORBIDDEN, 'ImageRequestExpired', 'Request has expired.'); + } + } + } catch (error) { + if (error.code === 'ImageRequestExpired') { + throw error; + } + if (error.code === 'ImageRequestExpiryFormat') { + throw error; + } + console.error('Error occurred while checking expiry.', error); + throw new ImageHandlerError(StatusCodes.INTERNAL_SERVER_ERROR, 'ExpiryDateCheckFailure', 'Expiry date check failed.'); + } + } } diff --git a/source/image-handler/lib/interfaces.ts b/source/image-handler/lib/interfaces.ts index 898cc725f..afa0e73ee 100644 --- a/source/image-handler/lib/interfaces.ts +++ b/source/image-handler/lib/interfaces.ts @@ -9,7 +9,8 @@ import { Headers, ImageEdits } from "./types"; export interface ImageHandlerEvent { path?: string; queryStringParameters?: { - signature: string; + signature?: string; + expires?: string; }; requestContext?: { elb?: unknown; diff --git a/source/image-handler/test/image-request/decode-request.spec.ts b/source/image-handler/test/image-request/decode-request.spec.ts index ce290055b..fff2c20b2 100644 --- a/source/image-handler/test/image-request/decode-request.spec.ts +++ b/source/image-handler/test/image-request/decode-request.spec.ts @@ -5,8 +5,9 @@ import S3 from "aws-sdk/clients/s3"; import SecretsManager from "aws-sdk/clients/secretsmanager"; import { ImageRequest } from "../../image-request"; -import { StatusCodes } from "../../lib"; +import { ImageHandlerEvent, StatusCodes } from "../../lib"; import { SecretProvider } from "../../secret-provider"; +import { mockAwsS3 } from '../mock'; describe("decodeRequest", () => { const s3Client = new S3(); @@ -70,4 +71,51 @@ describe("decodeRequest", () => { }); } }); + + describe('expires', () => { + const baseRequest = { + bucket: 'test', + requestType: 'Default', + key: 'test.png', + }; + const path = `/${Buffer.from(JSON.stringify(baseRequest)).toString('base64')}`; + it.each([ + { + expires: 'Thu, 01 Jan 1970 00:00:00 GMT', + error: { + code: 'ImageRequestExpired', + message: 'Request has expired.', + status: StatusCodes.FORBIDDEN, + }, + }, + { + expires: 'invalidKey', + error: { + code: 'ImageRequestExpiryFormat', + message: 'Request has invalid expiry date.', + status: StatusCodes.BAD_REQUEST, + } + } + ] as { expires: ImageHandlerEvent['queryStringParameters']['expires'], error: object, }[])( + "Should throw an error when $error.message", + (async ({ error: expectedError, expires }) => { + // Arrange + const event: ImageHandlerEvent = { + path, + queryStringParameters: { + expires, + }, + }; + // Mock + mockAwsS3.getObject.mockImplementationOnce(() => ({ + promise() { + return Promise.resolve({ Body: Buffer.from('SampleImageContent\n') }); + } + })); + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + await expect(imageRequest.setup(event)).rejects.toMatchObject(expectedError); + }) + ); + }); });