From 6ff227a26947f47b5c56cbb37960bf56e282dbdd Mon Sep 17 00:00:00 2001 From: Pablo Seibelt Date: Tue, 21 Oct 2025 12:22:09 -0300 Subject: [PATCH 1/5] Setup OAC to restrict S3 bucket access properly --- infrastructure/index.ts | 80 ++++++++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/infrastructure/index.ts b/infrastructure/index.ts index 6a38958677cc..7fdc749778a4 100644 --- a/infrastructure/index.ts +++ b/infrastructure/index.ts @@ -205,27 +205,14 @@ if (config.makeFallbackBucket) { ); } -// We deny the s3:ListBucket permission to anyone but account users to prevent unintended -// disclosure of the bucket's contents. -const originBucketPolicy = new aws.s3.BucketPolicy("origin-bucket-policy", { - bucket: originBucket.bucket, - policy: pulumi.all([originBucket.arn, aws.getCallerIdentity()]) - .apply(([arn, awsCallerIdentityResult]) => JSON.stringify({ - Version: "2008-10-17", - Statement: [ - { - Effect: "Deny", - Principal: "*", - Action: "s3:ListBucket", - Resource: arn, - Condition: { - StringNotEquals: { - "aws:PrincipalAccount": awsCallerIdentityResult.accountId, - }, - }, - }, - ], - })), +// Create an Origin Access Control for CloudFront to access S3 +// OAC is the recommended modern approach, replacing the legacy OAI +const originAccessControl = new aws.cloudfront.OriginAccessControl("origin-access-control", { + name: pulumi.interpolate`oac-${config.websiteDomain}`, + description: pulumi.interpolate`OAC for ${config.websiteDomain}`, + originAccessControlOriginType: "s3", + signingBehavior: "always", + signingProtocol: "sigv4", }); // websiteLogsBucket stores the request logs for incoming requests. @@ -523,17 +510,8 @@ const distributionArgs: aws.cloudfront.DistributionArgs = { origins: [ { originId: originBucket.arn, - domainName: originBucket.websiteEndpoint, - customOriginConfig: { - // > If your Amazon S3 bucket is configured as a website endpoint, [like we have here] you must specify - // > HTTP Only. Amazon S3 doesn't support HTTPS connections in that configuration. - // tslint:disable-next-line: max-line-length - // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/distribution-web-values-specify.html#DownloadDistValuesOriginProtocolPolicy - originProtocolPolicy: "http-only", - httpPort: 80, - httpsPort: 443, - originSslProtocols: ["TLSv1.2"], - }, + domainName: originBucket.bucketRegionalDomainName, + originAccessControlId: originAccessControl.id, }, { originId: uploadsBucket.arn, @@ -804,6 +782,44 @@ const cdn = new aws.cloudfront.Distribution( }, ); +// Configure bucket policy to allow CloudFront OAC access and deny direct access +// This must be created after the distribution so we can reference its ARN +const originBucketPolicy = new aws.s3.BucketPolicy("origin-bucket-policy", { + bucket: originBucket.bucket, + policy: pulumi.all([originBucket.arn, cdn.arn, aws.getCallerIdentity()]) + .apply(([bucketArn, distributionArn, awsCallerIdentityResult]) => JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Sid: "AllowCloudFrontServicePrincipal", + Effect: "Allow", + Principal: { + Service: "cloudfront.amazonaws.com", + }, + Action: "s3:GetObject", + Resource: `${bucketArn}/*`, + Condition: { + StringEquals: { + "AWS:SourceArn": distributionArn, + }, + }, + }, + { + Sid: "DenyDirectListBucket", + Effect: "Deny", + Principal: "*", + Action: "s3:ListBucket", + Resource: bucketArn, + Condition: { + StringNotEquals: { + "aws:PrincipalAccount": awsCallerIdentityResult.accountId, + }, + }, + }, + ], + })), +}); + // https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/AWS-logs-infrastructure-V2-S3.html // Configure CDN log delivery if cdnLogDeliverySourceName is set From affea6a2836a047b0b3e023a3c03f061e81d4d6d Mon Sep 17 00:00:00 2001 From: Pablo Seibelt Date: Tue, 21 Oct 2025 13:00:13 -0300 Subject: [PATCH 2/5] Add ListBucket --- infrastructure/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/infrastructure/index.ts b/infrastructure/index.ts index 7fdc749778a4..939440d71c0d 100644 --- a/infrastructure/index.ts +++ b/infrastructure/index.ts @@ -791,13 +791,13 @@ const originBucketPolicy = new aws.s3.BucketPolicy("origin-bucket-policy", { Version: "2012-10-17", Statement: [ { - Sid: "AllowCloudFrontServicePrincipal", + Sid: "AllowCloudFrontServicePrincipalReadOnly", Effect: "Allow", Principal: { Service: "cloudfront.amazonaws.com", }, - Action: "s3:GetObject", - Resource: `${bucketArn}/*`, + Action: ["s3:GetObject", "s3:ListBucket"], + Resource: [bucketArn, `${bucketArn}/*`], Condition: { StringEquals: { "AWS:SourceArn": distributionArn, From b3c0dc3e632233777b78862fcd428277e3cf4712 Mon Sep 17 00:00:00 2001 From: Pablo Seibelt Date: Tue, 21 Oct 2025 13:21:58 -0300 Subject: [PATCH 3/5] Redirect paths to index.html if the root of a folder is opened --- infrastructure/index.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/infrastructure/index.ts b/infrastructure/index.ts index 939440d71c0d..e69c95ca8e54 100644 --- a/infrastructure/index.ts +++ b/infrastructure/index.ts @@ -425,6 +425,28 @@ const SecurityHeadersPolicy = newSecurityHeadersPolicy('security-headers', confi // Copilot lives in an iframe const CopilotSecurityHeadersPolicy = newSecurityHeadersPolicy('copilot-security-headers', 'SAMEORIGIN'); +// CloudFront Function to append index.html to directory paths +// This restores S3 website endpoint behavior when using OAC with REST API endpoint +const indexRewriteFunction = new aws.cloudfront.Function("index-rewrite-function", { + runtime: "cloudfront-js-1.0", + comment: "Append index.html to directory paths", + code: `function handler(event) { + var request = event.request; + var uri = request.uri; + + // Check whether the URI is missing a file name + if (uri.endsWith('/')) { + request.uri += 'index.html'; + } + // Check whether the URI is missing a file extension (likely a directory) + else if (!uri.includes('.')) { + request.uri += '/index.html'; + } + + return request; +}`, +}); + const baseCacheBehavior: aws.types.input.cloudfront.DistributionDefaultCacheBehavior = { targetOriginId: originBucket.arn, compress: true, @@ -446,6 +468,12 @@ const baseCacheBehavior: aws.types.input.cloudfront.DistributionDefaultCacheBeha defaultTtl: fiveMinutes, maxTtl: fiveMinutes, lambdaFunctionAssociations: config.doEdgeRedirects ? [getEdgeRedirectAssociation()] : [], + functionAssociations: [ + { + eventType: "viewer-request", + functionArn: indexRewriteFunction.arn, + }, + ], responseHeadersPolicyId: SecurityHeadersPolicy.id, }; From a62b5f46e07079aabdaf4c7881f5b0f28b09c726 Mon Sep 17 00:00:00 2001 From: Pablo Seibelt Date: Tue, 21 Oct 2025 14:30:51 -0300 Subject: [PATCH 4/5] Fix bucket policy --- infrastructure/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/infrastructure/index.ts b/infrastructure/index.ts index e69c95ca8e54..ad42d917fbc5 100644 --- a/infrastructure/index.ts +++ b/infrastructure/index.ts @@ -842,6 +842,9 @@ const originBucketPolicy = new aws.s3.BucketPolicy("origin-bucket-policy", { StringNotEquals: { "aws:PrincipalAccount": awsCallerIdentityResult.accountId, }, + "Null": { + "AWS:SourceArn": "true", + }, }, }, ], From 82507c27e20c63a7ae053b58eac699e04baead50 Mon Sep 17 00:00:00 2001 From: Pablo Seibelt Date: Wed, 22 Oct 2025 16:23:04 -0300 Subject: [PATCH 5/5] Remove deny from policy --- infrastructure/index.ts | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/infrastructure/index.ts b/infrastructure/index.ts index ad42d917fbc5..c6f88c7905f8 100644 --- a/infrastructure/index.ts +++ b/infrastructure/index.ts @@ -810,12 +810,12 @@ const cdn = new aws.cloudfront.Distribution( }, ); -// Configure bucket policy to allow CloudFront OAC access and deny direct access +// Configure bucket policy to allow CloudFront OAC access // This must be created after the distribution so we can reference its ARN const originBucketPolicy = new aws.s3.BucketPolicy("origin-bucket-policy", { bucket: originBucket.bucket, - policy: pulumi.all([originBucket.arn, cdn.arn, aws.getCallerIdentity()]) - .apply(([bucketArn, distributionArn, awsCallerIdentityResult]) => JSON.stringify({ + policy: pulumi.all([originBucket.arn, cdn.arn]) + .apply(([bucketArn, distributionArn]) => JSON.stringify({ Version: "2012-10-17", Statement: [ { @@ -832,21 +832,6 @@ const originBucketPolicy = new aws.s3.BucketPolicy("origin-bucket-policy", { }, }, }, - { - Sid: "DenyDirectListBucket", - Effect: "Deny", - Principal: "*", - Action: "s3:ListBucket", - Resource: bucketArn, - Condition: { - StringNotEquals: { - "aws:PrincipalAccount": awsCallerIdentityResult.accountId, - }, - "Null": { - "AWS:SourceArn": "true", - }, - }, - }, ], })), });