Skip to content
Open
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"readreplicate:status": "bash read_replicate/replicate_status.sh",
"readreplicate:check-schema": "bash scripts/check-read-replica-schema.sh",
"supabase:reset-postgres-config": "bash scripts/reset_supabase_postgres_config_defaults.sh",
"supabase:allow-cloudflare": "bash scripts/update_supabase_network_restrictions.sh",
"cloudsql:allow-cloudflare": "bash scripts/update_cloudsql_authorized_networks.sh",
"supabase:start": "bun scripts/supabase-worktree.ts start",
"supabase:stop": "bun scripts/supabase-worktree.ts stop",
Expand Down
297 changes: 297 additions & 0 deletions scripts/update_supabase_network_restrictions.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
#!/usr/bin/env bash
set -euo pipefail

# Update Supabase DB network restrictions with current Cloudflare IP ranges.
# By default this REPLACES existing restrictions with Cloudflare IPv4 + IPv6 ranges.
# Use --append to keep existing restrictions and add Cloudflare ranges.

CF_IPS_URL="https://api.cloudflare.com/client/v4/ips"
ENV_FILE="internal/cloudflare/.env.prod"
PROJECT_REF="${SUPABASE_PROJECT_REF:-}"
PROFILE="${SUPABASE_PROFILE:-}"
APPEND="false"
DRY_RUN="false"
YES="false"
INCLUDE_IPV4="true"
INCLUDE_IPV6="true"
EXTRA_CIDRS_TEXT=""

usage() {
cat <<'EOF'
Usage:
scripts/update_supabase_network_restrictions.sh [options]

Updates Supabase database network restrictions to allow Cloudflare IP ranges.
This is the firewall control needed when Postgres should only be reachable from
Cloudflare Hyperdrive and other Cloudflare egress.

Default behavior:
- Replaces existing database allowed CIDRs with Cloudflare IPv4 + IPv6 ranges.
- Parses the Supabase project ref from SUPABASE_PROJECT_REF or the env file.

Options:
--project-ref <ref> Supabase project ref. Defaults to SUPABASE_PROJECT_REF
or parses MAIN_SUPABASE_DB_URL / SUPABASE_URL from env.
--env-file <path> Env file to parse when --project-ref is omitted
(default: internal/cloudflare/.env.prod).
--profile <name> Supabase CLI profile.
--append Append Cloudflare ranges instead of replacing existing restrictions.
--ipv4-only Only apply Cloudflare IPv4 CIDRs.
--ipv6-only Only apply Cloudflare IPv6 CIDRs.
--extra-cidr <cidr> Add one extra CIDR. Can be repeated.
--cloudflare-url <url> Override Cloudflare IP API URL
(default: https://api.cloudflare.com/client/v4/ips).
--dry-run Print commands without executing them.
--yes Skip confirmation prompt.
-h, --help Show this help.

Examples:
scripts/update_supabase_network_restrictions.sh --dry-run
scripts/update_supabase_network_restrictions.sh --project-ref xvwzpoazmxkqosrdewyv --yes
scripts/update_supabase_network_restrictions.sh --project-ref xvwzpoazmxkqosrdewyv --append --extra-cidr 203.0.113.10/32
EOF
}

require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Error: required command '$1' is not installed." >&2
exit 1
fi
}

require_arg() {
local flag="$1"
local value="${2:-}"
if [[ -z "$value" || "$value" == --* ]]; then
echo "Error: $flag requires a value." >&2
exit 1
fi
}

get_env_value() {
local key="$1"
local line

[[ -f "$ENV_FILE" ]] || return 1

while IFS= read -r line; do
line="${line#"${line%%[![:space:]]*}"}"
[[ -z "$line" ]] && continue
[[ "${line:0:1}" == "#" ]] && continue
if [[ "$line" == "${key}="* ]]; then
printf "%s\n" "${line#*=}"
return 0
fi
done < "$ENV_FILE"

return 1
}

extract_project_ref_from_url() {
local url="$1"
local user host

user="$(printf "%s" "$url" | sed -E 's|^[^:]+://([^:]+):.*|\1|')"
if [[ "$user" == postgres.* ]]; then
printf "%s\n" "${user#postgres.}"
return 0
fi

host="$(printf "%s" "$url" | sed -E 's|^[^:]+://||; s|^[^@]+@||; s|[/:?].*$||')"
if [[ "$host" =~ ^db\.([a-z0-9]+)\.supabase\.co$ ]]; then
printf "%s\n" "${BASH_REMATCH[1]}"
return 0
fi
if [[ "$host" =~ ^([a-z0-9]+)\.supabase\.co$ ]]; then
printf "%s\n" "${BASH_REMATCH[1]}"
return 0
fi

return 1
}

resolve_project_ref() {
local key value

if [[ -n "$PROJECT_REF" ]]; then
return
fi

for key in MAIN_SUPABASE_DB_URL SUPABASE_DB_DIRECT_URL SUPABASE_DB_URL DATABASE_URL SUPABASE_URL SUPA_URL; do
if value="$(get_env_value "$key")"; then
if PROJECT_REF="$(extract_project_ref_from_url "$value")"; then
return
fi
fi
done

echo "Error: --project-ref not provided and no Supabase project ref could be parsed from $ENV_FILE." >&2
exit 1
}

