Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions terraform/dns_operationcode_net.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Route53 zone: operationcode.net
# The domain sends no mail. It only redirects web traffic to operationcode.org,
# so it carries anti-spoofing records (null SPF, null MX, strict DMARC).

# Hosted zone for operationcode.net (imported; never destroy)
resource "aws_route53_zone" "operationcode_net" {
name = "operationcode.net"
# Live zone has an empty comment; keep it empty to avoid drift (provider would otherwise default it)
comment = ""

lifecycle {
prevent_destroy = true
}
}

# ======================================================================
# Apex and web
# ======================================================================

# S3 website endpoint (us-east-1): answers 301 to https://operationcode.org/
resource "aws_route53_record" "net_apex_a" {
zone_id = aws_route53_zone.operationcode_net.zone_id
name = "operationcode.net"
type = "A"

alias {
name = "s3-website-us-east-1.amazonaws.com."
zone_id = "Z3AQBSTGFYJSTF"
evaluate_target_health = true
}
}

# www CNAME to the apex (live value has no trailing dot; keep it as is)
# Note: the S3 website endpoint returns 403 for the www Host header, so www does not redirect today
resource "aws_route53_record" "net_www" {
zone_id = aws_route53_zone.operationcode_net.zone_id
name = "www.operationcode.net"
type = "CNAME"
ttl = 300
records = ["operationcode.net"]
}

# ======================================================================
# Anti-spoofing (domain sends no mail)
# ======================================================================

# Null SPF: No sender is authorized to use operationcode.net as its SPF identity.
resource "aws_route53_record" "net_apex_spf" {
zone_id = aws_route53_zone.operationcode_net.zone_id
name = "operationcode.net"
type = "TXT"
ttl = 3600
records = ["v=spf1 -all"]
}

# Null MX (RFC 7505): the domain accepts no mail
resource "aws_route53_record" "net_apex_mx" {
zone_id = aws_route53_zone.operationcode_net.zone_id
name = "operationcode.net"
type = "MX"
ttl = 3600
records = ["0 ."]
}

# Strict DMARC: reject all spoofed mail, send aggregate reports to tech@
resource "aws_route53_record" "net_dmarc" {
zone_id = aws_route53_zone.operationcode_net.zone_id
name = "_dmarc.operationcode.net"
type = "TXT"
ttl = 3600
records = ["v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s; rua=mailto:tech@operationcode.org"]
}

# DMARC report authorization (RFC 7489 section 7.1)
# The rua address is in operationcode.org, a different domain than the one being reported on.
# Receivers only send reports there if the .org zone publishes this record.
# It lives in this file (not dns_operationcode_org.tf) because it exists only to support the .net DMARC record.
# It is created in the operationcode.org zone, so it uses that zone's id.
resource "aws_route53_record" "net_dmarc_report_auth" {
zone_id = aws_route53_zone.operationcode.zone_id
name = "operationcode.net._report._dmarc.operationcode.org"
type = "TXT"
ttl = 3600
records = ["v=DMARC1"]
}
54 changes: 41 additions & 13 deletions terraform/dns_operationcode_org.tf
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,20 @@ resource "aws_route53_record" "apex_a" {
records = ["76.76.21.21"]
}

