66 lines
2.1 KiB
Python
66 lines
2.1 KiB
Python
|
|
"""Cloudflare Turnstile verification for public web form submissions.
|
||
|
|
|
||
|
|
Optional per form and off by default. `requests` is already a dependency, so
|
||
|
|
this adds no package, and it makes no network call unless an org has configured
|
||
|
|
both a provider and a secret.
|
||
|
|
|
||
|
|
FAILS CLOSED. Every path that cannot positively confirm a good token returns
|
||
|
|
False: a timeout, a connection error, a malformed response, a missing secret, a
|
||
|
|
missing token. Failing open would mean an attacker's first step is to make
|
||
|
|
Cloudflare unreachable.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import logging
|
||
|
|
|
||
|
|
import requests
|
||
|
|
|
||
|
|
from webforms.models import WebForm
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||
|
|
VERIFY_TIMEOUT_SECONDS = 5
|
||
|
|
|
||
|
|
|
||
|
|
def verify(form, token, remote_ip):
|
||
|
|
"""Whether this submission's captcha token is good.
|
||
|
|
|
||
|
|
Returns True immediately when the form has no captcha configured, which is
|
||
|
|
the default. The secret is sent to Cloudflare and never written to a log
|
||
|
|
line, including on the failure paths.
|
||
|
|
"""
|
||
|
|
if form.captcha_provider != WebForm.CAPTCHA_TURNSTILE:
|
||
|
|
return True
|
||
|
|
|
||
|
|
if not form.captcha_secret:
|
||
|
|
logger.warning(
|
||
|
|
"Web form %s has a captcha provider set but no secret; failing closed.",
|
||
|
|
form.id,
|
||
|
|
)
|
||
|
|
return False
|
||
|
|
|
||
|
|
if not token:
|
||
|
|
return False
|
||
|
|
|
||
|
|
payload = {"secret": form.captcha_secret, "response": token}
|
||
|
|
if remote_ip:
|
||
|
|
payload["remoteip"] = remote_ip
|
||
|
|
|
||
|
|
try:
|
||
|
|
response = requests.post(
|
||
|
|
TURNSTILE_VERIFY_URL, data=payload, timeout=VERIFY_TIMEOUT_SECONDS
|
||
|
|
)
|
||
|
|
body = response.json()
|
||
|
|
except (requests.RequestException, ValueError) as exc:
|
||
|
|
# `exc` here is a requests exception or a JSON decode error, neither of
|
||
|
|
# which carries the request body, so the secret cannot reach the log
|
||
|
|
# through it.
|
||
|
|
logger.warning(
|
||
|
|
"Turnstile verification failed for web form %s: %s", form.id, exc
|
||
|
|
)
|
||
|
|
return False
|
||
|
|
|
||
|
|
if not isinstance(body, dict):
|
||
|
|
return False
|
||
|
|
return bool(body.get("success"))
|