run_supabase() {
local args=(bunx supabase --experimental)
if [[ -n "$PROFILE" ]]; then
args+=(--profile "$PROFILE")
fi
if [[ "$YES" == "true" ]]; then
args+=(--yes)
fi
args+=("$@")

if [[ "$DRY_RUN" == "true" ]]; then
printf '+'
printf ' %q' "${args[@]}"
printf '\n'
return 0
fi

"${args[@]}"
}

while [[ $# -gt 0 ]]; do
case "$1" in
--project-ref)
require_arg "$1" "${2:-}"
PROJECT_REF="$2"
shift 2
;;
--env-file)
require_arg "$1" "${2:-}"
ENV_FILE="$2"
shift 2
;;
--profile)
require_arg "$1" "${2:-}"
PROFILE="$2"
shift 2
;;
--append)
APPEND="true"
shift
;;
--ipv4-only)
INCLUDE_IPV4="true"
INCLUDE_IPV6="false"
shift
;;
--ipv6-only)
INCLUDE_IPV4="false"
INCLUDE_IPV6="true"
shift
;;
--extra-cidr)
require_arg "$1" "${2:-}"
EXTRA_CIDRS_TEXT+="$2"$'\n'
shift 2
;;
--cloudflare-url)
require_arg "$1" "${2:-}"
CF_IPS_URL="$2"
shift 2
;;
--dry-run)
DRY_RUN="true"
shift
;;
--yes)
YES="true"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage
exit 1
;;
esac
done

require_cmd curl
require_cmd bun
require_cmd bunx

resolve_project_ref

echo "==> Fetching Cloudflare IP ranges from: $CF_IPS_URL"
CF_JSON="$(curl -fsSL "$CF_IPS_URL")"
CF_LINES="$(printf '%s' "$CF_JSON" | INCLUDE_IPV4="$INCLUDE_IPV4" INCLUDE_IPV6="$INCLUDE_IPV6" bun -e '
const raw = await Bun.stdin.text()
const data = JSON.parse(raw)
if (!data?.success || !data?.result) {
console.error("Invalid Cloudflare IP API response")
process.exit(1)
}
const cidrs = []
if (process.env.INCLUDE_IPV4 === "true") {
if (!Array.isArray(data.result.ipv4_cidrs)) {
console.error("Missing Cloudflare IPv4 CIDRs")
process.exit(1)
}
cidrs.push(...data.result.ipv4_cidrs)
}
if (process.env.INCLUDE_IPV6 === "true") {
if (!Array.isArray(data.result.ipv6_cidrs)) {
console.error("Missing Cloudflare IPv6 CIDRs")
process.exit(1)
}
cidrs.push(...data.result.ipv6_cidrs)
}
console.log(cidrs.join("\n"))
')"

FINAL_CIDRS=()
while IFS= read -r cidr; do
[[ -n "$cidr" ]] && FINAL_CIDRS+=("$cidr")
done < <(printf '%s\n%s' "$CF_LINES" "$EXTRA_CIDRS_TEXT" | awk 'NF' | sort -u)

if [[ "${#FINAL_CIDRS[@]}" -eq 0 ]]; then
echo "Error: final CIDR list is empty." >&2
exit 1
fi

update_args=(network-restrictions update --project-ref "$PROJECT_REF")
if [[ "$APPEND" == "true" ]]; then
update_args+=(--append)
fi
for cidr in "${FINAL_CIDRS[@]}"; do
update_args+=(--db-allow-cidr "$cidr")
done

echo "==> Supabase project ref: $PROJECT_REF"
echo "==> CIDR count: ${#FINAL_CIDRS[@]}"
echo "==> Mode: $([[ "$APPEND" == "true" ]] && echo 'append' || echo 'replace')"
echo "==> Dry run: $DRY_RUN"

echo "==> Current Supabase network restrictions:"
run_supabase network-restrictions get --project-ref "$PROJECT_REF" || true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fail closed when the current restrictions cannot be read. This is the only preflight before the default replace operation, but || true lets the script proceed after any GET failure. I reproduced this with a stub where network-restrictions get exits 42 while update succeeds: the script still executed the replacing update and exited 0. In --yes automation, a transient GET/API failure can therefore overwrite existing non-Cloudflare CIDRs without ever displaying them. The same suppression at line 295 also prints Done when post-update verification fails. Please require a successful preflight in replace mode and propagate the final verification failure.


if [[ "$YES" != "true" && "$DRY_RUN" != "true" ]]; then
echo ""
if [[ "$APPEND" == "true" ]]; then
echo "This appends Cloudflare CIDRs to existing Supabase DB network restrictions."
else
echo "This replaces existing Supabase DB network restrictions with Cloudflare CIDRs only."
echo "Direct Postgres connections from non-Cloudflare IPs will be blocked."
fi
read -r -p "Proceed with updating network restrictions for project $PROJECT_REF? [y/N]: " confirm
case "$confirm" in
y|Y|yes|YES)
;;
*)
echo "Aborted."
exit 0
;;
esac
fi

echo "==> Updating Supabase network restrictions..."
run_supabase "${update_args[@]}"

echo "==> Updated Supabase network restrictions:"
run_supabase network-restrictions get --project-ref "$PROJECT_REF" || true

echo "==> Done."
Loading