Skip to content
Open
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,35 @@

**Note**: If you want to use the solution without building from source, navigate to [Solution Landing Page](https://aws.amazon.com/solutions/implementations/dynamic-image-transformation-for-amazon-cloudfront/).

> **⚠️ FORK NOTICE ⚠️**:
>
> This is a fork of the [original AWS solution](https://github.com/aws-solutions/dynamic-image-transformation-for-amazon-cloudfront) with additional features not available in the official release.
## Additional Features

### Custom S3 Behaviors for Non-Image Content

Serve static files (PDFs, documents, videos, etc.) directly from S3 through CloudFront alongside image processing. Configure via [CDK context](source/constructs/cdk.json):

```json
{
"context": {
"customBehaviors": [
{
"pathPattern": "*.pdf",
"s3BucketName": "my-bucket"
},{
"pathPattern": "*.mp4",
"s3BucketName": "my-bucket",
"description": "Serve MP4 files directly from S3",
"allowedOrigins": ["*"],
"cacheTtlDays": 1
}
]
}
}
```

## Table of Content

- [Solution Overview](#solution-overview)
Expand Down
3 changes: 2 additions & 1 deletion source/constructs/bin/constructs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,5 @@ const managementStackProps: ManagementStackProps = {
solutionVersion,
description,
};
new ManagementStack(app, "v8-Stack", managementStackProps);
const stackName = app.node.tryGetContext("stackName") || "v8-Stack";
new ManagementStack(app, stackName, managementStackProps);
192 changes: 185 additions & 7 deletions source/constructs/lib/v8/stacks/image-processing-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
NestedStack,
NestedStackProps,
} from "aws-cdk-lib";
import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import { TableV2 } from "aws-cdk-lib/aws-dynamodb";
Expand All @@ -24,6 +25,19 @@ import { Utility } from "../constructs/common";
import { AlbEcsConstruct, ContainerConstruct, EcsConfig, NetworkConstruct } from "../constructs/processor";
import { SolutionsMetrics, ExecutionDay } from "metrics-utils";

/**
* Configuration for custom S3 origins and behaviors
* This allows serving non-image content (PDFs, documents, etc.) directly from S3
*/
interface CustomBehaviorConfig {
pathPattern: string;
s3BucketName: string;
s3BucketRegion?: string;
description: string;
allowedOrigins?: string[]; // For CORS configuration
cacheTtl?: Duration;
}

interface ImageProcessingStackProps extends NestedStackProps {
configTable: TableV2;
uuid?: string;
Expand Down Expand Up @@ -93,6 +107,17 @@ export class ImageProcessingStack extends NestedStack {
const deploymentMode = this.node.tryGetContext("deploymentMode") || "prod";
const isDevMode = deploymentMode === "dev";

const customBehaviorsData = this.node.tryGetContext("customBehaviors") || [];

const customBehaviors: CustomBehaviorConfig[] = customBehaviorsData.map((behavior: any) => ({
pathPattern: behavior.pathPattern,
s3BucketName: behavior.s3BucketName,
s3BucketRegion: behavior.s3BucketRegion || Aws.REGION,
description: behavior.description,
allowedOrigins: behavior.allowedOrigins || ["*"],
cacheTtl: Duration.days(behavior.cacheTtlDays || 1),
}));

let vpcOrigin: origins.VpcOrigin | undefined;
if (!isDevMode) {
vpcOrigin = origins.VpcOrigin.withApplicationLoadBalancer(albEcsConstruct.loadBalancer, {
Expand All @@ -113,6 +138,8 @@ export class ImageProcessingStack extends NestedStack {
let loggingBucket: s3.Bucket | undefined;
let ditCachePolicy: cloudfront.CachePolicy | undefined;
let ditFunction: cloudfront.Function | undefined;
let configuredDomainNames: string[] | undefined;
let configuredCertificate: acm.ICertificate | undefined;

if (!isDevMode) {
loggingBucket = new s3.Bucket(this, "LoggingBucket", {
Expand Down Expand Up @@ -207,13 +234,130 @@ export class ImageProcessingStack extends NestedStack {
value: "86400",
override: false,
},
{
header: "Cross-Origin-Resource-Policy",
value: "cross-origin",
override: true,
},
],
},
});

const corsResponseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, "CorsResponseHeadersPolicy", {
responseHeadersPolicyName: `CORS-S3-Content-${Aws.REGION}`,
comment: "CORS headers for serving non-image content from S3 origins",
corsBehavior: {
accessControlAllowCredentials: false,
accessControlAllowHeaders: ["*"],
accessControlAllowMethods: ["GET", "HEAD", "OPTIONS"],
accessControlAllowOrigins: ["*"], // Will be customized per behavior if needed
accessControlMaxAge: Duration.seconds(600),
originOverride: true,
},
securityHeadersBehavior: {
strictTransportSecurity: {
accessControlMaxAge: Duration.seconds(31536000),
includeSubdomains: false,
override: true,
},
contentTypeOptions: {
override: true,
},
frameOptions: {
frameOption: cloudfront.HeadersFrameOption.DENY,
override: true,
},
referrerPolicy: {
referrerPolicy: cloudfront.HeadersReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN,
override: true,
},
xssProtection: {
modeBlock: true,
protection: true,
override: true,
},
},
});

// Create additional behaviors for S3 origins
const additionalBehaviors: Record<string, cloudfront.BehaviorOptions> = {};

// Cache for S3 origins to avoid creating duplicates for the same bucket
const s3OriginCache = new Map<string, cloudfront.IOrigin>();

customBehaviors.forEach((behaviorConfig, index) => {
let s3Origin = s3OriginCache.get(behaviorConfig.s3BucketName);

if (!s3Origin) {
// First time seeing this bucket - create the origin
const s3Bucket = s3.Bucket.fromBucketName(
this,
`CustomS3Origin-${behaviorConfig.s3BucketName}`,
behaviorConfig.s3BucketName
);
s3Origin = origins.S3BucketOrigin.withOriginAccessControl(s3Bucket);
s3OriginCache.set(behaviorConfig.s3BucketName, s3Origin);
}

const s3CachePolicy = new cloudfront.CachePolicy(this, `S3CachePolicy${index}`, {
cachePolicyName: `s3-content-cache-${index}-${Aws.REGION}`,
comment: behaviorConfig.description,
defaultTtl: behaviorConfig.cacheTtl || Duration.days(1),
maxTtl: Duration.days(365),
minTtl: Duration.seconds(0),
headerBehavior: cloudfront.CacheHeaderBehavior.none(),
queryStringBehavior: cloudfront.CacheQueryStringBehavior.none(),
cookieBehavior: cloudfront.CacheCookieBehavior.none(),
enableAcceptEncodingGzip: true,
enableAcceptEncodingBrotli: true,
});

additionalBehaviors[behaviorConfig.pathPattern] = {
origin: s3Origin,
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD_OPTIONS,
cachePolicy: s3CachePolicy,
responseHeadersPolicy: corsResponseHeadersPolicy,
compress: true,
};
});

// Get custom domain configuration from CDK context
// Note: Must use CDK context (not CloudFormation parameters) because
// domain names and certificate need to be set at synthesis time
const contextDomainNames = this.node.tryGetContext("customDomainNames");
const contextCertificateArn = this.node.tryGetContext("acmCertificateArn");

let domainNames: string[] | undefined;
let certificate: acm.ICertificate | undefined;

if (contextCertificateArn && contextDomainNames) {
// Parse domain names (can be string or array)
let domainsStr: string;
if (Array.isArray(contextDomainNames)) {
domainsStr = contextDomainNames.join(",");
} else {
domainsStr = contextDomainNames;
}

// Parse comma-separated domains and trim whitespace
const domains = domainsStr.split(",").map(d => d.trim()).filter(d => d.length > 0);

if (domains.length > 0) {
domainNames = domains;
certificate = acm.Certificate.fromCertificateArn(this, "CustomDomainCertificate", contextCertificateArn);
// Save for outputs
configuredDomainNames = domains;
configuredCertificate = certificate;
}
}

distribution = new cloudfront.Distribution(this, "ImageProcessingDistribution", {
comment: `Image Handler Distribution for Dynamic Image Transformation - ${deploymentMode} mode`,
priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
domainNames,
certificate,
defaultBehavior: {
origin: vpcOrigin!,
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
Expand All @@ -229,6 +373,7 @@ export class ImageProcessingStack extends NestedStack {
},
],
},
additionalBehaviors, // Add custom S3 behaviors
logBucket: loggingBucket,
logFilePrefix: "cloudfront-logs/",
logIncludesCookies: false,
Expand All @@ -253,13 +398,16 @@ export class ImageProcessingStack extends NestedStack {
});

// Add CFN Guard suppression for CloudFront Distribution TLS version requirement
addCfnGuardSuppressRules(cfnDistribution, [
{
id: "CLOUDFRONT_MINIMUM_PROTOCOL_VERSION_RULE",
reason:
"Not creating custom certificate and using the default CloudFront certificate that doesn't use TLS 1.2",
},
]);
// Only suppress when NOT using a custom certificate
if (!certificate) {
addCfnGuardSuppressRules(cfnDistribution, [
{
id: "CLOUDFRONT_MINIMUM_PROTOCOL_VERSION_RULE",
reason:
"Not creating custom certificate and using the default CloudFront certificate that doesn't use TLS 1.2",
},
]);
}
}

new Utility(this, "UtilityLambda", {
Expand Down Expand Up @@ -372,6 +520,19 @@ export class ImageProcessingStack extends NestedStack {
value: distribution.distributionDomainName,
description: "CloudFront distribution domain name for accessing the image processing service",
});

// Output custom domain names if configured
if (configuredDomainNames && configuredDomainNames.length > 0 && configuredCertificate) {
new CfnOutput(this, "ConfiguredCustomDomains", {
value: configuredDomainNames.join(", "),
description: "Custom domain names configured for the CloudFront distribution",
});

new CfnOutput(this, "ConfiguredCertificateArn", {
value: configuredCertificate.certificateArn,
description: "ACM certificate ARN used for custom domain names",
});
}
}

if (isDevMode) {
Expand All @@ -380,6 +541,23 @@ export class ImageProcessingStack extends NestedStack {
description: "Direct ALB endpoint for development mode (bypasses CloudFront)",
});
}

// Output custom behaviors information
if (!isDevMode && customBehaviors.length > 0) {
const behaviorsInfo = customBehaviors
.map(b => `${b.pathPattern} -> ${b.s3BucketName}`)
.join(", ");

new CfnOutput(this, "CustomBehaviors", {
value: behaviorsInfo,
description: "Custom CloudFront behaviors for S3 content (path pattern -> S3 bucket)",
});

new CfnOutput(this, "CustomBehaviorsCount", {
value: customBehaviors.length.toString(),
description: "Number of custom S3 behaviors configured",
});
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe('OriginFetcher', () => {
describe('validateImageMagicNumbers', () => {
it('should reject files under 4 bytes', () => {
const smallBuffer = Buffer.from([0xFF, 0xD8]);
expect(() => fetcher['validateImageMagicNumbers'](smallBuffer)).toThrow('File too small to be a valid image');
expect(() => fetcher['validateImageMagicNumbers'](smallBuffer)).toThrow(/File too small to be a valid image/);
});

it('should accept valid JPEG with magic numbers', () => {
Expand Down Expand Up @@ -83,7 +83,7 @@ describe('OriginFetcher', () => {
it('should reject content-type mismatch', () => {
const pngBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]);
expect(() => fetcher['validateImageMagicNumbers'](pngBuffer, 'image/jpeg'))
.toThrow('Content-Type image/jpeg does not match detected format png');
.toThrow(/Content-Type image\/jpeg does not match detected format png/);
});

it('should allow unknown content-type with detected format', () => {
Expand All @@ -99,7 +99,14 @@ describe('OriginFetcher', () => {
it('should reject malformed magic numbers with content-type', () => {
const malformedPngBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x46]); // Should be 0x47, not 0x46
expect(() => fetcher['validateImageMagicNumbers'](malformedPngBuffer, 'image/png'))
.toThrow('Invalid or corrupted png file');
.toThrow(/Invalid or corrupted png file/);
});

it('should include URL path in error message when provided', () => {
const pngBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]);
const url = 'https://example.com/path/to/image.png';
expect(() => fetcher['validateImageMagicNumbers'](pngBuffer, 'image/jpeg', url))
.toThrow(/Content-Type image\/jpeg does not match detected format png from: \/path\/to\/image\.png/);
});
});

Expand Down
18 changes: 13 additions & 5 deletions source/container/src/services/image-processing/origin-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class OriginFetcher {
throw new ImageProcessingError(400, 'InvalidUrl', 'Unsupported URL protocol');
}

this.validateImageMagicNumbers(result.buffer, result.contentType);
this.validateImageMagicNumbers(result.buffer, result.contentType, url);
const fetchDurationMs = Date.now() - startTime;

console.log(JSON.stringify({
Expand Down Expand Up @@ -157,12 +157,20 @@ export class OriginFetcher {
return validTypes.some(type => contentType.toLowerCase().includes(type));
}

private validateImageMagicNumbers(buffer: Buffer, contentType?: string): void {
private validateImageMagicNumbers(buffer: Buffer, contentType?: string, url?: string): void {
// Where applicable the first 4 bytes are checked against that formats starting sequence.
// For formats with inconsistent or non-existant starting sequences(av1, raw, etc) this validation is skipped.

let urlInfo = '';
if (url) {
const sanitized = this.sanitizeUrl(url);
// Extract only the path, removing protocol and host for security
const pathOnly = sanitized.replace(/^https?:\/\/[^/]+/, '') || sanitized.split('/').slice(1).join('/');
urlInfo = ` from: ${pathOnly}`;
}

if (buffer.length < 4) {
throw new ImageProcessingError(415, 'InvalidImage', 'File too small to be a valid image');
throw new ImageProcessingError(415, 'InvalidImage', `File too small to be a valid image${urlInfo}`);
}

const magicToFormat = {
Expand Down Expand Up @@ -198,10 +206,10 @@ export class OriginFetcher {
// If no expectedFormat found, skip magic number validation
if (expectedFormat) {
if (!detectedFormat) {
throw new ImageProcessingError(415, 'InvalidImage', `Invalid or corrupted ${expectedFormat} file`);
throw new ImageProcessingError(415, 'InvalidImage', `Invalid or corrupted ${expectedFormat} file${urlInfo}`);
}
if (expectedFormat !== detectedFormat) {
throw new ImageProcessingError(415, 'InvalidImage', `Content-Type ${contentType} does not match detected format ${detectedFormat}`);
throw new ImageProcessingError(415, 'InvalidImage', `Content-Type ${contentType} does not match detected format ${detectedFormat}${urlInfo}`);
}
}
}
Expand Down