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
Empty file.
7 changes: 7 additions & 0 deletions backend/src/baserow/api/captcha/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from rest_framework.status import HTTP_400_BAD_REQUEST

ERROR_CAPTCHA_VERIFICATION_FAILED = (
"ERROR_CAPTCHA_VERIFICATION_FAILED",
HTTP_400_BAD_REQUEST,
"Captcha verification failed. Please try again.",
)
10 changes: 10 additions & 0 deletions backend/src/baserow/api/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ def _set_user_websocket_id(user, websocket_id):


def get_user_remote_ip_address_from_request(request):
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
# X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2.
# The first one is the original client IP.
return x_forwarded_for.split(",")[0].strip()

x_real_ip = request.META.get("HTTP_X_REAL_IP")
if x_real_ip:
return x_real_ip.strip()

return request.META.get("REMOTE_ADDR")


Expand Down
6 changes: 6 additions & 0 deletions backend/src/baserow/api/user/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ class RegisterSerializer(serializers.Serializer):
"account. This only works if the `workspace_invitation_token` param is not "
"provided.",
)
captcha_token = serializers.CharField(
required=False,
default="",
allow_blank=True,
help_text="The captcha response token, required when captcha is enabled.",
)


class AccountSerializer(serializers.Serializer):
Expand Down
12 changes: 12 additions & 0 deletions backend/src/baserow/api/user/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
UndoRedoResponseSerializer,
get_undo_request_serializer,
)
from baserow.api.captcha.errors import ERROR_CAPTCHA_VERIFICATION_FAILED
from baserow.api.decorators import map_exceptions, validate_body
from baserow.api.errors import (
BAD_TOKEN_SIGNATURE,
Expand All @@ -34,6 +35,7 @@
from baserow.api.schemas import get_error_schema
from baserow.api.sessions import (
get_untrusted_client_session_id,
get_user_remote_ip_address_from_request,
set_user_session_data_from_request,
)
from baserow.api.user.registries import user_data_registry
Expand All @@ -48,6 +50,8 @@
EmailVerificationRequired,
)
from baserow.core.auth_provider.handler import PasswordProviderHandler
from baserow.core.captcha.exceptions import CaptchaVerificationFailed
from baserow.core.captcha.handler import CaptchaHandler
from baserow.core.exceptions import (
BaseURLHostnameNotAllowed,
LockConflict,
Expand Down Expand Up @@ -286,6 +290,7 @@ class UserView(APIView):
"ERROR_GROUP_INVITATION_DOES_NOT_EXIST",
"ERROR_REQUEST_BODY_VALIDATION",
"BAD_TOKEN_SIGNATURE",
"ERROR_CAPTCHA_VERIFICATION_FAILED",
]
),
404: get_error_schema(["ERROR_GROUP_INVITATION_DOES_NOT_EXIST"]),
Expand All @@ -302,6 +307,7 @@ class UserView(APIView):
WorkspaceInvitationEmailMismatch: ERROR_GROUP_INVITATION_EMAIL_MISMATCH,
DisabledSignupError: ERROR_DISABLED_SIGNUP,
AuthProviderDisabled: ERROR_AUTH_PROVIDER_DISABLED,
CaptchaVerificationFailed: ERROR_CAPTCHA_VERIFICATION_FAILED,
}
)
@validate_body(RegisterSerializer)
Expand All @@ -311,6 +317,12 @@ def post(self, request, data):
if not PasswordProviderHandler.get().enabled:
raise AuthProviderDisabled()

CaptchaHandler.validate_if_required(
"signup",
data.get("captcha_token"),
get_user_remote_ip_address_from_request(request),
)

template = (
Template.objects.get(pk=data["template_id"])
if data["template_id"]
Expand Down
11 changes: 11 additions & 0 deletions backend/src/baserow/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1520,3 +1520,14 @@ def install_cachalot():
try_float(os.getenv("BASEROW_DEADLOCK_INITIAL_BACKOFF"), 0.2),
0.1,
)

# Set to "all" to enable captcha everywhere, or comma-separated contexts like
# "signup,invitations" to enable only in specific places.
BASEROW_ENABLE_CAPTCHA = os.getenv("BASEROW_ENABLE_CAPTCHA", "")
BASEROW_CAPTCHA_PROVIDER = os.getenv("BASEROW_CAPTCHA_PROVIDER", "cloudflare_turnstile")
BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY = os.getenv(
"BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY", ""
)
BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY = os.getenv(
"BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY", ""
)
1 change: 1 addition & 0 deletions backend/src/baserow/config/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ def getenv_for_tests(key: str, default: str = "") -> str:

install_cachalot()

BASEROW_ENABLE_CAPTCHA = ""

try:
from .local_test import * # noqa: F403, F401
Expand Down
12 changes: 12 additions & 0 deletions backend/src/baserow/core/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,18 @@ def ready(self):

two_factor_auth_type_registry.register(TOTPAuthProviderType())

from baserow.core.captcha.provider_types import (
CloudflareTurnstileCaptchaProviderType,
)
from baserow.core.captcha.registries import captcha_provider_registry

captcha_provider_registry.register(CloudflareTurnstileCaptchaProviderType())

from baserow.api.settings.registries import settings_data_registry
from baserow.core.captcha.settings_data_type import CaptchaSettingsDataType

settings_data_registry.register(CaptchaSettingsDataType())

import baserow.core.notifications.receivers # noqa: F401
import baserow.core.notifications.tasks # noqa: F401
from baserow.core.notification_types import (
Expand Down
Empty file.
9 changes: 9 additions & 0 deletions backend/src/baserow/core/captcha/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from baserow.core.exceptions import InstanceTypeDoesNotExist


class CaptchaVerificationFailed(Exception):
"""Raised when captcha token validation fails."""


class CaptchaProviderDoesNotExist(InstanceTypeDoesNotExist):
"""Raised when the requested captcha provider type does not exist."""
101 changes: 101 additions & 0 deletions backend/src/baserow/core/captcha/handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from typing import Optional

from django.conf import settings

from baserow.core.captcha.exceptions import CaptchaVerificationFailed
from baserow.core.captcha.registries import (
CaptchaProviderType,
captcha_provider_registry,
)


class CaptchaHandler:
@staticmethod
def is_enabled() -> bool:
"""
Return True if the captcha is enabled in the instance.

:return: True if captcha is enabled in this instance.
"""

return bool(getattr(settings, "BASEROW_ENABLE_CAPTCHA", ""))

@staticmethod
def is_captcha_enabled_for(context: str) -> bool:
"""
Returns True if captcha is enabled for the given context.

:param context: The context to check, e.g. "signup" or "invitations".
:return: True if captcha should be required for this context.
"""

if not CaptchaHandler.is_enabled():
return False

enabled = getattr(settings, "BASEROW_ENABLE_CAPTCHA", "")
enabled = enabled.strip().lower()
if enabled == "all":
return True

enabled_contexts = [c.strip().lower() for c in enabled.split(",") if c.strip()]
return context.lower() in enabled_contexts

@staticmethod
def get_active_provider() -> CaptchaProviderType:
"""
Returns the active captcha provider based on the BASEROW_CAPTCHA_PROVIDER
setting.

:raises CaptchaProviderDoesNotExist: If the configured provider type is not
registered.
:raises RuntimeError: If the provider is not properly configured (missing
env vars).
"""

provider_type = getattr(settings, "BASEROW_CAPTCHA_PROVIDER", "")
if not provider_type:
raise RuntimeError(
"BASEROW_ENABLE_CAPTCHA is set but BASEROW_CAPTCHA_PROVIDER is empty. "
"Please configure a captcha provider."
)

provider = captcha_provider_registry.get(provider_type)

if not provider.is_configured():
raise RuntimeError(
f"Captcha provider '{provider_type}' is enabled but not properly "
f"configured. Please check that all required environment variables "
f"are set."
)

return provider

@staticmethod
def validate_if_required(
context: str,
token: str,
remote_ip: Optional[str] = None,
) -> None:
"""
Validates the captcha token if captcha is enabled for the given context.
Does nothing if captcha is not enabled.

:param context: The context to check, e.g. "signup".
:param token: The captcha response token from the client.
:param remote_ip: Optional IP address of the client.
:raises CaptchaVerificationFailed: If captcha is required and the token is
invalid or missing.
:raises RuntimeError: If captcha is enabled but not properly configured.
"""

if not CaptchaHandler.is_captcha_enabled_for(context):
return

provider = CaptchaHandler.get_active_provider()

if not token:
raise CaptchaVerificationFailed(
"Captcha token is required but was not provided."
)

provider.validate_token(token, remote_ip)
52 changes: 52 additions & 0 deletions backend/src/baserow/core/captcha/provider_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import logging
from typing import Optional

from django.conf import settings

import requests

from baserow.core.captcha.exceptions import CaptchaVerificationFailed
from baserow.core.captcha.registries import CaptchaProviderType

logger = logging.getLogger(__name__)

TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"


class CloudflareTurnstileCaptchaProviderType(CaptchaProviderType):
type = "cloudflare_turnstile"

def is_configured(self) -> bool:
return bool(
getattr(settings, "BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY", "")
and getattr(settings, "BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY", "")
)

def get_frontend_config(self) -> dict:
return {
"site_key": getattr(settings, "BASEROW_CLOUDFLARE_TURNSTILE_SITE_KEY", ""),
}

def validate_token(self, token: str, remote_ip: Optional[str] = None) -> bool:
secret_key = getattr(settings, "BASEROW_CLOUDFLARE_TURNSTILE_SECRET_KEY", "")

payload = {
"secret": secret_key,
"response": token,
}

if remote_ip:
payload["remoteip"] = remote_ip

# Let network errors and unexpected HTTP errors bubble up uncaught so they
# surface in Sentry. Only expected captcha failures are caught below.
response = requests.post(TURNSTILE_VERIFY_URL, data=payload, timeout=10)
response.raise_for_status()
result = response.json()

if not result.get("success"):
error_codes = result.get("error-codes", [])
logger.warning("Cloudflare Turnstile verification failed: %s", error_codes)
raise CaptchaVerificationFailed("Captcha verification failed.")

return True
55 changes: 55 additions & 0 deletions backend/src/baserow/core/captcha/registries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from typing import Optional

from baserow.core.captcha.exceptions import CaptchaProviderDoesNotExist
from baserow.core.registry import Instance, Registry


class CaptchaProviderType(Instance):
"""
Base class for captcha provider types. Each captcha provider (e.g., Cloudflare
Turnstile, reCAPTCHA) should extend this class.
"""

def is_configured(self) -> bool:
"""
Returns True if all required configuration (env vars, etc.) is present
for this provider to function.
"""

raise NotImplementedError(
"The is_configured method must be implemented by the captcha provider."
)

def get_frontend_config(self) -> dict:
"""
Returns a dict of public configuration to send to the frontend. This must
not include secrets. For example, a site key but never a secret key.
"""

raise NotImplementedError(
"The get_frontend_config method must be implemented by the captcha provider."
)

def validate_token(self, token: str, remote_ip: Optional[str] = None) -> bool:
"""
Validates the captcha response token by calling the provider's verification
API. Should raise CaptchaVerificationFailed on failure.

:param token: The captcha response token from the client.
:param remote_ip: Optional IP address of the client.
:return: True if validation succeeds.
:raises CaptchaVerificationFailed: If the token is invalid or verification
fails.
"""

raise NotImplementedError(
"The validate_token method must be implemented by the captcha provider."
)


class CaptchaProviderRegistry(Registry[CaptchaProviderType]):
name = "captcha_provider"
does_not_exist_exception_class = CaptchaProviderDoesNotExist


captcha_provider_registry: CaptchaProviderRegistry = CaptchaProviderRegistry()
32 changes: 32 additions & 0 deletions backend/src/baserow/core/captcha/settings_data_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from django.http import HttpRequest

from baserow.api.settings.registries import SettingsDataType
from baserow.core.captcha.handler import CaptchaHandler


class CaptchaSettingsDataType(SettingsDataType):
type = "captcha"

def get_settings_data(self, request: HttpRequest) -> dict:
disabled = {"enabled": False}

if not CaptchaHandler.is_enabled():
return disabled

provider = CaptchaHandler.get_active_provider()
if provider is None:
return disabled

enabled_contexts = [
ctx for ctx in ["signup"] if CaptchaHandler.is_captcha_enabled_for(ctx)
]

if not enabled_contexts:
return disabled

return {
"enabled": True,
"provider": provider.type,
"enabled_contexts": enabled_contexts,
**provider.get_frontend_config(),
}
Loading
Loading