infra: require IMDSv2 on the dev box + TLS-only policies on every bucket (#938)
* infra: require IMDSv2 on the dev box + TLS-only policies on every bucket Aikido triage follow-ups (the two real IaC/cloud findings from the first full-workspace feed pull): - infra/dev/compute.tf: metadata_options http_tokens=required — the dev box's instance-role credentials can no longer be read with a bare IMDSv1 GET. - All seven buckets (status, releases, prod-instances, fleet-backup, cloudtrail, dev-instances, dev-home) get a DenyInsecureTransport bucket-policy statement (aws:SecureTransport=false → deny). Every existing client already speaks HTTPS; deny-only statements attach cleanly under Block Public Access. Not applied — needs a deliberate AWS_PROFILE=… tofu plan/apply in infra/prod and infra/dev, per the standing rule in those files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * infra: document the hand-applied TLS-only policy on the tofu-state bucket The state bucket is the one imperatively-created resource, so its DenyInsecureTransport policy (applied 2026-07-15 via the AWS CLI) can't live in this configuration — record it in the README so a recreate knows to reapply it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+185
−1
infra/README.md+5 −1infra/dev/buckets.tf+35 −0infra/dev/compute.tf+9 −0infra/prod/backup.tf+32 −0infra/prod/cloudtrail.tf+23 −0infra/prod/hibernate.tf+32 −0infra/prod/releases.tf+25 −0infra/prod/status.tf+24 −0
diff --git a/infra/README.md b/infra/README.md| index d07763b..5df2dc5 100644 |
| --- a/infra/README.md |
| +++ b/infra/README.md |
| @@ -25,7 +25,11 @@ AWS_PROFILE=eleven-dev tofu apply |
| State lives in S3 (`eleven-tofu-state-427880590669`, versioned, private) with |
| native lockfile locking — no DynamoDB lock table needed. That bucket is the |
| one imperatively-created resource (chicken and egg); everything else is |
| -declared here. |
| +declared here. Being hand-managed, its hardening is hand-applied too: full |
| +Block Public Access, and a `DenyInsecureTransport` bucket policy (deny on |
| +`aws:SecureTransport=false`, applied 2026-07-15) matching the TLS-only |
| +policy every declared bucket carries — reapply both if the bucket is ever |
| +recreated. |
| ## What exists (dev) |
diff --git a/infra/dev/buckets.tf b/infra/dev/buckets.tf| index 240051e..2a6beee 100644 |
| --- a/infra/dev/buckets.tf |
| +++ b/infra/dev/buckets.tf |
| @@ -52,3 +52,38 @@ resource "aws_s3_bucket_public_access_block" "state" { |
| ignore_public_acls = true |
| restrict_public_buckets = true |
| } |
| + |
| +# TLS-only: deny any request arriving over plain HTTP. Every client of these |
| +# buckets (Litestream, the AWS SDKs/CLI) already speaks HTTPS — this turns |
| +# that assumption into policy. A deny-only statement grants nothing public, |
| +# so it attaches fine under the full public-access block above. |
| +data "aws_iam_policy_document" "state_tls_only" { |
| + for_each = local.buckets |
| + |
| + statement { |
| + sid = "DenyInsecureTransport" |
| + effect = "Deny" |
| + actions = ["s3:*"] |
| + resources = [ |
| + aws_s3_bucket.state[each.key].arn, |
| + "${aws_s3_bucket.state[each.key].arn}/*", |
| + ] |
| + |
| + principals { |
| + type = "*" |
| + identifiers = ["*"] |
| + } |
| + |
| + condition { |
| + test = "Bool" |
| + variable = "aws:SecureTransport" |
| + values = ["false"] |
| + } |
| + } |
| +} |
| + |
| +resource "aws_s3_bucket_policy" "state_tls_only" { |
| + for_each = local.buckets |
| + bucket = aws_s3_bucket.state[each.key].id |
| + policy = data.aws_iam_policy_document.state_tls_only[each.key].json |
| +} |
diff --git a/infra/dev/compute.tf b/infra/dev/compute.tf| index 947722b..1c36be6 100644 |
| --- a/infra/dev/compute.tf |
| +++ b/infra/dev/compute.tf |
| @@ -117,6 +117,15 @@ resource "aws_instance" "dev_box" { |
| vpc_security_group_ids = [aws_security_group.dev_box.id] |
| iam_instance_profile = aws_iam_instance_profile.dev_box.name |
| + # The instance role's credentials are served by IMDS, so require IMDSv2's |
| + # session tokens: an SSRF or misconfigured proxy on the box can't read the |
| + # role credentials with a bare GET. Hop limit stays at the default 1 — |
| + # nothing here runs in containers. |
| + metadata_options { |
| + http_endpoint = "enabled" |
| + http_tokens = "required" |
| + } |
| + |
| root_block_device { |
| volume_size = 20 |
| volume_type = "gp3" |
diff --git a/infra/prod/backup.tf b/infra/prod/backup.tf| index b590f8d..27697e5 100644 |
| --- a/infra/prod/backup.tf |
| +++ b/infra/prod/backup.tf |
| @@ -41,6 +41,38 @@ resource "aws_s3_bucket_public_access_block" "fleet_backup" { |
| restrict_public_buckets = true |
| } |
| +# TLS-only: deny any request arriving over plain HTTP. Litestream and the |
| +# vault sync both speak HTTPS already — this turns that assumption into |
| +# policy. A deny-only statement grants nothing public, so it attaches fine |
| +# under the full access block above. |
| +data "aws_iam_policy_document" "fleet_backup_tls_only" { |
| + statement { |
| + sid = "DenyInsecureTransport" |
| + effect = "Deny" |
| + actions = ["s3:*"] |
| + resources = [ |
| + aws_s3_bucket.fleet_backup.arn, |
| + "${aws_s3_bucket.fleet_backup.arn}/*", |
| + ] |
| + |
| + principals { |
| + type = "*" |
| + identifiers = ["*"] |
| + } |
| + |
| + condition { |
| + test = "Bool" |
| + variable = "aws:SecureTransport" |
| + values = ["false"] |
| + } |
| + } |
| +} |
| + |
| +resource "aws_s3_bucket_policy" "fleet_backup" { |
| + bucket = aws_s3_bucket.fleet_backup.id |
| + policy = data.aws_iam_policy_document.fleet_backup_tls_only.json |
| +} |
| + |
| # The router box's credentials: object read/write/delete + list on the fleet |
| # bucket, nothing else in the account. Mint the access key by hand |
| # (aws iam create-access-key --user-name litestream-fleet) — keys never land |
diff --git a/infra/prod/cloudtrail.tf b/infra/prod/cloudtrail.tf| index e03dead..efa0530 100644 |
| --- a/infra/prod/cloudtrail.tf |
| +++ b/infra/prod/cloudtrail.tf |
| @@ -59,6 +59,29 @@ data "aws_iam_policy_document" "trail_logs" { |
| values = ["bucket-owner-full-control"] |
| } |
| } |
| + |
| + # TLS-only: deny any request arriving over plain HTTP (CloudTrail's own |
| + # delivery is HTTPS; this just makes it policy). |
| + statement { |
| + sid = "DenyInsecureTransport" |
| + effect = "Deny" |
| + actions = ["s3:*"] |
| + resources = [ |
| + aws_s3_bucket.trail_logs.arn, |
| + "${aws_s3_bucket.trail_logs.arn}/*", |
| + ] |
| + |
| + principals { |
| + type = "*" |
| + identifiers = ["*"] |
| + } |
| + |
| + condition { |
| + test = "Bool" |
| + variable = "aws:SecureTransport" |
| + values = ["false"] |
| + } |
| + } |
| } |
| resource "aws_s3_bucket_policy" "trail_logs" { |
diff --git a/infra/prod/hibernate.tf b/infra/prod/hibernate.tf| index 1dfe198..22412f2 100644 |
| --- a/infra/prod/hibernate.tf |
| +++ b/infra/prod/hibernate.tf |
| @@ -41,6 +41,38 @@ resource "aws_s3_bucket_public_access_block" "prod_instances" { |
| restrict_public_buckets = true |
| } |
| +# TLS-only: deny any request arriving over plain HTTP. The activator's |
| +# embedded Litestream and the stats publisher both speak HTTPS already — |
| +# this turns that assumption into policy. A deny-only statement grants |
| +# nothing public, so it attaches fine under the full access block above. |
| +data "aws_iam_policy_document" "prod_instances_tls_only" { |
| + statement { |
| + sid = "DenyInsecureTransport" |
| + effect = "Deny" |
| + actions = ["s3:*"] |
| + resources = [ |
| + aws_s3_bucket.prod_instances.arn, |
| + "${aws_s3_bucket.prod_instances.arn}/*", |
| + ] |
| + |
| + principals { |
| + type = "*" |
| + identifiers = ["*"] |
| + } |
| + |
| + condition { |
| + test = "Bool" |
| + variable = "aws:SecureTransport" |
| + values = ["false"] |
| + } |
| + } |
| +} |
| + |
| +resource "aws_s3_bucket_policy" "prod_instances" { |
| + bucket = aws_s3_bucket.prod_instances.id |
| + policy = data.aws_iam_policy_document.prod_instances_tls_only.json |
| +} |
| + |
| # The router's activator credentials: object rw + version listing (the wake |
| # path restores, the lease CASes, sweepers may prune) on this bucket only. |
| # Key minted by hand, lives in the router's env — never in tofu state. |
diff --git a/infra/prod/releases.tf b/infra/prod/releases.tf| index 356e646..6b1f316 100644 |
| --- a/infra/prod/releases.tf |
| +++ b/infra/prod/releases.tf |
| @@ -72,6 +72,31 @@ data "aws_iam_policy_document" "releases_public_read" { |
| identifiers = ["*"] |
| } |
| } |
| + |
| + # Public means anonymous, not unencrypted: deny any request over plain |
| + # HTTP. Updaters fetch the mirror via its https URL already (the manifest |
| + # is Ed25519-signed regardless — TLS here is defence in depth, not the |
| + # trust anchor). |
| + statement { |
| + sid = "DenyInsecureTransport" |
| + effect = "Deny" |
| + actions = ["s3:*"] |
| + resources = [ |
| + aws_s3_bucket.releases.arn, |
| + "${aws_s3_bucket.releases.arn}/*", |
| + ] |
| + |
| + principals { |
| + type = "*" |
| + identifiers = ["*"] |
| + } |
| + |
| + condition { |
| + test = "Bool" |
| + variable = "aws:SecureTransport" |
| + values = ["false"] |
| + } |
| + } |
| } |
| resource "aws_s3_bucket_policy" "releases" { |
diff --git a/infra/prod/status.tf b/infra/prod/status.tf| index 6f82c80..2aabcfd 100644 |
| --- a/infra/prod/status.tf |
| +++ b/infra/prod/status.tf |
| @@ -69,6 +69,30 @@ data "aws_iam_policy_document" "status_public_read" { |
| identifiers = ["*"] |
| } |
| } |
| + |
| + # Public means anonymous, not unencrypted: deny any request over plain |
| + # HTTP. The viewer pages and the publisher both use the https endpoint |
| + # already; this closes the door on downgraded fetches. |
| + statement { |
| + sid = "DenyInsecureTransport" |
| + effect = "Deny" |
| + actions = ["s3:*"] |
| + resources = [ |
| + aws_s3_bucket.status.arn, |
| + "${aws_s3_bucket.status.arn}/*", |
| + ] |
| + |
| + principals { |
| + type = "*" |
| + identifiers = ["*"] |
| + } |
| + |
| + condition { |
| + test = "Bool" |
| + variable = "aws:SecureTransport" |
| + values = ["false"] |
| + } |
| + } |
| } |
| resource "aws_s3_bucket_policy" "status" { |