Files

138 lines
4.3 KiB
Bash

#!/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 <sitekey> --account-id <account-id> --expected-domains '<json-array>'" >&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"}'