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
23 changes: 23 additions & 0 deletions docs/20-hosting-and-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions docs/21-hosting-ui-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions docs/22-infrastructure-as-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down Expand Up @@ -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_project_name>.pages.dev`; non-production environments point at `<environment>.<pages_project_name>.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
Expand Down
4 changes: 4 additions & 0 deletions infra/opentofu/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions infra/opentofu/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
122 changes: 122 additions & 0 deletions infra/opentofu/security.tf
Original file line number Diff line number Diff line change
@@ -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
}
]
}
15 changes: 15 additions & 0 deletions infra/opentofu/terraform.tfvars.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading