#!/usr/bin/env bash # Validates a Turnstile widget without placing its secret in arguments, # exported environment variables, logs, or temporary files. set +x set -euo pipefail usage() { echo "Usage: printf '%s' \"\$TURNSTILE_SECRET\" | $0 --sitekey --account-id --expected-domains ''" >&2 exit 2 } need_arg() { if [[ -z "${2-}" || "$2" == --* ]]; then usage fi } SITEKEY="" ACCOUNT_ID="" EXPECTED_DOMAINS_JSON="" while [[ $# -gt 0 ]]; do case "$1" in --sitekey) need_arg "$1" "${2-}" SITEKEY="$2" shift 2 ;; --account-id) need_arg "$1" "${2-}" ACCOUNT_ID="$2" shift 2 ;; --expected-domains) need_arg "$1" "${2-}" EXPECTED_DOMAINS_JSON="$2" shift 2 ;; *) usage ;; esac done [[ -n "$SITEKEY" && -n "$ACCOUNT_ID" && -n "$EXPECTED_DOMAINS_JSON" ]] || usage : "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN must be set}" API_TOKEN="$CLOUDFLARE_API_TOKEN" unset CLOUDFLARE_API_TOKEN [[ "$API_TOKEN" =~ ^[A-Za-z0-9_-]+$ ]] || { echo "validate: CLOUDFLARE_API_TOKEN has an invalid format" >&2 exit 1 } for command_name in curl jq python3; do command -v "$command_name" >/dev/null 2>&1 || { echo "validate: $command_name is required" >&2 exit 1 } done if ! jq -e ' type == "array" and length > 0 and all(.[]; type == "string" and length > 0) ' <<<"$EXPECTED_DOMAINS_JSON" >/dev/null; then echo "validate: --expected-domains must be a non-empty JSON array of domains" >&2 exit 2 fi WIDGET_SECRET="" IFS= read -r -d '' WIDGET_SECRET || true trap 'unset API_TOKEN WIDGET_SECRET WIDGET_API_SECRET WIDGET_RESPONSE SITEVERIFY_RESPONSE' EXIT if [[ -z "$WIDGET_SECRET" || "$WIDGET_SECRET" =~ [[:space:]] ]]; then echo "validate: standard input must contain one non-empty secret without whitespace" >&2 exit 1 fi ACCOUNT_ENCODED="$(python3 -I -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$ACCOUNT_ID")" SITEKEY_ENCODED="$(python3 -I -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$SITEKEY")" if ! WIDGET_RESPONSE="$( printf 'header = "Authorization: Bearer %s"\n' "$API_TOKEN" | curl --disable --config - --fail --silent --show-error \ "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ENCODED/challenges/widgets/$SITEKEY_ENCODED" )"; then echo "validate: widget metadata lookup failed" >&2 exit 1 fi if ! printf '%s' "$WIDGET_RESPONSE" | jq -e --arg sitekey "$SITEKEY" --argjson expected "$EXPECTED_DOMAINS_JSON" ' . as $widget | (.success == true) and (.result.sitekey == $sitekey) and ((.result.clearance_level | type) == "string") and (.result.clearance_level as $clearance | ["no_clearance", "interactive", "managed", "jschallenge"] | index($clearance) != null) and ((.result.domains | type) == "array") and (all($expected[]; . as $domain | $widget.result.domains | index($domain) != null)) ' >/dev/null; then echo "validate: widget sitekey, domains, or clearance level was invalid" >&2 exit 1 fi if ! WIDGET_API_SECRET="$(printf '%s' "$WIDGET_RESPONSE" | jq -er '.result.secret | select(type == "string" and test("^\\S+$"))')"; then echo "validate: widget metadata did not include a valid secret" >&2 exit 1 fi if [[ "$WIDGET_API_SECRET" != "$WIDGET_SECRET" ]]; then echo "validate: secret does not belong to the requested sitekey" >&2 exit 1 fi unset WIDGET_API_SECRET unset WIDGET_RESPONSE if ! SITEVERIFY_RESPONSE="$( printf '%s' "$WIDGET_SECRET" | python3 -I -c 'import sys,urllib.parse; print(urllib.parse.urlencode({"secret":sys.stdin.read(),"response":"XXXX.DUMMY.TOKEN.XXXX"}),end="")' | curl --disable --fail --silent --show-error \ "https://challenges.cloudflare.com/turnstile/v0/siteverify" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-binary @- )"; then echo "validate: dummy-token siteverify request failed" >&2 exit 1 fi if ! jq -e ' (.success == false) and ((.["error-codes"] | type) == "array") and ((.["error-codes"] | index("invalid-input-response")) != null) and ((.["error-codes"] | index("invalid-input-secret")) == null) ' <<<"$SITEVERIFY_RESPONSE" >/dev/null; then echo "validate: siteverify did not confirm the widget secret" >&2 exit 1 fi unset WIDGET_SECRET SITEVERIFY_RESPONSE echo '{"status":"ok","metadata_check":"ran","dummy_siteverify":"ran"}'