# CA policy: restricts wildcard issuance to Amazon, reports to tech@
# CA policy: restricts issuance to the CAs in use, reports to tech@
# issue: amazon.com and amazontrust.com (ACM wildcard cert on the ALB: api, pybot, resources, api.staging)
# letsencrypt.org (Vercel certs for apex, www, storybook; renewals fail without it)
# issuewild: Amazon only (the only wildcard cert is ACM; no Let's Encrypt wildcard is served)
# Cloudways hosts (services family) serve a *.cloudwaysapps.com cert from SSL.com, not a cert for our names, so no CA is added for them
resource "aws_route53_record" "apex_caa" {
zone_id = aws_route53_zone.operationcode.zone_id
name = "operationcode.org"
type = "CAA"
ttl = 300
records = [
"0 issue \"amazon.com\"",
"0 issue \"amazontrust.com\"",
"0 issue \"letsencrypt.org\"",
"0 issuewild \"amazontrust.com\"",
"0 issuewild \"amazon.com\"",
"0 iodef \"mailto:tech@operationcode.org\"",
Expand Down Expand Up @@ -174,18 +181,13 @@ resource "aws_route53_record" "api_staging_aaaa" {
# ======================================================================

# Google Workspace mail
# Replaced this zone's legacy ASPMX records with Google's current single MX on 2026-11-01. Google still supports the old values.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low bug, confidence low, pass review

Comment cites a specific date that may be wrong or future

The comment claims the MX was replaced on a specific date (2026-11-01). The date cannot be verified from the bundle and may be inaccurate, but it has no runtime effect.

Model output from untrusted input. Check it before you act on it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium bug, confidence medium, pass review

Comment cites a future/unverifiable change date

The comment asserts the MX replacement happened on 2026-11-01, which may be a wrong or future date. It is misleading about when the change was made, but has no runtime effect.

Model output from untrusted input. Check it before you act on it.

resource "aws_route53_record" "apex_mx" {
zone_id = aws_route53_zone.operationcode.zone_id
name = "operationcode.org"
type = "MX"
ttl = 3600
records = [
"1 ASPMX.L.GOOGLE.COM.",
"5 ALT1.ASPMX.L.GOOGLE.COM.",
"5 ALT2.ASPMX.L.GOOGLE.COM.",
"10 ALT3.ASPMX.L.GOOGLE.COM.",
"10 ALT4.ASPMX.L.GOOGLE.COM.",
]
records = ["1 smtp.google.com."]
}

# SPF (Google, Mailgun, Mandrill) and Google site verification. Keep the Mandrill include (production mail uses it). INVESTIGATE: Mailgun include is unused by code, confirm with the Mailgun account
Expand All @@ -201,13 +203,14 @@ resource "aws_route53_record" "apex_txt" {
]
}

# DMARC policy for the apex domain (monitoring only, p=none)
# DMARC policy for the apex domain (monitoring only, p=none, aggregate reports to tech@)
# Next steps: enable Google DKIM, then move to p=quarantine, then p=reject
resource "aws_route53_record" "apex_dmarc" {
zone_id = aws_route53_zone.operationcode.zone_id
name = "_dmarc.operationcode.org"
type = "TXT"
ttl = 60
records = ["v=DMARC1; p=none;"]
ttl = 3600
records = ["v=DMARC1; p=none; rua=mailto:tech@operationcode.org"]
}

# ======================================================================
Expand Down Expand Up @@ -319,7 +322,7 @@ resource "aws_route53_record" "mailchimp_dkim_k2" {
zone_id = aws_route53_zone.operationcode.zone_id
name = "k2._domainkey.operationcode.org"
type = "CNAME"
ttl = 60
ttl = 3600
records = ["dkim2.mcsv.net"]
}

Expand All @@ -328,7 +331,7 @@ resource "aws_route53_record" "mailchimp_dkim_k3" {
zone_id = aws_route53_zone.operationcode.zone_id
name = "k3._domainkey.operationcode.org"
type = "CNAME"
ttl = 60
ttl = 3600
records = ["dkim3.mcsv.net"]
}

Expand Down Expand Up @@ -461,3 +464,28 @@ resource "aws_route53_record" "test" {
ttl = 60
records = ["services.operationcode.org."]
}

# ======================================================================
# Null SPF for non-sending hosts
# ======================================================================

# v=spf1 -all on hosts with an address record, no other TXT, and no known mail sending.
# Excluded: Cloudways and DigitalOcean hosts (out of scope), apex, coders.* (sends via SES), CNAME names (TXT cannot coexist), o1.notify (SendGrid sending IP)
locals {
null_spf_hosts = toset([
"www",
"api",
"pybot",
"resources",
"api.staging",
])
}

resource "aws_route53_record" "null_spf" {
for_each = local.null_spf_hosts
zone_id = aws_route53_zone.operationcode.zone_id
name = "${each.key}.operationcode.org"
type = "TXT"
ttl = 3600
records = ["v=spf1 -all"]
}
168 changes: 168 additions & 0 deletions terraform/dns_operationcode_org_security.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# DNSSEC signing and query logging for operationcode.org
# Zone: aws_route53_zone.operationcode (dns_operationcode_org.tf)
# Route53 requires the DNSSEC KMS key and the query log group in us-east-1.

# ======================================================================
# DNSSEC signing (stage 1 of 2: sign the zone; no DS record at the registrar yet)
# ======================================================================

# Key policy:
# - account root: full admin, so IAM policies control key management
# - Route53 DNSSEC service: read the public key and sign, only for hosted zones in this account
# - Route53 DNSSEC service: create grants, only for AWS resources
data "aws_iam_policy_document" "dnssec_kms" {
statement {
sid = "EnableIAMUserPermissions"
effect = "Allow"
actions = ["kms:*"]
resources = ["*"]

principals {
type = "AWS"
identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"]
}
}

statement {
sid = "AllowRoute53DNSSECService"
effect = "Allow"
actions = ["kms:DescribeKey", "kms:GetPublicKey", "kms:Sign"]
resources = ["*"]

principals {
type = "Service"
identifiers = ["dnssec-route53.amazonaws.com"]
}

condition {
test = "StringEquals"
variable = "aws:SourceAccount"
values = [data.aws_caller_identity.current.account_id]
}

condition {
test = "ArnLike"
variable = "aws:SourceArn"
values = ["arn:aws:route53:::hostedzone/*"]
}
}

statement {
sid = "AllowRoute53DNSSECToCreateGrant"
effect = "Allow"
actions = ["kms:CreateGrant"]
resources = ["*"]

principals {
type = "Service"
identifiers = ["dnssec-route53.amazonaws.com"]
}

condition {
test = "Bool"
variable = "kms:GrantIsForAWSResource"
values = ["true"]
}
}
}

# Asymmetric signing key for the KSK. Deleting it breaks resolution once the DS record is live; never destroy
resource "aws_kms_key" "dnssec" {
provider = aws.us_east_1
description = "Route53 DNSSEC KSK for operationcode.org"
customer_master_key_spec = "ECC_NIST_P256"
key_usage = "SIGN_VERIFY"
deletion_window_in_days = 7
policy = data.aws_iam_policy_document.dnssec_kms.json

lifecycle {
prevent_destroy = true
}
}

resource "aws_kms_alias" "dnssec" {
provider = aws.us_east_1
name = "alias/route53-dnssec-operationcode-org"
target_key_id = aws_kms_key.dnssec.key_id
}

resource "aws_route53_key_signing_key" "operationcode" {
hosted_zone_id = aws_route53_zone.operationcode.id
key_management_service_arn = aws_kms_key.dnssec.arn
name = "operationcode_org_ksk"
}

resource "aws_route53_hosted_zone_dnssec" "operationcode" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium bug, confidence low, pass review

Query logging and DNSSEC changes in one apply may race

The DNSSEC signing resource has no dependency on the KMS alias or the key policy being fully propagated. KMS policy eventual consistency can make the KSK creation fail on first apply. A retry normally resolves it.

Model output from untrusted input. Check it before you act on it.

hosted_zone_id = aws_route53_key_signing_key.operationcode.hosted_zone_id

depends_on = [aws_route53_key_signing_key.operationcode]
}

# Stage 2: DS record at the registrar (Route53 Domains, us-east-1). Keep commented out until:
# 1. signing is verified: `aws route53 get-dnssec --hosted-zone-id <zone id>` shows SIGNING and the
# KSK ACTIVE, and `dig +dnssec operationcode.org SOA` returns RRSIG records
# 2. the max TTL in the zone has passed since signing started (NS: 172800s = 48h; other records: 3600s)
# Rollback order: remove the DS record first, wait for the DS TTL at the .org parent (and the
# registrar change) to pass, then disable signing (aws_route53_hosted_zone_dnssec signing_status = "NOT_SIGNING").
# Disabling signing while the DS record is live makes the domain fail resolution for validating resolvers.
#
# resource "aws_route53domains_delegation_signer_record" "operationcode" {
# provider = aws.us_east_1
# domain_name = "operationcode.org"
#
# signing_attributes {
# algorithm = aws_route53_key_signing_key.operationcode.signing_algorithm_type
# flags = aws_route53_key_signing_key.operationcode.flag
# public_key = aws_route53_key_signing_key.operationcode.public_key
# }
# }

# ======================================================================
# Query logging
# ======================================================================

resource "aws_cloudwatch_log_group" "route53_query_operationcode" {
provider = aws.us_east_1
name = "/aws/route53/operationcode.org"
retention_in_days = 30
}

# Account-wide policy (us-east-1 limit: 10 per region) that lets Route53 write any /aws/route53/* log group
data "aws_iam_policy_document" "route53_query_logging" {
statement {
sid = "Route53QueryLogging"
effect = "Allow"
actions = ["logs:CreateLogStream", "logs:PutLogEvents"]
resources = ["arn:aws:logs:us-east-1:${data.aws_caller_identity.current.account_id}:log-group:/aws/route53/*"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low security, confidence medium, pass security

Log policy grants Route53 write to all /aws/route53/* log groups

The CloudWatch resource policy allows route53.amazonaws.com to write to any log group under /aws/route53/* in the account, not only the new operationcode.org group. It is limited by SourceAccount and hostedzone SourceArn conditions, so only this account's Route53 zones can use it. The risk is small, but scoping to the specific log group ARN would follow least privilege.

Model output from untrusted input. Check it before you act on it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low security, confidence low, pass security

Route53 log policy allows writes to any /aws/route53/* log group

The account-wide CloudWatch Logs resource policy lets Route53 write to any log group under /aws/route53/* for any hosted zone in the account. It is scoped by source account, so exposure is limited to this account's own zones. The policy could instead name the specific log group and zone, so this is a minor least-privilege gap.

Model output from untrusted input. Check it before you act on it.


principals {
type = "Service"
identifiers = ["route53.amazonaws.com"]
}

condition {
test = "StringEquals"
variable = "aws:SourceAccount"
values = [data.aws_caller_identity.current.account_id]
}

condition {
test = "ArnLike"
variable = "aws:SourceArn"
values = ["arn:aws:route53:::hostedzone/*"]
}
}
}

resource "aws_cloudwatch_log_resource_policy" "route53_query_logging" {
provider = aws.us_east_1
policy_name = "route53-query-logging"
policy_document = data.aws_iam_policy_document.route53_query_logging.json
}

resource "aws_route53_query_log" "operationcode" {
zone_id = aws_route53_zone.operationcode.zone_id
cloudwatch_log_group_arn = aws_cloudwatch_log_group.route53_query_operationcode.arn

depends_on = [aws_cloudwatch_log_resource_policy.route53_query_logging]
}
Loading