eleven / ocho Public

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>
Paul Campbell 75a61965b3b7f64c41e445429dc8077f18766c64 parent 77aa6af
8 files changed, +185 −1
  • infra/README.md +5 −1
  • infra/dev/buckets.tf +35 −0
  • infra/dev/compute.tf +9 −0
  • infra/prod/backup.tf +32 −0
  • infra/prod/cloudtrail.tf +23 −0
  • infra/prod/hibernate.tf +32 −0
  • infra/prod/releases.tf +25 −0
  • infra/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" {