From 563b38e31b68a639243800765cd67dd50dad5aa6 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Mon, 7 Sep 2026 23:00:44 -0400 Subject: [PATCH 1/3] fix(uploads): unblock browser PUTs to the reports bucket Two independent defects stopped a receipt PDF from ever reaching S3. The bucket had no CORS configuration, so the preflight for the presigned PUT returned 403 with no Access-Control-Allow-* headers and Chrome dropped the upload before sending it. API Gateway's CORS does not cover this hop -- the browser talks straight to S3. Adds a cors_rule allowing PUT with a content-type header from the CloudFront domain, plus the custom domain once it is attached. Behind that, the presigners ran with the SDK default requestChecksumCalculation of WHEN_SUPPORTED, which computes a CRC32 over the presigner's empty body and signs it into the URL as x-amz-checksum-crc32=AAAAAA==. S3 then validates the real bytes against the empty-body checksum and rejects the upload, so fixing CORS alone would have turned the block into a 400. Switches the three clients that presign browser PUTs (receipts, avatars, reports) to WHEN_REQUIRED. Server-side puts keep the default; they checksum a real body. Co-Authored-By: Claude Opus 5 (1M context) --- .../expenditures/services/expenditures.ts | 5 ++++- .../lambdas/reports/controllers/reports.ts | 7 ++++++- apps/backend/lambdas/users/photos.ts | 4 +++- infrastructure/aws/s3.tf | 19 +++++++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/backend/lambdas/expenditures/services/expenditures.ts b/apps/backend/lambdas/expenditures/services/expenditures.ts index cf4645fc..6c87e11d 100644 --- a/apps/backend/lambdas/expenditures/services/expenditures.ts +++ b/apps/backend/lambdas/expenditures/services/expenditures.ts @@ -9,7 +9,10 @@ import { applyExpenditureScope, type ExpenditureScope } from './scope'; const REGION = process.env.AWS_REGION ?? 'us-east-2'; const BUCKET = process.env.REPORTS_BUCKET_NAME ?? ''; -const s3 = new S3Client({ region: REGION }); +// WHEN_SUPPORTED (the SDK default) signs a CRC32 of the presigner's empty body +// into the upload URL, which the real bytes then fail. Presigned PUTs need +// WHEN_REQUIRED. +const s3 = new S3Client({ region: REGION, requestChecksumCalculation: 'WHEN_REQUIRED' }); // Receipts are PDFs only, matching the dropzone in AddExpenseModal. export const RECEIPT_CONTENT_TYPE = 'application/pdf'; diff --git a/apps/backend/lambdas/reports/controllers/reports.ts b/apps/backend/lambdas/reports/controllers/reports.ts index 712e9f29..7dd8c01c 100644 --- a/apps/backend/lambdas/reports/controllers/reports.ts +++ b/apps/backend/lambdas/reports/controllers/reports.ts @@ -15,7 +15,12 @@ import { getObjectSize, } from '../report-service'; -const s3 = new S3Client({ region: process.env.AWS_REGION ?? 'us-east-2' }); +// WHEN_REQUIRED, not the SDK default: see the note in +// expenditures/services/expenditures.ts. The upload URL below is a browser PUT. +const s3 = new S3Client({ + region: process.env.AWS_REGION ?? 'us-east-2', + requestChecksumCalculation: 'WHEN_REQUIRED', +}); const BUCKET = process.env.REPORTS_BUCKET_NAME ?? ''; const ALLOWED_EXTENSIONS = ['pdf', 'docx'] as const; diff --git a/apps/backend/lambdas/users/photos.ts b/apps/backend/lambdas/users/photos.ts index 3fde94be..45b38a5a 100644 --- a/apps/backend/lambdas/users/photos.ts +++ b/apps/backend/lambdas/users/photos.ts @@ -4,7 +4,9 @@ import { reportError } from '@branch/lambda-http'; const REGION = process.env.AWS_REGION ?? 'us-east-2'; const BUCKET = process.env.REPORTS_BUCKET_NAME ?? ''; -const s3 = new S3Client({ region: REGION }); +// WHEN_REQUIRED, not the SDK default: see the note in +// expenditures/services/expenditures.ts. presignAvatarUpload is a browser PUT. +const s3 = new S3Client({ region: REGION, requestChecksumCalculation: 'WHEN_REQUIRED' }); /** Profile photos share the reports bucket, under their own prefix. */ export const AVATAR_PREFIX = 'avatars/'; diff --git a/infrastructure/aws/s3.tf b/infrastructure/aws/s3.tf index b3e01c52..ca35c6f5 100644 --- a/infrastructure/aws/s3.tf +++ b/infrastructure/aws/s3.tf @@ -11,6 +11,25 @@ resource "aws_s3_bucket_public_access_block" "reports_bucket_public_access" { restrict_public_buckets = true } +# Receipt, photo and report uploads are browser PUTs straight at a presigned +# URL, so S3 answers the preflight itself -- the API Gateway CORS in +# api_gateway.tf never sees these requests. +resource "aws_s3_bucket_cors_configuration" "reports_bucket_cors" { + bucket = aws_s3_bucket.reports_bucket.id + + cors_rule { + allowed_methods = ["PUT"] + allowed_headers = ["content-type"] + max_age_seconds = 7200 + + # CloudFront serves the app whether or not the custom domain is attached. + allowed_origins = compact([ + "https://${aws_cloudfront_distribution.frontend.domain_name}", + local.attach_dns ? "https://${var.app_domain}" : "", + ]) + } +} + output "reports_bucket_name" { description = "Name of the S3 bucket for generated reports" value = aws_s3_bucket.reports_bucket.id From 197f3e2316ed2dfd2dfa5410371377c09dd1c1e2 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Mon, 7 Sep 2026 23:02:10 -0400 Subject: [PATCH 2/3] chore: trim comments --- apps/backend/lambdas/expenditures/services/expenditures.ts | 4 +--- apps/backend/lambdas/reports/controllers/reports.ts | 2 -- apps/backend/lambdas/users/photos.ts | 2 -- infrastructure/aws/s3.tf | 4 ---- 4 files changed, 1 insertion(+), 11 deletions(-) diff --git a/apps/backend/lambdas/expenditures/services/expenditures.ts b/apps/backend/lambdas/expenditures/services/expenditures.ts index 6c87e11d..9de79f78 100644 --- a/apps/backend/lambdas/expenditures/services/expenditures.ts +++ b/apps/backend/lambdas/expenditures/services/expenditures.ts @@ -9,9 +9,7 @@ import { applyExpenditureScope, type ExpenditureScope } from './scope'; const REGION = process.env.AWS_REGION ?? 'us-east-2'; const BUCKET = process.env.REPORTS_BUCKET_NAME ?? ''; -// WHEN_SUPPORTED (the SDK default) signs a CRC32 of the presigner's empty body -// into the upload URL, which the real bytes then fail. Presigned PUTs need -// WHEN_REQUIRED. +// SDK default signs a checksum of the presigner's empty body; real bytes then fail the PUT. const s3 = new S3Client({ region: REGION, requestChecksumCalculation: 'WHEN_REQUIRED' }); // Receipts are PDFs only, matching the dropzone in AddExpenseModal. diff --git a/apps/backend/lambdas/reports/controllers/reports.ts b/apps/backend/lambdas/reports/controllers/reports.ts index 7dd8c01c..4530b247 100644 --- a/apps/backend/lambdas/reports/controllers/reports.ts +++ b/apps/backend/lambdas/reports/controllers/reports.ts @@ -15,8 +15,6 @@ import { getObjectSize, } from '../report-service'; -// WHEN_REQUIRED, not the SDK default: see the note in -// expenditures/services/expenditures.ts. The upload URL below is a browser PUT. const s3 = new S3Client({ region: process.env.AWS_REGION ?? 'us-east-2', requestChecksumCalculation: 'WHEN_REQUIRED', diff --git a/apps/backend/lambdas/users/photos.ts b/apps/backend/lambdas/users/photos.ts index 45b38a5a..7da133ed 100644 --- a/apps/backend/lambdas/users/photos.ts +++ b/apps/backend/lambdas/users/photos.ts @@ -4,8 +4,6 @@ import { reportError } from '@branch/lambda-http'; const REGION = process.env.AWS_REGION ?? 'us-east-2'; const BUCKET = process.env.REPORTS_BUCKET_NAME ?? ''; -// WHEN_REQUIRED, not the SDK default: see the note in -// expenditures/services/expenditures.ts. presignAvatarUpload is a browser PUT. const s3 = new S3Client({ region: REGION, requestChecksumCalculation: 'WHEN_REQUIRED' }); /** Profile photos share the reports bucket, under their own prefix. */ diff --git a/infrastructure/aws/s3.tf b/infrastructure/aws/s3.tf index ca35c6f5..b796a852 100644 --- a/infrastructure/aws/s3.tf +++ b/infrastructure/aws/s3.tf @@ -11,9 +11,6 @@ resource "aws_s3_bucket_public_access_block" "reports_bucket_public_access" { restrict_public_buckets = true } -# Receipt, photo and report uploads are browser PUTs straight at a presigned -# URL, so S3 answers the preflight itself -- the API Gateway CORS in -# api_gateway.tf never sees these requests. resource "aws_s3_bucket_cors_configuration" "reports_bucket_cors" { bucket = aws_s3_bucket.reports_bucket.id @@ -22,7 +19,6 @@ resource "aws_s3_bucket_cors_configuration" "reports_bucket_cors" { allowed_headers = ["content-type"] max_age_seconds = 7200 - # CloudFront serves the app whether or not the custom domain is attached. allowed_origins = compact([ "https://${aws_cloudfront_distribution.frontend.domain_name}", local.attach_dns ? "https://${var.app_domain}" : "", From 72b6eb4203a6cfb17ea61f721a937a8a81ee1158 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 8 Sep 2026 03:03:27 +0000 Subject: [PATCH 3/3] chore: auto-format terraform and update documentation - Auto-formatted .tf files with terraform fmt - Updated README.md with terraform-docs Co-authored-by: nourshoreibah --- infrastructure/aws/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/infrastructure/aws/README.md b/infrastructure/aws/README.md index 6f23dbab..72c06d7d 100644 --- a/infrastructure/aws/README.md +++ b/infrastructure/aws/README.md @@ -88,6 +88,7 @@ | [aws_s3_bucket.frontend](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket) | resource | | [aws_s3_bucket.lambda_deployments](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket) | resource | | [aws_s3_bucket.reports_bucket](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket) | resource | +| [aws_s3_bucket_cors_configuration.reports_bucket_cors](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_cors_configuration) | resource | | [aws_s3_bucket_policy.frontend](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_policy) | resource | | [aws_s3_bucket_public_access_block.frontend](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_public_access_block) | resource | | [aws_s3_bucket_public_access_block.reports_bucket_public_access](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_public_access_block) | resource |