Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions source/image-handler/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 24 additions & 1 deletion source/image-handler/src/image-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ImageFormatTypes,
ImageHandlerError,
ImageRequestInfo,
isClientError,
RequestTypes,
StatusCodes,
} from './lib';
Expand Down Expand Up @@ -92,6 +93,11 @@ export class ImageRequest {
*/
public async setup(event: APIGatewayProxyEventV2): Promise<ImageRequestInfo> {
try {
// Recover requests where a whole `srcset` value was placed into a single image URL
// (see fixSrcSetPath). Sanitizing once here means every downstream parser
// (request type, key, edits) operates on the first, valid candidate.
event = { ...event, rawPath: ImageRequest.fixSrcSetPath(event.rawPath) };

let imageRequestInfo: ImageRequestInfo = <ImageRequestInfo>{};

imageRequestInfo.requestType = this.parseRequestType(event);
Expand Down Expand Up @@ -132,14 +138,31 @@ export class ImageRequest {

return imageRequestInfo;
} catch (error) {
if (error.code && error.code !== 'NoSuchKey') {
if (isClientError(error)) {
logger.debug('Client error while setting up the image request. Error: ', error);
} else {
logger.warn('Error occurred while setting up the image request. Error: ', error);
}

throw error;
}
}

/**
* fixes image source set requests that somehow reach the image handler
* @param rawPath The raw request path.
* @returns The sanitized path, or the original path if it is not a srcset-style request.
*/
public static fixSrcSetPath(rawPath: string): string {
// Bounded quantifiers on the descriptor digits and a single greedy `[\s\S]*$` tail (instead
// of the ambiguous `(?:[\s,].*)?$`) keep this linear — avoiding the polynomial backtracking
// CodeQL flags for a regex run on uncontrolled request input.
return (rawPath ?? '').replace(
/(\.(?:jpe?g|png|webp|tiff?|svg|gif|avif))(?:%20|\s)\d{1,6}(?:\.\d{1,4})?[wx][\s\S]*$/i,
'$1',
);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}

/**
* Gets the original image from an Amazon S3 bucket.
* @param bucket The name of the bucket containing the image.
Expand Down
11 changes: 8 additions & 3 deletions source/image-handler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { ImageHandler } from './image-handler';
import { ImageRequest } from './image-request';
import { Headers, ImageHandlerExecutionResult, StatusCodes } from './lib';
import { Headers, ImageHandlerExecutionResult, isClientError, StatusCodes } from './lib';
import { S3 } from '@aws-sdk/client-s3';
import { APIGatewayProxyEventV2 } from 'aws-lambda';
import { Logger } from '@aws-lambda-powertools/logger';
Expand Down Expand Up @@ -78,11 +78,16 @@ export async function handler(event: APIGatewayProxyEventV2): Promise<ImageHandl
body: processedRequest,
};
} catch (error) {
if (error.code && error.code !== 'NoSuchKey') {
const { statusCode, headers, body } = getErrorResponse(error);

// Client errors (4xx: malformed/non-image requests, scanner noise, missing keys) are expected
// background traffic; log them quietly so WARN-level logs surface only server-side problems.
if (isClientError(error, statusCode)) {
logger.debug('Client error during image processing', { error });
} else {
logger.warn('Error occurred during image processing', { error });
}

const { statusCode, headers, body } = getErrorResponse(error);
return {
statusCode,
isBase64Encoded: false,
Expand Down
16 changes: 16 additions & 0 deletions source/image-handler/src/lib/LogstashFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ class LogStashFormatter extends LogFormatter {

return logItem;
}

/**
* Normalizes error attributes before they are logged.
* @param error The error to format.
* @returns The formatted error attributes with a single, lowercase `code` field.
*/
public formatError(error: Error): LogAttributes {
const formattedError = super.formatError(error);

if (formattedError.Code !== undefined && formattedError.code === undefined) {
formattedError.code = formattedError.Code;
}
delete formattedError.Code;

return formattedError;
}
}

export { LogStashFormatter };
13 changes: 11 additions & 2 deletions source/image-handler/src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { StatusCodes } from "./enums";
import { StatusCodes } from './enums';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Headers = Record<string, any>;
Expand All @@ -10,7 +10,16 @@ export type Headers = Record<string, any>;
export type ImageEdits = Record<string, any>;

export class ImageHandlerError extends Error {
constructor(public readonly status: StatusCodes, public readonly code: string, public readonly message: string) {
constructor(
public readonly status: StatusCodes,
public readonly code: string,
public readonly message: string,
) {
super();
}
}

export function isClientError(error: unknown, statusCode?: number): boolean {
const status = statusCode ?? (error as { status?: number })?.status;
return typeof status === 'number' && status >= 400 && status < 500;
}
22 changes: 20 additions & 2 deletions source/image-handler/src/thumbor-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import { ImageEdits, ImageFitTypes, ImageFormatTypes } from './lib';
export class ThumborMapper {
private static readonly EMPTY_IMAGE_EDITS: ImageEdits = {};

// Upper bound (px, longest side) applied to otherwise-unbounded `0x0` resize requests
// (e.g. `fit-in/0x0`). Without a bound, sharp returns the image at its full native (cropped)
// resolution; for multi-megapixel originals the base64-encoded result exceeds the 6 MB Lambda
// payload limit and fails with `TooLargeImageException`. Override via the
// `UNBOUNDED_FIT_IN_MAX_DIMENSION` environment variable.
private static readonly UNBOUNDED_FIT_IN_MAX_DIMENSION = parseInt(process.env.UNBOUNDED_FIT_IN_MAX_DIMENSION) || 4000;

/**
* Initializer function for creating a new Thumbor mapping, used by the image
* handler to perform image modifications based on legacy URL path requests.
Expand Down Expand Up @@ -451,8 +458,19 @@ export class ThumborMapper {
if (width === 0 || height === 0) {
resizeEdit.resize.fit = ImageFitTypes.INSIDE;
}
resizeEdit.resize.width = width === 0 ? null : width;
resizeEdit.resize.height = height === 0 ? null : height;

if (width === 0 && height === 0) {
// `0x0` (e.g. `fit-in/0x0`) requests no resize bound, which would return the image at
// its full native (cropped) resolution and can blow past the 6 MB Lambda payload limit.
// Clamp the longest side so large images are downscaled to fit; `withoutEnlargement`
// leaves images already smaller than the bound untouched (no upscaling).
resizeEdit.resize.width = ThumborMapper.UNBOUNDED_FIT_IN_MAX_DIMENSION;
resizeEdit.resize.height = ThumborMapper.UNBOUNDED_FIT_IN_MAX_DIMENSION;
resizeEdit.resize.withoutEnlargement = true;
} else {
resizeEdit.resize.width = width === 0 ? null : width;
resizeEdit.resize.height = height === 0 ? null : height;
}

return resizeEdit;
}
Expand Down
90 changes: 90 additions & 0 deletions source/image-handler/test/image-request/fix-srcset-path.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { ImageRequest } from '../../src/image-request';

describe('fixSrcSetPath', () => {
it('Should recover the first candidate from a srcset with width descriptors (the reported failing request)', () => {
// Arrange
const rawPath =
'/2022/10/673obCnS14wB/175x0:450x450/fit-in/90x0/rachel-holmes-quelle-gamedistribution.jpg%2090w,%20https://images.t-online.de/2022/10/673obCnS14wB/175x0:450x450/fit-in/180x0/rachel-holmes-quelle-gamedistribution.jpg%20180w';

// Act
const result = ImageRequest.fixSrcSetPath(rawPath);

// Assert
expect(result).toEqual('/2022/10/673obCnS14wB/175x0:450x450/fit-in/90x0/rachel-holmes-quelle-gamedistribution.jpg');
});

it('Should recover the first candidate from a srcset with pixel-density descriptors', () => {
// Arrange
const rawPath =
'/2026/06/1Ff18x3yYonF/0x222:4283x2409/fit-in/168x0/joshua-kimmich.jpg%201x,%20https://images.t-online.de/2026/06/1Ff18x3yYonF/0x222:4283x2409/fit-in/336x0/joshua-kimmich.jpg%202x';

// Act
const result = ImageRequest.fixSrcSetPath(rawPath);

// Assert
expect(result).toEqual('/2026/06/1Ff18x3yYonF/0x222:4283x2409/fit-in/168x0/joshua-kimmich.jpg');
});

it('Should handle fractional pixel-density descriptors', () => {
// Arrange
const rawPath =
'/2024/07/abc/fit-in/400x0/image.jpg%201.5x,%20https://images.t-online.de/2024/07/abc/fit-in/800x0/image.jpg%203x';

// Act
const result = ImageRequest.fixSrcSetPath(rawPath);

// Assert
expect(result).toEqual('/2024/07/abc/fit-in/400x0/image.jpg');
});

it('Should strip a trailing descriptor even when there is only a single candidate', () => {
// Arrange
const rawPath = '/2024/07/abc/fit-in/90x0/image.jpg%2090w';

// Act
const result = ImageRequest.fixSrcSetPath(rawPath);

// Assert
expect(result).toEqual('/2024/07/abc/fit-in/90x0/image.jpg');
});

it('Should also handle literal (non URL-encoded) spaces', () => {
// Arrange
const rawPath =
'/2024/07/abc/fit-in/90x0/image.jpg 90w, https://images.t-online.de/2024/07/abc/fit-in/180x0/image.jpg 180w';

// Act
const result = ImageRequest.fixSrcSetPath(rawPath);

// Assert
expect(result).toEqual('/2024/07/abc/fit-in/90x0/image.jpg');
});

it.each([
'/2022/10/673obCnS14wB/175x0:450x450/fit-in/90x0/rachel-holmes-quelle-gamedistribution.jpg',
'/filters:rotate(90)/filters:grayscale()/thumbor-image.jpg',
'/fit-in/400x400/filters:watermark(bucket,folder/key.png,0,0)/image.jpg',
'/filters:rotate(90)/filters:grayscale()/thumbor-image (1) suffix.jpg',
'/eyJidWNrZXQiOiJteS1zYW1wbGUtYnVja2V0Iiwia2V5Ijoic2FtcGxlLWltYWdlLTAwMS5qcGcifQ==',
])('Should leave a valid request path unchanged: %s', rawPath => {
expect(ImageRequest.fixSrcSetPath(rawPath)).toEqual(rawPath);
});

it('Should not be confused by commas inside a watermark filter', () => {
// Arrange — a comma exists, but there is no "<extension> <descriptor>" signature
const rawPath = '/fit-in/400x400/filters:watermark(bucket,key.png,0,0)/image.jpg';

// Act
const result = ImageRequest.fixSrcSetPath(rawPath);

// Assert
expect(result).toEqual(rawPath);
});

it('Should handle an empty path gracefully', () => {
expect(ImageRequest.fixSrcSetPath('')).toEqual('');
});
});
52 changes: 52 additions & 0 deletions source/image-handler/test/image-request/setup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,4 +480,56 @@ describe('setup', () => {
expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, { Bucket: 'test', Key: '中文' });
expect(imageRequestInfo).toEqual(expectedResult);
});

it('Should recover a srcset-style request (whole srcset placed into one URL) and serve the first candidate', async () => {
// Arrange — the reported failing request: an entire `srcset` value in a single URL
const event = build_event({
rawPath:
'/2022/10/673obCnS14wB/175x0:450x450/fit-in/90x0/rachel-holmes-quelle-gamedistribution.jpg%2090w,%20https://images.t-online.de/2022/10/673obCnS14wB/175x0:450x450/fit-in/180x0/rachel-holmes-quelle-gamedistribution.jpg%20180w',
});
process.env.SOURCE_BUCKETS = 'allowedBucket001, allowedBucket002';

// Mock
mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStreamFromString('SampleImageContent\n') });

// Act
const imageRequest = new ImageRequest(new S3({}));
const imageRequestInfo = await imageRequest.setup(event);

// Assert — resolves to the first candidate's image and edits instead of throwing a 400
expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, {
Bucket: 'allowedBucket001',
Key: '2022/10/673obCnS14wB/image.jpg',
});
expect(imageRequestInfo.requestType).toEqual(RequestTypes.THUMBOR);
expect(imageRequestInfo.key).toEqual('2022/10/673obCnS14wB/image.jpg');
expect(imageRequestInfo.edits).toEqual({
crop: { left: 175, top: 0, width: 450, height: 450 },
resize: { fit: 'inside', width: 90, height: null },
});
});

it('Should recover a srcset-style request that uses pixel-density descriptors', async () => {
// Arrange
const event = build_event({
rawPath:
'/2026/06/1Ff18x3yYonF/0x222:4283x2409/fit-in/168x0/joshua-kimmich.jpg%201x,%20https://images.t-online.de/2026/06/1Ff18x3yYonF/0x222:4283x2409/fit-in/336x0/joshua-kimmich.jpg%202x',
});
process.env.SOURCE_BUCKETS = 'allowedBucket001, allowedBucket002';

// Mock
mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStreamFromString('SampleImageContent\n') });

// Act
const imageRequest = new ImageRequest(new S3({}));
const imageRequestInfo = await imageRequest.setup(event);

// Assert
expect(mockS3Client).toHaveReceivedCommandWith(GetObjectCommand, {
Bucket: 'allowedBucket001',
Key: '2026/06/1Ff18x3yYonF/image.jpg',
});
expect(imageRequestInfo.requestType).toEqual(RequestTypes.THUMBOR);
expect(imageRequestInfo.key).toEqual('2026/06/1Ff18x3yYonF/image.jpg');
});
});
Loading