Command Palette

Search for a command to run...

Hectal

Mission 4.4 · Stage 4 — Data & Secrets

Private Uploads with a Least-Privilege Task Role

Goal: A private uploads bucket that clients write to directly with presigned URLs issued by ShopLite 1.3.0, where the task role can touch exactly one prefix of one bucket and non-TLS requests are denied.

40 min Cents 5 steps 2 break-it drills

By the end of this mission

  • Write an S3 bucket policy that denies insecure transport
  • Scope a task role's IAM policy to one bucket prefix
  • Configure CORS and lifecycle rules as code
  • Test presigned uploads end to end with curl

Part 1

Understand it first

Identity policy vs bucket policy, together

The TASK ROLE's identity policy says what ShopLite may do: put and get objects under uploads/* in one bucket. The BUCKET policy says what the bucket accepts from anyone: nothing over plain HTTP. Both are evaluated, and an explicit deny in either wins (AWS course, Topic 1.1). Least privilege isn't one policy; it's each layer allowing only what's needed.

Presigned URLs inherit the signer's permissions

A presigned URL is a request signed with the TASK ROLE's credentials, handed to a client. When the client uses it, S3 evaluates the task role's permissions at that moment. So the role must allow s3:PutObject on the target key, a URL for a key outside uploads/* fails even though ShopLite signed it, and URLs signed with temporary role credentials expire when those credentials do (at most the session lifetime), whatever expiresIn says.

Object ARNs vs bucket ARNs

Bucket-level actions like s3:ListBucket apply to arn:aws:s3:::bucket; object-level actions like s3:PutObject apply to arn:aws:s3:::bucket/key. Putting object actions on the bucket ARN silently grants nothing. This mistake shows up in real IAM reviews constantly, and you'll trigger it deliberately below.

Presigned upload flowdiagram
Rendering diagram…

Part 2

Your project after this mission · 7 files change

shoplite/
  • app/
    • package.jsonmodified
    • server.jsmodified
  • infra/
    • alb.tf
    • autoscaling.tf
    • backend.tf
    • ecr.tf
    • ecs.tfmodified
    • iam.tfmodified
    • locals.tf
    • logs.tf
    • network.tf
    • outputs.tfmodified
    • probe.tf
    • providers.tf
    • refactors.tf
    • security.tf
    • storage.tf
    • terraform.tfvarsmodified
    • uploads.tfnew
    • variables.tf
    • versions.tf

Part 3

Build it, step by step

  1. 1

    The uploads bucket

    Same baseline as the assets bucket, plus: CORS allowing browsers to PUT directly (tighten allowed_origins to your real frontend domain), a lifecycle rule that deletes abandoned multipart uploads, and a bucket policy denying any request without TLS.

    infra/uploads.tfwhole filehcl
    resource "aws_s3_bucket" "uploads" {
      bucket        = "${local.name_prefix}-uploads-${random_id.suffix.hex}"
      force_destroy = var.environment != "prod"
    }
    
    resource "aws_s3_bucket_public_access_block" "uploads" {
      bucket                  = aws_s3_bucket.uploads.id
      block_public_acls       = true
      block_public_policy     = true
      ignore_public_acls      = true
      restrict_public_buckets = true
    }
    
    resource "aws_s3_bucket_cors_configuration" "uploads" {
      bucket = aws_s3_bucket.uploads.id
    
      cors_rule {
        allowed_methods = ["PUT"]
        allowed_origins = ["*"] # tighten to your frontend's origin
        allowed_headers = ["content-type"]
        max_age_seconds = 3000
      }
    }
    
    resource "aws_s3_bucket_lifecycle_configuration" "uploads" {
      bucket = aws_s3_bucket.uploads.id
    
      rule {
        id     = "abort-incomplete-multipart"
        status = "Enabled"
        filter {}
        abort_incomplete_multipart_upload {
          days_after_initiation = 1
        }
      }
    }
    
    data "aws_iam_policy_document" "uploads_bucket" {
      statement {
        sid     = "DenyInsecureTransport"
        effect  = "Deny"
        actions = ["s3:*"]
        resources = [
          aws_s3_bucket.uploads.arn,
          "${aws_s3_bucket.uploads.arn}/*",
        ]
        principals {
          type        = "*"
          identifiers = ["*"]
        }
        condition {
          test     = "Bool"
          variable = "aws:SecureTransport"
          values   = ["false"]
        }
      }
    }
    
    resource "aws_s3_bucket_policy" "uploads" {
      bucket = aws_s3_bucket.uploads.id
      policy = data.aws_iam_policy_document.uploads_bucket.json
    
      # Block Public Access must exist first, or a policy change could briefly be evaluated without it.
      depends_on = [aws_s3_bucket_public_access_block.uploads]
    }
  2. 2

    Scope the task role to one prefix

    Extend the task role's policy document with a second statement. Object actions go on <bucket-arn>/uploads/*, not on the bucket ARN and not on *.

    infra/iam.tfadd to filehcl

    Add a second statement inside data "aws_iam_policy_document" "api_task".

      statement {
        sid       = "UploadsPrefixOnly"
        actions   = ["s3:PutObject", "s3:GetObject"]
        resources = ["${aws_s3_bucket.uploads.arn}/uploads/*"]
      }
  3. 3

    Presigned URLs in ShopLite 1.3.0

    npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner. The SDK picks up the task role's credentials automatically from the ECS container credentials endpoint, with no keys anywhere. Keys get a random prefix so users can't overwrite each other's files.

    app/server.jsadd to filejs
    import crypto from "node:crypto";
    import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
    import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
    
    const s3 = new S3Client({});
    
    // inside the handler, before the fallback:
      if (req.method === "POST" && req.url === "/uploads") {
        const body = await new Promise((r) => { let d = ""; req.on("data", (c) => (d += c)); req.on("end", () => r(d)); });
        const { filename = "file", contentType = "application/octet-stream" } = JSON.parse(body || "{}");
        const key = `uploads/${crypto.randomUUID()}-${filename.replace(/[^\w.-]/g, "_")}`;
        const url = await getSignedUrl(
          s3,
          new PutObjectCommand({ Bucket: process.env.UPLOADS_BUCKET, Key: key, ContentType: contentType }),
          { expiresIn: 300 },
        );
        return json(res, 201, { key, url });
      }
  4. 4

    Tell the container which bucket, then deploy

    Add { name = "UPLOADS_BUCKET", value = aws_s3_bucket.uploads.bucket } to the container's environment, plus an uploads_bucket output. Build and push 1.3.0, set it in tfvars, and apply.

    terminal
    $ docker buildx build --platform linux/arm64 -t "$REPO:1.3.0" --push ../app
    terraform apply
    ── expected output ──
    Plan: 6 to add, 2 to change, 1 to destroy.
    ...
    Apply complete! Resources: 6 added, 2 changed, 1 destroyed.
  5. 5

    Upload end to end

    Ask ShopLite for a URL, then PUT a file straight to S3 with it. The Content-Type header must match what was signed. Then confirm the object exists.

    terminal
    $ URL=$(terraform output -raw api_url)
    RESP=$(curl -s -X POST "$URL/uploads" -H 'content-type: application/json' -d '{"filename":"logo.png","contentType":"image/png"}')
    curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H 'content-type: image/png' --upload-file ./logo.png "$(echo $RESP | jq -r .url)"
    aws s3 ls "s3://$(terraform output -raw uploads_bucket)/uploads/"
    ── expected output ──
    200
    2026-09-26 14:02:51 4821 3c9b1f0e-8a2d-4f6b-9e41-7d20c3a5b6e8-logo.png

Checkpoint — you should now have

  • ✓The uploads bucket is private, TLS-only, with CORS and multipart cleanup.
  • ✓The task role can put/get only uploads/* in that bucket, and nothing else in S3.
  • ✓A presigned PUT from curl succeeds and the object appears under uploads/.
  • ✓ShopLite 1.3.0 is pinned in tfvars.

Part 4

Break it on purpose

Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.

Break #1

Grant object actions on the bucket ARN

Change the statement's resource to aws_s3_bucket.uploads.arn (drop /uploads/*), apply, request a new URL, and upload.

terminal
$ curl -s -X PUT -H 'content-type: image/png' --upload-file ./logo.png "$SIGNED_URL"
── what you'll see ──
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>User: arn:aws:sts::123456789012:assumed-role/shoplite-dev-api-task/3f2a... is not authorized to perform: s3:PutObject on resource: "arn:aws:s3:::shoplite-dev-uploads-3f9a/uploads/9e0c...-logo.png" because no identity-based policy allows the s3:PutObject action</Message>...</Error>

Break #2

Use the presigned URL over plain HTTP

Take a fresh URL and replace https:// with http:// before uploading.

terminal
$ curl -s -X PUT -H 'content-type: image/png' --upload-file ./logo.png "$(echo $SIGNED_URL | sed 's/^https/http/')"
── what you'll see ──
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>User: arn:aws:sts::123456789012:assumed-role/shoplite-dev-api-task/3f2a... is not authorized to perform: s3:PutObject on resource: "arn:aws:s3:::shoplite-dev-uploads-3f9a/uploads/..." with an explicit deny in a resource-based policy</Message>...</Error>

Part 5

Interview questions from this mission

01

An app generates presigned S3 URLs but uploads fail with AccessDenied. What do you check?

02

How do you enforce HTTPS-only access to an S3 bucket?

03

Why scope an application role to a prefix like uploads/* rather than the whole bucket?

0/4 · 0%