diff --git a/README.md b/README.md index 6856af105..9bfa44f9b 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/source/constructs/bin/constructs.ts b/source/constructs/bin/constructs.ts index 91ca1e069..9d23909e8 100644 --- a/source/constructs/bin/constructs.ts +++ b/source/constructs/bin/constructs.ts @@ -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); diff --git a/source/constructs/lib/v8/stacks/image-processing-stack.ts b/source/constructs/lib/v8/stacks/image-processing-stack.ts index 2e6b8f27b..45a258aa5 100644 --- a/source/constructs/lib/v8/stacks/image-processing-stack.ts +++ b/source/constructs/lib/v8/stacks/image-processing-stack.ts @@ -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"; @@ -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; @@ -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, { @@ -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", { @@ -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 = {}; + + // Cache for S3 origins to avoid creating duplicates for the same bucket + const s3OriginCache = new Map(); + + 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, @@ -229,6 +373,7 @@ export class ImageProcessingStack extends NestedStack { }, ], }, + additionalBehaviors, // Add custom S3 behaviors logBucket: loggingBucket, logFilePrefix: "cloudfront-logs/", logIncludesCookies: false, @@ -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", { @@ -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) { @@ -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", + }); + } } /** diff --git a/source/container/src/services/image-processing/origin-fetcher.test.ts b/source/container/src/services/image-processing/origin-fetcher.test.ts index 15c6cec2d..743e2f767 100644 --- a/source/container/src/services/image-processing/origin-fetcher.test.ts +++ b/source/container/src/services/image-processing/origin-fetcher.test.ts @@ -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', () => { @@ -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', () => { @@ -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/); }); }); diff --git a/source/container/src/services/image-processing/origin-fetcher.ts b/source/container/src/services/image-processing/origin-fetcher.ts index 9b474d904..35949c9b4 100644 --- a/source/container/src/services/image-processing/origin-fetcher.ts +++ b/source/container/src/services/image-processing/origin-fetcher.ts @@ -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({ @@ -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 = { @@ -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}`); } } }