diff --git a/docs/20-hosting-and-security.md b/docs/20-hosting-and-security.md index bba0ff7..d5b18f1 100644 --- a/docs/20-hosting-and-security.md +++ b/docs/20-hosting-and-security.md @@ -42,6 +42,7 @@ Already implemented: - Pages static headers include HSTS, frame denial, referrer policy, nosniff, and a restrictive permissions policy. - Repository data, analysis results, reports, and exports stay out of Cloudflare. - Initial OpenTofu scaffold for Cloudflare Pages, KV, domains, and environment output wiring. +- OpenTofu-managed security controls for WAF geo challenges, API/auth rate limits, and optional staging Access allowlist. Not production-ready yet: - Production `SETTINGS_KV` namespace IDs are not configured in `wrangler.toml`. @@ -94,6 +95,7 @@ Infrastructure: - OpenTofu configuration lives in `infra/opentofu`. - Keep secret values out of OpenTofu state. - Use OpenTofu outputs for `SETTINGS_KV`, Pages env vars, Worker env vars, and GitHub App URLs. +- Use OpenTofu security variables for WAF, rate limiting, and staging Access once the domain is active in Cloudflare. Pages production config: - Build command: `pnpm --filter @forage/web build` @@ -122,6 +124,25 @@ Local config: - Copy `apps/worker/.dev.vars.example` to `apps/worker/.dev.vars`. - Never commit `.env`, `.env.local`, `.dev.vars`, or production secret files. +## Traffic Protection + +Staging web should be private by default: +- Enable `manage_staging_access`. +- Set `staging_access_allowed_emails` to the operator/tester email addresses. +- Cloudflare Access protects the staging web hostname before the app is reached. +- Do not put the staging API hostname behind Access initially because OAuth callbacks, cookies, and CORS are easier to validate with WAF and rate limits first. + +Hosted web and API domains should use WAF/rate-limit controls: +- Enable `manage_security_controls`. +- Keep `security_allowed_countries = ["US", "CA"]` unless product testing requires broader access. +- Use `managed_challenge` before `block` for production until real traffic patterns are better understood. +- Use the combined `/auth/*` and `/api/*` rate-limit rule on Cloudflare plans that allow only one `http_ratelimit` rule per zone. +- Use short rate-limit blocks when the Cloudflare plan does not allow managed challenges in `http_ratelimit`. + +Cloudflare Pages branch URLs require separate attention: +- Add the branch hostname to `staging_access_extra_hostnames` if it should be covered by the same Access app. +- Also verify Pages preview access settings in the Cloudflare UI because Pages preview URLs can remain reachable outside custom-domain routing. + ## Security Headers Pages: @@ -194,6 +215,8 @@ Do not point local development at production KV by default. - Verify no repository data is written to Worker logs, KV, D1, R2, or analytics. - Verify GitHub tokens are never returned to the browser. - Add deployment preview checks after Cloudflare bindings are configured. +- Verify WAF/rate-limit rules do not break GitHub OAuth start/callback. +- Verify Cloudflare Access blocks unauthenticated staging web access and allows the configured tester emails. ## Sources Checked diff --git a/docs/21-hosting-ui-setup.md b/docs/21-hosting-ui-setup.md index 4e5a5fa..cdab1a8 100644 --- a/docs/21-hosting-ui-setup.md +++ b/docs/21-hosting-ui-setup.md @@ -136,6 +136,40 @@ Optional fallback KV namespaces: These are fallback stores only. Production should use `AUTH_COORDINATOR` for OAuth/session coordination. +## Cloudflare Security UI + +Before applying OpenTofu Access resources: +- Open `Zero Trust` in the Cloudflare dashboard. +- Complete the initial Access setup for the Cloudflare account. +- Confirm the login method you want testers to use is available. + +OpenTofu can manage: +- Staging web Access application. +- Staging tester allow policy. +- Zone WAF geo challenge rules. +- Zone rate limiting rule for `/auth/*` and `/api/*`. + +Minimum security-token permissions: +- Zone `shrimpworks.dev`: Zone Read, Zone WAF Write +- Account: Access Apps and Policies Write, Access Organizations Read, Zero Trust Read + +If using the same token for the full OpenTofu root, include read/write permissions for the resources already managed in state, such as Pages, Workers KV, Worker scripts/custom domains, and DNS. + +Recommended staging posture: +- Protect `forage-staging.example.com` with Cloudflare Access. +- Allow only explicit tester email addresses. +- Do not put `api-staging.forage.example.com` behind Access initially. +- Use WAF and rate limiting for the API hostname so GitHub OAuth callbacks and credentialed CORS remain straightforward to test. + +Recommended production posture: +- Do not put the public web app behind Access. +- Use managed challenges before hard blocks until real traffic patterns are known. +- Keep the combined API/auth rate limit enabled. + +Pages preview URLs: +- Add the Pages branch hostname to `staging_access_extra_hostnames` if OpenTofu should include it in the Access app. +- Also check Cloudflare Pages preview access settings in the dashboard because preview URLs can be exposed independently from custom domains. + ## GitHub Repository UI Required repository settings: diff --git a/docs/22-infrastructure-as-code.md b/docs/22-infrastructure-as-code.md index ddae638..a715b63 100644 --- a/docs/22-infrastructure-as-code.md +++ b/docs/22-infrastructure-as-code.md @@ -22,6 +22,9 @@ Current OpenTofu scope: - DNS CNAME records for Pages custom domains. - Cloudflare KV namespaces for `SETTINGS_KV`. - Optional Worker custom domains after the Worker service exists. +- Optional WAF custom rules for hosted web/API traffic. +- Optional rate limiting rule for hosted `/auth/*` and `/api/*` endpoints. +- Optional Cloudflare Access app and policy for staging web allowlisting. - Outputs for GitHub App homepage/callback URL values. - Outputs for Worker and Pages environment variables. @@ -32,6 +35,8 @@ Current manual scope: - Worker secret values. - First Worker code deployment with Wrangler. - Any Cloudflare Git integration that requires a connected GitHub account. +- Cloudflare Zero Trust account initialization before Access resources can be applied. +- Pages preview access settings in the Cloudflare UI when branch URLs need protection. ## Privacy Boundary @@ -62,6 +67,29 @@ Worker custom domains intentionally attach to the Cloudflare Worker service envi Pages custom domain DNS is managed by OpenTofu when `manage_pages_domains = true`. Production points at `.pages.dev`; non-production environments point at `..pages.dev`, so the Pages branch name must match the environment key. +Security controls are opt-in: + +```hcl +manage_security_controls = true +manage_staging_access = true + +staging_access_allowed_emails = [ + "operator@example.com", +] +``` + +`manage_security_controls` creates zone-level WAF rules and a combined API/auth rate-limit rule for the configured hosted domains. `manage_staging_access` creates a Cloudflare Access self-hosted application for the staging web hostname when at least one allowed email is configured. + +The default combined rate limit uses a 10-second period, 10-second mitigation timeout, and `block` action because some Cloudflare plans only allow one rate-limit rule per zone with those `http_ratelimit` values. Raise the entitlement-specific values only after confirming the active zone plan allows them. + +For a temporary local token named `TEMP_CLOUDFLARE_API_TOKEN`, run plans with: + +```sh +CLOUDFLARE_API_TOKEN="$TEMP_CLOUDFLARE_API_TOKEN" tofu plan +``` + +If the same OpenTofu state already manages Pages, KV, DNS, or Worker custom domains, a normal `tofu plan` refreshes those resources too. The API token must therefore be able to read existing managed resources, not only the new security resources. A narrow security-only token can validate the new resources with `-refresh=false`, but use a full infra/deploy token for normal apply runs. + Use [Deployment Automation](./23-deployment-automation.md) for the GitHub Actions workflow, repository secrets, GitHub environment variables, and first deployment order. ## Sources Checked diff --git a/infra/opentofu/main.tf b/infra/opentofu/main.tf index 8373e44..f837bcc 100644 --- a/infra/opentofu/main.tf +++ b/infra/opentofu/main.tf @@ -73,6 +73,8 @@ resource "cloudflare_pages_project" "web" { deployment_configs = { preview = { + compatibility_date = var.pages_compatibility_date + compatibility_flags = var.pages_compatibility_flags env_vars = { NODE_VERSION = { type = "plain_text" @@ -86,6 +88,8 @@ resource "cloudflare_pages_project" "web" { fail_open = false } production = { + compatibility_date = var.pages_compatibility_date + compatibility_flags = var.pages_compatibility_flags env_vars = { NODE_VERSION = { type = "plain_text" diff --git a/infra/opentofu/outputs.tf b/infra/opentofu/outputs.tf index b575227..7ec70ea 100644 --- a/infra/opentofu/outputs.tf +++ b/infra/opentofu/outputs.tf @@ -58,3 +58,16 @@ output "pages_custom_domains" { } } } + +output "security_controls" { + description = "Cloudflare security controls managed by this configuration." + value = { + waf_ruleset_id = try(cloudflare_ruleset.forage_firewall_custom[0].id, null) + rate_limit_ruleset_id = try(cloudflare_ruleset.forage_rate_limits[0].id, null) + staging_access_app = try({ + id = cloudflare_zero_trust_access_application.staging_web[0].id + domain = cloudflare_zero_trust_access_application.staging_web[0].domain + hostnames = local.staging_access_hostnames + }, null) + } +} diff --git a/infra/opentofu/security.tf b/infra/opentofu/security.tf new file mode 100644 index 0000000..1dcc7fd --- /dev/null +++ b/infra/opentofu/security.tf @@ -0,0 +1,122 @@ +locals { + security_enabled = var.manage_security_controls + security_allowed_country_set = "{${join(" ", [for country in var.security_allowed_countries : "\"${country}\""])}}" + security_all_api_hostnames = [for environment in local.environment_config : environment.api_hostname] + security_api_host_set = "{${join(" ", [for hostname in local.security_all_api_hostnames : "\"${hostname}\""])}}" + staging_security_hostnames = [ + local.environment_config[var.staging_environment_name].web_hostname, + local.environment_config[var.staging_environment_name].api_hostname, + ] + production_security_hostnames = [ + local.environment_config["production"].web_hostname, + local.environment_config["production"].api_hostname, + ] + staging_security_host_set = "{${join(" ", [for hostname in local.staging_security_hostnames : "\"${hostname}\""])}}" + production_security_host_set = "{${join(" ", [for hostname in local.production_security_hostnames : "\"${hostname}\""])}}" + staging_access_hostnames = distinct(concat([local.environment_config[var.staging_environment_name].web_hostname], tolist(var.staging_access_extra_hostnames))) + staging_access_policy_enabled = var.manage_staging_access && length(var.staging_access_allowed_emails) > 0 + + production_geo_expression = "(http.host in ${local.production_security_host_set} and not ip.geoip.country in ${local.security_allowed_country_set})" + staging_geo_expression = "(http.host in ${local.staging_security_host_set} and not ip.geoip.country in ${local.security_allowed_country_set})" + api_auth_rate_limit_expression = <<-EOT + (http.host in ${local.security_api_host_set} and (starts_with(http.request.uri.path, "/api/") or starts_with(http.request.uri.path, "/auth/"))) + EOT +} + +resource "cloudflare_ruleset" "forage_firewall_custom" { + count = local.security_enabled ? 1 : 0 + + zone_id = var.cloudflare_zone_id + name = "${var.project_slug} hosted traffic controls" + description = "Forage WAF controls for hosted web and API traffic." + kind = "zone" + phase = "http_request_firewall_custom" + + rules = [ + { + ref = "forage_staging_geo_challenge" + description = "Challenge non-primary-region traffic to staging web and API hosts." + expression = local.staging_geo_expression + action = var.security_geo_challenge_action + enabled = var.security_staging_geo_challenge_enabled + }, + { + ref = "forage_hosted_geo_challenge" + description = "Challenge non-primary-region traffic to production Forage web and API hosts." + expression = local.production_geo_expression + action = var.security_geo_challenge_action + enabled = var.security_production_geo_challenge_enabled + }, + ] +} + +resource "cloudflare_ruleset" "forage_rate_limits" { + count = local.security_enabled ? 1 : 0 + + zone_id = var.cloudflare_zone_id + name = "${var.project_slug} API rate limits" + description = "Forage rate limits for hosted auth and API endpoints." + kind = "zone" + phase = "http_ratelimit" + + rules = [ + { + ref = "forage_api_auth_rate_limit" + description = "Challenge clients that hit Forage API or GitHub auth endpoints too frequently." + expression = trimspace(local.api_auth_rate_limit_expression) + action = var.security_rate_limit_action + enabled = var.security_rate_limits_enabled + ratelimit = { + characteristics = ["ip.src", "cf.colo.id"] + period = var.security_api_auth_rate_limit.period_seconds + requests_per_period = var.security_api_auth_rate_limit.requests_per_period + mitigation_timeout = var.security_api_auth_rate_limit.mitigation_timeout_seconds + } + }, + ] +} + +resource "cloudflare_zero_trust_access_policy" "staging_allowlist" { + count = local.staging_access_policy_enabled ? 1 : 0 + + account_id = var.cloudflare_account_id + name = "${var.project_slug} staging tester allowlist" + decision = "allow" + session_duration = var.staging_access_session_duration + + include = [ + for email in var.staging_access_allowed_emails : { + email = { + email = email + } + } + ] +} + +resource "cloudflare_zero_trust_access_application" "staging_web" { + count = local.staging_access_policy_enabled ? 1 : 0 + + account_id = var.cloudflare_account_id + name = "${var.project_slug} staging web" + domain = local.environment_config[var.staging_environment_name].web_hostname + type = "self_hosted" + app_launcher_visible = false + enable_binding_cookie = true + http_only_cookie_attribute = true + same_site_cookie_attribute = "strict" + session_duration = var.staging_access_session_duration + + destinations = [ + for hostname in local.staging_access_hostnames : { + type = "public" + uri = hostname + } + ] + + policies = [ + { + id = cloudflare_zero_trust_access_policy.staging_allowlist[0].id + precedence = 1 + } + ] +} diff --git a/infra/opentofu/terraform.tfvars.example b/infra/opentofu/terraform.tfvars.example index 444db8f..b53861c 100644 --- a/infra/opentofu/terraform.tfvars.example +++ b/infra/opentofu/terraform.tfvars.example @@ -10,6 +10,21 @@ worker_service_name = "forage-worker" # Leave false for the first apply if Wrangler has not deployed the Worker yet. manage_worker_custom_domains = false +# Hosted traffic protection. Keep disabled until the zone is ready for WAF and Access resources. +manage_security_controls = false +manage_staging_access = false + +security_allowed_countries = ["US", "CA"] + +staging_access_allowed_emails = [ + "replace-with-your-email@example.com", +] + +# Optional: protect the Cloudflare Pages branch URL as well as the custom staging hostname. +staging_access_extra_hostnames = [ + "staging.forage-web.pages.dev", +] + environments = { staging = { web_hostname = "staging.forage.example.com" diff --git a/infra/opentofu/variables.tf b/infra/opentofu/variables.tf index cd85a01..082c607 100644 --- a/infra/opentofu/variables.tf +++ b/infra/opentofu/variables.tf @@ -44,6 +44,18 @@ variable "node_version" { default = "22" } +variable "pages_compatibility_date" { + description = "Cloudflare Pages compatibility date preserved in preview and production deployment configs." + type = string + default = "2026-06-10" +} + +variable "pages_compatibility_flags" { + description = "Cloudflare Pages compatibility flags preserved in preview and production deployment configs." + type = list(string) + default = [] +} + variable "github_api_version" { description = "GitHub REST API version passed to the Worker." type = string @@ -68,6 +80,107 @@ variable "manage_worker_custom_domains" { default = false } +variable "manage_security_controls" { + description = "Whether OpenTofu should manage Cloudflare WAF and rate limiting rules for hosted Forage domains." + type = bool + default = false +} + +variable "manage_staging_access" { + description = "Whether OpenTofu should manage a Cloudflare Access application for the staging web hostname." + type = bool + default = false +} + +variable "staging_environment_name" { + description = "Environment key protected by Cloudflare Access for hosted staging." + type = string + default = "staging" +} + +variable "staging_access_allowed_emails" { + description = "Email addresses allowed through the staging Cloudflare Access application. Required when manage_staging_access is true." + type = set(string) + default = [] +} + +variable "staging_access_extra_hostnames" { + description = "Additional staging hostnames protected by the same Access app, such as a Pages preview branch hostname." + type = set(string) + default = [] +} + +variable "staging_access_session_duration" { + description = "Cloudflare Access session duration for staging web access." + type = string + default = "8h" +} + +variable "security_allowed_countries" { + description = "Country codes treated as primary expected traffic sources for hosted Forage domains." + type = set(string) + default = ["US", "CA"] + + validation { + condition = length(var.security_allowed_countries) > 0 && alltrue([for country in var.security_allowed_countries : can(regex("^[A-Z]{2}$", country))]) + error_message = "security_allowed_countries must contain one or more uppercase ISO 3166-1 alpha-2 country codes." + } +} + +variable "security_geo_challenge_action" { + description = "WAF action used for traffic outside security_allowed_countries." + type = string + default = "managed_challenge" + + validation { + condition = contains(["managed_challenge", "js_challenge", "challenge", "block"], var.security_geo_challenge_action) + error_message = "security_geo_challenge_action must be one of managed_challenge, js_challenge, challenge, or block." + } +} + +variable "security_rate_limit_action" { + description = "WAF action used when hosted API or auth rate limits are exceeded." + type = string + default = "block" + + validation { + condition = contains(["block", "managed_challenge", "js_challenge", "challenge"], var.security_rate_limit_action) + error_message = "security_rate_limit_action must be one of block, managed_challenge, js_challenge, or challenge." + } +} + +variable "security_staging_geo_challenge_enabled" { + description = "Whether the staging geo challenge WAF rule is enabled." + type = bool + default = true +} + +variable "security_production_geo_challenge_enabled" { + description = "Whether the production geo challenge WAF rule is enabled." + type = bool + default = true +} + +variable "security_rate_limits_enabled" { + description = "Whether API and auth rate limiting rules are enabled." + type = bool + default = true +} + +variable "security_api_auth_rate_limit" { + description = "Combined rate limit threshold for hosted /auth/* and /api/* endpoints. Some Cloudflare plans allow only one http_ratelimit rule per zone." + type = object({ + period_seconds = number + requests_per_period = number + mitigation_timeout_seconds = number + }) + default = { + period_seconds = 10 + requests_per_period = 20 + mitigation_timeout_seconds = 10 + } +} + variable "environments" { description = "Hosted environment topology. Keep repository data out of all server-side resources." type = map(object({ @@ -108,4 +221,9 @@ variable "environments" { condition = contains(keys(var.environments), var.preview_environment_name) error_message = "preview_environment_name must match a key in environments." } + + validation { + condition = contains(keys(var.environments), var.staging_environment_name) + error_message = "staging_environment_name must match a key in environments." + } }