diff --git a/CLAUDE.md b/CLAUDE.md index 2d79403b46..67c88908a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,9 @@ pre-commit install ## Testing +If you want to override settings - either django or askbot livesettings - use decorators designed for this, +not context managers, if the setting value should not change during the test. + ```bash # Tox (recommended) tox @@ -81,6 +84,14 @@ the said epic issue - i.e. `bd dep --blocks ` Use `/land` skill when ending a work session. +## Changelog + +Changelog is in askbot/doc/source/changelog.rst + +- all new edits should be appended to thes section "Development (not yet released)" +- entries must be brief - ideally 1 liners, should not be too technical + + ## Architecture See `.claude/docs/architecture.md` for detailed architecture reference including: @@ -89,3 +100,15 @@ See `.claude/docs/architecture.md` for detailed architecture reference including - Template system (Jinja2) - Authentication (django_authopenid fork) - Multi-language support, search, email, Celery tasks + + +## Coding style + +Comments in the code must: + +- be concise +- limit use of the technical jargon +- must be written in clear simple English +- should not cross reference files or lines of code - that info rots quickly + +Python code must adhere to PEP8 standards. diff --git a/askbot/__init__.py b/askbot/__init__.py index 2d46b5dda5..7e7db30846 100644 --- a/askbot/__init__.py +++ b/askbot/__init__.py @@ -24,9 +24,10 @@ 'bs4': 'beautifulsoup4<=4.7.1', 'compressor': 'django-compressor>=3.0,<=4.4', 'celery': 'celery==5.3.0', - 'django': 'django>=3.0,<5.0', + 'django': 'django>=4.2,<5.0', 'django_countries': 'django-countries>=3.3', 'django_jinja': 'django-jinja>=2.0', + 'django_ratelimit': 'django-ratelimit>=4.0,<5.0', 'followit': 'django-followit==0.7.0', 'html5lib': 'html5lib==1.1', 'jinja2': 'Jinja2>=2.10,<3.1', diff --git a/askbot/apps.py b/askbot/apps.py index 198b9e86c5..5ce2c357e9 100644 --- a/askbot/apps.py +++ b/askbot/apps.py @@ -29,6 +29,8 @@ def ready(self): import askbot.conf askbot.conf.init() + from askbot import checks # noqa: F401 registers django system checks + if getattr(django_settings, 'ASKBOT_AUTO_INIT_BADGES', False): request_started.connect( init_badges_once, diff --git a/askbot/checks.py b/askbot/checks.py new file mode 100644 index 0000000000..e8c816baf7 --- /dev/null +++ b/askbot/checks.py @@ -0,0 +1,176 @@ +"""Django system checks for askbot rate-limit configuration. + +Registered in ``AskbotConfig.ready()``. Surface misconfigurations in +``manage.py check`` output so deploy pipelines fail before traffic +hits the live container — complements the runtime WARNING emitted in +``askbot.middleware.ratelimit.maybe_warn_misconfig``. +""" +import logging + +from django.conf import settings +from django.core.checks import Info, Warning, register + +from askbot.utils.ratelimit import PER_PROCESS_CACHE_BACKENDS + + +@register() +def check_ratelimit_enable_consistency(app_configs, **kwargs): + """W001: RATELIMIT_ENABLE falsy with any askbot policy enabled.""" + # if django-ratelimit is enabled - then any askbot rate limit settins + # are acceptable - return no errors + if getattr(settings, 'RATELIMIT_ENABLE', True): + return [] + + # rate-limit is disabled - therefore all askbot rate limits + # must be disabled as well + try: + # handle the case when livesettings are not available + from askbot.conf import settings as askbot_settings + contradicts = any([ + askbot_settings.REQUEST_RATE_LIMIT_ENABLED, + askbot_settings.REGISTRATION_RATE_LIMIT_ENABLED, + askbot_settings.WATCHED_USER_POST_RATE_LIMIT_ENABLED, + ]) + except Exception: + return [Info( + 'RATELIMIT_ENABLE is falsy but askbot livesettings could ' + 'not be read; consistency with admin toggles could not be ' + 'verified.', + hint='This is expected during collectstatic / migrate / ' + 'fresh bootstrap. If it persists during normal worker ' + 'operation, investigate the livesettings DB ' + 'connection.', + id='askbot.I001', + )] + if not contradicts: + return [] + # warn if any askbot rate limits is enabled when the ratelimit app is disabled + return [Warning( + 'RATELIMIT_ENABLE is falsy; this disables every askbot ' + 'rate-limit livesetting and admin toggles have no effect.', + hint='Either remove the falsy RATELIMIT_ENABLE from your ' + 'django settings, or disable the askbot rate-limit ' + 'livesettings in the admin UI to make the configuration ' + 'consistent.', + id='askbot.W001', + )] + + +@register() +def check_ratelimit_cache_backend(app_configs, **kwargs): + """W002/W004: RATELIMIT_USE_CACHE cache-entry shape and backend.""" + cache_name = getattr(settings, 'RATELIMIT_USE_CACHE', 'default') + caches = getattr(settings, 'CACHES', None) or {} + cache_entry = caches.get(cache_name) + shared_w004_hint = ( + f'Either add a CACHES[{cache_name!r}] entry pointing at Redis ' + f'or Memcached, or remove RATELIMIT_USE_CACHE so the limiter ' + f"falls back to CACHES['default']." + ) + if cache_entry is None: + return [Warning( + f'RATELIMIT_USE_CACHE resolves to {cache_name!r}, but ' + f'CACHES has no entry {cache_name!r}. django-ratelimit ' + f'will raise InvalidCacheBackendError at the first ' + f'rate-limited request.', + hint=shared_w004_hint, + id='askbot.W004', + )] + if not isinstance(cache_entry, dict): + return [Warning( + f'RATELIMIT_USE_CACHE resolves to {cache_name!r}, but the ' + f'matching CACHES entry is not a dict; django-ratelimit ' + f'expects a cache configuration dict.', + hint=shared_w004_hint, + id='askbot.W004', + )] + backend = cache_entry.get('BACKEND', '') + if not backend.endswith(PER_PROCESS_CACHE_BACKENDS): + return [] + return [Warning( + f'RATELIMIT_USE_CACHE={cache_name!r} points at {backend!r}; ' + f'rate-limit counters will be per-process and ineffective ' + f'under multiple workers.', + hint=(f'Configure CACHES[{cache_name!r}] to use Redis or ' + f'Memcached, or point RATELIMIT_USE_CACHE at a shared ' + f'cache entry.'), + id='askbot.W002', + )] + + +@register() +def check_ratelimit_logger_level(app_configs, **kwargs): + """W003: askbot.utils.ratelimit logger muted above WARNING. + Gated on rate limiting being actually enabled — a muted logger has + no operational impact when no rate-limit hit events are produced, + so the warning would be pure noise for deployments that leave rate + limiting off. + """ + # `RATELIMIT_ENABLE` short-circuit mirrors W001's first guard + # above; a falsy value bypasses django-ratelimit entirely, so the + # logger level no longer matters. + if not getattr(settings, 'RATELIMIT_ENABLE', True): + return [] + try: + # Deferred import + broad except mirror W001 — see + # check_ratelimit_enable_consistency for the rationale on why + # the except cannot be narrowed. Returning [] silently on read + # failure is INTENTIONAL: unlike W001 there is no + # user-supplied signal worth surfacing as Info from logger + # configuration alone. + from askbot.conf import settings as askbot_settings + any_enabled = any([ + askbot_settings.REQUEST_RATE_LIMIT_ENABLED, + askbot_settings.REGISTRATION_RATE_LIMIT_ENABLED, + askbot_settings.WATCHED_USER_POST_RATE_LIMIT_ENABLED, + ]) + except Exception: + return [] + if not any_enabled: + return [] + level = logging.getLogger('askbot.utils.ratelimit').getEffectiveLevel() + if level <= logging.WARNING: + return [] + return [Warning( + 'Rate-limit logger is muted above WARNING.', + hint='Set askbot.utils.ratelimit logger to WARNING or lower ' + 'in LOGGING; otherwise rate-limit hit events will be ' + 'dropped silently and log-tailer integrations ' + '(fail2ban / CrowdSec / Wazuh / Filebeat) will fail.', + id='askbot.W003', + )] + + +@register() +def check_ratelimit_middleware_installed(app_configs, **kwargs): + """W005: REQUEST_RATE_LIMIT_ENABLED on but RateLimitMiddleware not installed.""" + # `RATELIMIT_ENABLE` short-circuit mirrors W001's first guard; + # keeps W001 and W005 mutually exclusive on a single misconfig so + # the operator does not see two warnings for one underlying issue. + if not getattr(settings, 'RATELIMIT_ENABLE', True): + return [] + try: + # Deferred import + broad except mirror W001/W003 — tolerate + # collectstatic / migrate / fresh bootstrap where livesettings + # are not reachable. Silent on read failure matches W003. + from askbot.conf import settings as askbot_settings + if not askbot_settings.REQUEST_RATE_LIMIT_ENABLED: + return [] + except Exception: + return [] + middleware = getattr(settings, 'MIDDLEWARE', ()) + if 'askbot.middleware.ratelimit.RateLimitMiddleware' in middleware: + return [] + return [Warning( + "REQUEST_RATE_LIMIT_ENABLED is on but " + "'askbot.middleware.ratelimit.RateLimitMiddleware' is not " + "installed; the per-request rate limiter will not run and " + "the admin toggle will have no effect.", + hint="Add 'askbot.middleware.ratelimit.RateLimitMiddleware' " + "to MIDDLEWARE (ahead of " + "askbot.middleware.view_log.ViewLogMiddleware so that " + "rate-limited requests do not get logged as ordinary " + "traffic), or disable REQUEST_RATE_LIMIT_ENABLED in the " + "admin UI.", + id='askbot.W005', + )] diff --git a/askbot/conf/__init__.py b/askbot/conf/__init__.py index c174d0ec33..c3510bf65e 100644 --- a/askbot/conf/__init__.py +++ b/askbot/conf/__init__.py @@ -34,6 +34,7 @@ def init(): import askbot.conf.site_modes import askbot.conf.words import askbot.conf.api_settings + import askbot.conf.rate_limiting #import main settings object from askbot.conf.settings_wrapper import settings diff --git a/askbot/conf/rate_limiting.py b/askbot/conf/rate_limiting.py new file mode 100644 index 0000000000..de4f491373 --- /dev/null +++ b/askbot/conf/rate_limiting.py @@ -0,0 +1,164 @@ +"""Rate limiting livesettings configuration.""" +from askbot.conf.settings_wrapper import settings +from askbot.conf.super_groups import EXTERNAL_SERVICES +from livesettings import values as livesettings +from django.utils.translation import gettext_lazy as _ + +RATE_LIMITING = livesettings.ConfigurationGroup( + 'RATE_LIMITING', + _('Rate limiting'), + super_group=EXTERNAL_SERVICES +) + +settings.register( + livesettings.BooleanValue( + RATE_LIMITING, + 'REQUEST_RATE_LIMIT_ENABLED', + description=_('Enable rate limiting'), + default=False, + help_text=_('Master switch for per-IP request rate limiting.') + ) +) + +# RATE_LIMIT_IP_ALLOWLIST and RATE_LIMIT_SUBNET_GRANULARITY are +# cross-policy: the allowlist applies to every limiter, the granularity +# applies to every IP-keyed limiter. They intentionally drop the +# per-scope prefix the other settings use. +settings.register( + livesettings.StringArrayValue( + RATE_LIMITING, + 'RATE_LIMIT_IP_ALLOWLIST', + description=_('Rate-limit allowlist (IPs / CIDR ranges)'), + default=[], + help_text=_( + 'IP addresses and CIDR ranges that bypass every rate-limit ' + 'policy (request, registration, watched-user-post). One ' + 'entry per row. Plain IP (1.2.3.4, 2001:db8::1) or CIDR ' + '(1.2.3.0/24, 2001:db8::/32). IPv4 or IPv6. ' + 'Combines with the deploy-time ASKBOT_INTERNAL_IPS django.' + 'Edits apply on the next request and do NOT ' + 'invalidate any rate-limit buckets.' + ), + ) +) + +RATE_LIMIT_SUBNET_GRANULARITY_CHOICES = ( + ('host', _('Individual IP (/32 IPv4, /128 IPv6) — strict')), + ('subnet', _('Local subnet (/24 IPv4, /64 IPv6) — recommended')), + ('region', _('Regional block (/16 IPv4, /48 IPv6) — aggressive')), +) +settings.register( + livesettings.StringValue( + RATE_LIMITING, + 'RATE_LIMIT_SUBNET_GRANULARITY', + description=_('Subnet granularity for IP-keyed rate limits'), + default='subnet', + choices=RATE_LIMIT_SUBNET_GRANULARITY_CHOICES, + help_text=_( + 'Controls how broadly IP-keyed rate limits group ' + 'addresses. "host" buckets each IP separately (/32 IPv4, ' + '/128 IPv6) — strict. "subnet" buckets local-network ' + 'neighbors together (/24 IPv4, /64 IPv6) — recommended ' + 'default; "region" buckets entire regional blocks together ' + '(/16 IPv4, /48 IPv6) — aggressive. IPv4 and IPv6 widths ' + 'always change together.' + ), + ) +) + +settings.register( + livesettings.IntegerValue( + RATE_LIMITING, + 'REQUEST_RATE_LIMIT_MAX_REQUESTS', + description=_('Max requests per IP per window'), + default=60, + help_text=_('Number of requests allowed per IP within a 60-second window.') + ) +) + +# --- Registration rate limiting --- + +settings.register( + livesettings.BooleanValue( + RATE_LIMITING, + 'REGISTRATION_RATE_LIMIT_ENABLED', + description=_('Enable registration rate limiting'), + default=True, + help_text=_('Per-IP throttle on signup endpoints to slow ' + 'automated account creation.') + ) +) + +settings.register( + livesettings.IntegerValue( + RATE_LIMITING, + 'REGISTRATION_RATE_LIMIT_MAX_REGISTRATIONS', + description=_('Max registrations per IP per window'), + default=3, + help_text=_('Number of registrations allowed per IP within a 1-day window.') + ) +) + +# --- Watched user post rate limiting --- + +settings.register( + livesettings.BooleanValue( + RATE_LIMITING, + 'WATCHED_USER_POST_RATE_LIMIT_ENABLED', + description=_('Enable post rate limiting for watched users'), + default=False, + help_text=_('Per-user post limit for watched users to slow ' + 'sophisticated spammers.') + ) +) + +settings.register( + livesettings.IntegerValue( + RATE_LIMITING, + 'WATCHED_USER_POST_RATE_LIMIT_MAX_POSTS', + description=_('Max posts per window (watched users)'), + default=5, + help_text=_('Maximum posts a watched user can make within a 1-hour window.') + ) +) + +# Reputation-based bypass for the watched-user-post limit. Placed +# here (not in askbot/conf/minimum_reputation.py) so every knob that +# tunes rate-limit behavior lives in one admin form. +settings.register( + livesettings.BooleanValue( + RATE_LIMITING, + 'RATE_LIMIT_BYPASS_HIGH_REP_USERS', + description=_('Bypass post rate limit for high-reputation users'), + default=False, + help_text=_( + 'Master switch. When on, users whose reputation meets the ' + 'threshold below bypass the watched-user post rate limit. ' + 'The per-IP request limit and the registration limit are not affected.' + 'Note: askbot also auto-approves watched users once their ' + 'reputation crosses MIN_REP_TO_AUTOAPPROVE_USER — for this ' + 'bypass to ever take effect, set the threshold below LOWER ' + 'than MIN_REP_TO_AUTOAPPROVE_USER. See the deployment doc ' + "section 'rate-limit-high-rep-bypass' for the full " + 'rationale.' + ) + ) +) + +settings.register( + livesettings.IntegerValue( + RATE_LIMITING, + 'MIN_REP_TO_BYPASS_RATE_LIMIT', + description=_('Reputation threshold for rate-limit bypass'), + default=200, + help_text=_( + "Only consulted when 'Bypass post rate limit for " + "high-reputation users' is enabled. Applies ONLY to the " + 'watched-user post limit; the per-IP request limit and the ' + 'registration limit are not affected. Set strictly LOWER ' + 'than MIN_REP_TO_AUTOAPPROVE_USER — otherwise the bypass ' + 'never fires, because askbot promotes reputable users out ' + 'of the watched status before this threshold is reached.' + ) + ) +) diff --git a/askbot/const/__init__.py b/askbot/const/__init__.py index 5f056a8223..3ab198e0e4 100644 --- a/askbot/const/__init__.py +++ b/askbot/const/__init__.py @@ -32,6 +32,12 @@ LONG_TIME = 60*60*24*30 #30 days is a lot of time DATETIME_FORMAT = '%I:%M %p, %d %b %Y' +# --- Rate limiting policy windows --- +# Fixed by policy; operators tune the max counts via livesettings. +REQUEST_RATE_LIMIT_WINDOW_SECONDS = 60 # 1 minute +REGISTRATION_RATE_LIMIT_WINDOW_SECONDS = 86400 # 1 day +WATCHED_USER_POST_RATE_LIMIT_WINDOW_SECONDS = 3600 # 1 hour + SHARE_NOTHING = 0 SHARE_MY_POSTS = 1 SHARE_EVERYTHING = 2 diff --git a/askbot/deps/django_authopenid/tests.py b/askbot/deps/django_authopenid/tests.py index d0bf5b4852..3c64f3eabc 100644 --- a/askbot/deps/django_authopenid/tests.py +++ b/askbot/deps/django_authopenid/tests.py @@ -58,7 +58,8 @@ def _post_signup(self, username, email): BLANK_EMAIL_ALLOWED=False, TERMS_CONSENT_REQUIRED=False, USE_RECAPTCHA=False, - NEW_REGISTRATIONS_DISABLED=False) + NEW_REGISTRATIONS_DISABLED=False, + REGISTRATION_RATE_LIMIT_ENABLED=False) def test_validation_off_creates_user_directly(self): response = self._post_signup('ua', 'a@example.com') # Validation-off path redirects via get_next_url(request) @@ -75,7 +76,8 @@ def test_validation_off_creates_user_directly(self): BLANK_EMAIL_ALLOWED=False, TERMS_CONSENT_REQUIRED=False, USE_RECAPTCHA=False, - NEW_REGISTRATIONS_DISABLED=False) + NEW_REGISTRATIONS_DISABLED=False, + REGISTRATION_RATE_LIMIT_ENABLED=False) def test_validation_on_delays_user_creation(self): response = self._post_signup('ub', 'b@example.com') # Validation-on path redirects to verify_email_and_register diff --git a/askbot/deps/django_authopenid/views.py b/askbot/deps/django_authopenid/views.py index d01cb0d50c..71f24354c5 100644 --- a/askbot/deps/django_authopenid/views.py +++ b/askbot/deps/django_authopenid/views.py @@ -74,6 +74,7 @@ from askbot.deps.django_authopenid.providers import discourse from askbot.utils.http import get_request_info, get_request_params, is_ajax from askbot.utils.loading import load_module +from askbot.utils.ratelimit import is_askbot_ratelimited, check_askbot_ratelimit try: from xmlrpc.client import Fault as WpFault @@ -83,6 +84,17 @@ pass +def registration_rate_limit_message(): + """Return the message shown when the registration limit is hit. + + A helper, not a module-level constant: ``_`` in this module is the + eager ``gettext``, so a constant would freeze the translation at + import time. Calling it per request applies the active locale. + """ + return _('Too many registration attempts from your network. ' + 'Please try again later.') + + def email_is_acceptable(email): email = email.strip() @@ -1130,6 +1142,10 @@ def register(request, login_provider_name=None, and provider_data.one_click_registration: if username_is_acceptable(username) and email_is_acceptable(email): + rate_limit_response = check_askbot_ratelimit( + request, policy='registration') + if rate_limit_response: + return rate_limit_response #try auto-registration and redirect to the next_url user = create_authenticated_user_account( username=username, @@ -1181,9 +1197,9 @@ def register(request, login_provider_name=None, logging.debug('trying to create new account associated with openid') form_class = forms.get_federated_registration_form_class() register_form = form_class(request.POST, request=request) - if not register_form.is_valid(): - logging.debug('registration form is INVALID') - else: + if is_askbot_ratelimited(request, policy='registration'): + register_form.add_error(None, registration_rate_limit_message()) + elif register_form.is_valid(): username = register_form.cleaned_data['username'] email = register_form.cleaned_data['email'] @@ -1330,7 +1346,9 @@ def signup_with_password(request): if request.method == 'POST': form = RegisterForm(request.POST, request=request) - if form.is_valid(): + if is_askbot_ratelimited(request, policy='registration'): + form.add_error(None, registration_rate_limit_message()) + elif form.is_valid(): username = form.cleaned_data['username'] password = form.cleaned_data['password1'] email = form.cleaned_data['email'] diff --git a/askbot/doc/source/changelog.rst b/askbot/doc/source/changelog.rst index a5b828d8c0..c5674ebebc 100644 --- a/askbot/doc/source/changelog.rst +++ b/askbot/doc/source/changelog.rst @@ -3,6 +3,40 @@ Changes in Askbot Development (not yet released) ------------------------------ +* Added per subnet rate-limiting feature that allows + limiting three types of requests: get requests, + post requests from watched users + and user account registrations. + + Rate limiting relies on the django-ratelimit app, which counts the requests + per subnet and stores this count in the shared cache. + + When the user is rate-limited, they see a page advising them to slow down + or a similar banner message - when rate limited is detected + during an AJAX request. In the user registration flows this error + is shown as the form feedback. + + Rate limited responses are with the 429 status code, with a + retry-after header set (in seconds). + + Maximum number of requests per window can be adjusted. + The window durations are fixed: 1 minute for the get requests, + 1 hour for the posts and 1 day for the registrations. + + The size of the subnet can be set either per ip /32, local /24, + or regional /16, which can be adjusted in the livesettings. + + IPv4 and IPv6 networks are supported. + + Allow-listed IP ranges can be added in the livesettings. + + High-reputation users can be allowed to bypass for the watched-user + post rate limit, controlled by two new livesettings. + + The livesettings admin pages are exempt from request rate limiting. + +* Closed-forum-mode bypass via ``ASKBOT_INTERNAL_IPS`` now honors + CIDR ranges and IPv6 addresses (including IPv4-mapped IPv6). * Removed legacy ``askbot/container/`` directory and ``askbot_requirements.txt`` * Added test to verify ``askbot.REQUIREMENTS`` and ``pyproject.toml`` dependencies stay in sync * Bumped ``urllib3`` dependency from ``>=1.21.1,<1.27`` to ``>=2,<3`` @@ -25,6 +59,7 @@ Development (not yet released) * Added the option to require validation of email address when users change it. Controlled by the ``EMAIL_VALIDATION_REQUIRED`` livesetting. * Changed the default of the ``EMAIL_VALIDATION_REQUIRED`` livesetting from ``False`` to ``True``. +* Fixed: AJAX-injected system message banners now use the same markup as page-load banners. 0.12.8 (Mar 15, 2026) --------------------- diff --git a/askbot/doc/source/deployment.rst b/askbot/doc/source/deployment.rst index 6a98539f15..bfc234c9f4 100644 --- a/askbot/doc/source/deployment.rst +++ b/askbot/doc/source/deployment.rst @@ -23,6 +23,328 @@ a local directory, but it is also possible to use a dedicated static files storage or a CDN, for more information see django documentation about serving static files. +Cache backend for rate limiting +------------------------------- +Askbot's rate limiting stores counters in Django's cache framework (via +``django-ratelimit``). In production you **must** use a shared cache +backend such as Redis or Memcached — in-process or no-op cache backends +are unsafe: + +* ``LocMemCache`` (Django's default when ``CACHES`` is not configured) is + per-worker, so counters are not shared across gunicorn workers and + clients can exceed the configured limit by a factor of the worker count. +* ``DummyCache`` never stores anything at all, which silently disables + rate limiting entirely. + +Recommended backends (choose one that matches your installed Django +version): + +* Django 4.0+: ``django.core.cache.backends.redis.RedisCache`` +* Django 3.2+: ``django.core.cache.backends.memcached.PyMemcacheCache`` +* Django 3.2+ via third-party packages: + ``django_redis.cache.RedisCache`` (from ``django-redis``) or + ``django.core.cache.backends.memcached.PyLibMCCache``. + +Avoid the legacy ``django.core.cache.backends.memcached.MemcachedCache`` +backend: it was deprecated in Django 3.2 and removed in Django 4.1. + +``LocMemCache`` is acceptable only for single-process development servers. + +Askbot's ``askbot/setup_templates/settings.py.jinja2`` sets +``CACHES['default']['KEY_PREFIX'] = 'askbot'`` when ``askbot-setup`` +generates your ``settings.py``, so a Redis or Memcached cluster may +safely be shared with other applications without key collisions. Note: +the worked ``CACHES`` examples in that template currently reference +older backends and will be refreshed in a follow-up; configure your own +``CACHES`` dict using one of the recommended backends above. + +.. _rate-limit-reverse-proxies: + +Rate limiting under reverse proxies and multi-worker deployments +---------------------------------------------------------------- +Askbot's rate limiting is controlled by two layers stacked together: +the ``django-ratelimit`` library (configured in ``settings.py``) and +Askbot's livesettings (configured in the admin UI). When the two +disagree the disagreement is silent — the admin UI keeps showing rate +limiting as enabled while the library no-ops every request. The three +django-side knobs below are the ones operators must get right; +``manage.py check`` emits stable warning codes (``askbot.W001``, +``askbot.W002``, ``askbot.W003``, ``askbot.W004``, ``askbot.W005``) so +deploy pipelines can fail before traffic hits production **when +``manage.py check`` is invoked with** ``--fail-level=WARNING --deploy``. Without those flags, +Django's default fail-level is ``ERROR`` and these Warning-level checks +are informational. ``askbot.W003`` polices the rate-limit log channel +and is documented in :ref:`rate-limit-log-monitoring`. + +**Multiple workers — pick a shared cache.** ``RATELIMIT_USE_CACHE`` +(default ``'default'``) names the ``CACHES`` entry used for rate-limit +counters. Under multi-worker deployments this MUST point at a shared +cache (Redis or Memcached); a per-process backend such as +``LocMemCache`` or ``DummyCache`` makes the configured limit +``N × workers`` instead of ``N``. See *Cache backend for rate limiting* +above for backend recommendations. ``manage.py check`` emits +``askbot.W002`` when ``RATELIMIT_USE_CACHE`` resolves to a per-process +backend. ``askbot.W004`` fires when ``RATELIMIT_USE_CACHE`` resolves +to a ``CACHES`` key that is missing or not a dict — django-ratelimit +will raise ``InvalidCacheBackendError`` at the first rate-limited +request. + +**Reverse proxy — set RATELIMIT_IP_META_KEY.** Without it, +``django-ratelimit`` reads ``REMOTE_ADDR``, which is the proxy's IP +under nginx / haproxy / ELB. Every visitor then appears to come from +the proxy, so the per-IP request bucket and the registration bucket +degrade to a single global counter, and ``RATE_LIMIT_IP_ALLOWLIST`` +matching the proxy IP whitelists the entire internet. For single-hop +nginx:: + + RATELIMIT_IP_META_KEY = 'HTTP_X_FORWARDED_FOR' + +For multi-hop proxy chains that need to skip trusted intermediaries, +``RATELIMIT_IP_META_KEY`` accepts a callable returning the client IP. + +**Master kill switch — RATELIMIT_ENABLE.** Defaults to ``True``. ANY +falsy value (``False``, ``0``, ``''``, ``None``) disables every askbot +rate-limit policy regardless of admin UI state. ``manage.py check`` +emits ``askbot.W001`` when a falsy ``RATELIMIT_ENABLE`` contradicts an +enabled livesetting. ``askbot.I001`` (informational) is emitted when +the livesettings DB cannot be reached during the consistency check; +this is expected during fresh bootstrap and does not fail deploy +gates. + +**Middleware presence — install RateLimitMiddleware.** ``manage.py +check`` emits ``askbot.W005`` when ``REQUEST_RATE_LIMIT_ENABLED`` is +on in the admin UI but +``askbot.middleware.ratelimit.RateLimitMiddleware`` is missing from +Django's ``MIDDLEWARE`` setting — the admin toggle then has no effect +because no middleware consumes it. Add the middleware ahead of +``askbot.middleware.view_log.ViewLogMiddleware`` so rate-limited +requests are not logged as ordinary traffic, or disable the +livesetting. W005 short-circuits when ``RATELIMIT_ENABLE`` is falsy +so it does not duplicate ``askbot.W001`` for the same underlying +disable. + +.. _rate-limit-subnet-keying: + +Subnet keying for IP-based rate limits +-------------------------------------- +The ``RATE_LIMIT_SUBNET_GRANULARITY`` livesetting (admin UI → "Rate +limiting") controls how broadly IP-keyed limiters group neighbouring +addresses. Three options: + +* ``host`` — bucket each IP separately (/32 IPv4, /128 IPv6). Strict. +* ``subnet`` — bucket local-network neighbours together + (/24 IPv4, /64 IPv6). Recommended default; defends against per-IP + rotation within one subnet. +* ``region`` — bucket entire regional blocks together + (/16 IPv4, /48 IPv6). Aggressive. + +IPv4 and IPv6 widths always change together — a single setting drives +both families. The cache key for each bucket includes the prefix, so +changing the granularity starts fresh buckets for every client. This +is intentional and is distinct from edits to the IP allowlist (see +:ref:`rate-limit-allowlist`), which short-circuit before the bucket +key is computed and therefore do not invalidate any cached counters. + +.. _rate-limit-log-monitoring: + +Log monitoring and the log-tailer recipe +---------------------------------------- +Each rate-limit hit emits a structured WARNING line on the +``askbot.utils.ratelimit`` logger, anchored on the literal string +``askbot.ratelimit hit `` (trailing space). The emit site is +``askbot/utils/ratelimit.py`` (single emission point inside +``_is_over_limit``). Two rendered examples: + +.. code-block:: text + + askbot.ratelimit hit policy=request ip=1.2.3.4 group=askbot.ratelimit.request + askbot.ratelimit hit policy=request ip=- group=askbot.ratelimit.request + +The ``group=`` field is per policy: ``askbot.ratelimit.request``, +``askbot.ratelimit.registration``, and +``askbot.ratelimit.watched_user_post``. Log-tailers anchored on the +older ``askbot.middleware.ratelimit`` / ``askbot.registration`` +literals must update to the new strings; recipes anchored on +``policy=`` (including the bundled fail2ban filter below) are +unaffected. + +When the limiter cannot resolve a client IP (proxy misconfigured, the +header was anonymized) the IP field renders as ``ip=-``. Log-tailer +recipes must either skip those rows or anchor on a bannable policy so +the actioner is never fed the ``-`` placeholder. + +**Exempted endpoints emit nothing.** Views marked exempt +short-circuit the per-request middleware before any bucket lookup, so +they neither consume a slot nor emit a ``askbot.ratelimit hit`` line. +Askbot ships this mark on two UI-bookkeeping endpoints whose 429s +would break the user session: ``POST /messages/markread/`` (dismissing +the rate-limit banner) and ``GET /jsi18n/`` (the JavaScript-i18n +catalog). The livesettings admin pages (``/settings/...``) are also +exempt, so an admin tuning configuration is never locked out. These +two endpoints carry the per-view +``askbot.utils.ratelimit.ratelimit_exempt`` decorator; the +livesettings pages are exempted at the URLconf level via +``ratelimit_exempt_resolver``, because they are added by a bulk +``include()`` of a third-party URLconf whose views cannot be decorated +at their definition site. Custom views that should bypass the +per-request bucket can apply the same decorator; only the per-request +policy honours it — the registration policy fires from its own +view-level decorator and is unaffected. + +**Bannable-policy choice.** The middleware emits ``policy=request``, +the registration decorator emits ``policy=registration``, and the +watched-user-post check declares ``policy=watched_user_post`` (in +``_POLICIES`` inside ``askbot/utils/ratelimit.py``). Operators who +also want to ban registration spam can broaden their regex to +``policy=(request|registration)``. The ``watched_user_post`` policy +is currently dormant — enforcement lives in +``askbot.views.writers.check_watched_user_post_rate_limit`` and +raises ``PermissionDenied`` rather than emitting an +``askbot.ratelimit hit policy=watched_user_post`` line, so do not +expect that policy in fail2ban regex matches today. The +watched-user-post limiter is content-based and would not be a typical +fail2ban target even once it does emit. + +**Two distinct askbot rate-limit loggers.** They are sibling modules, +NOT parent/child. Configure both: + +* ``askbot.utils.ratelimit`` — per-hit + ``askbot.ratelimit hit …`` WARNINGs from ``_is_over_limit`` (the + log-tailer integration target). ``askbot.W003`` polices this + logger's effective level. +* ``askbot.middleware.ratelimit`` — one-shot worker-boot WARNINGs + from ``maybe_warn_misconfig`` (RATELIMIT_ENABLE-vs-livesetting + contradiction; missing or non-dict ``CACHES`` entry; per-process + ``RATELIMIT_USE_CACHE``). NOT covered by ``askbot.W003``. These are + the runtime twin of the W001/W002/W004 system checks; the + deterministic emission ordering at boot is ``W001 → W002/W004`` + (W002 and W004 are mutually exclusive — see + ``askbot/middleware/ratelimit.py``). + +**Recommended LOGGING.** Askbot ships no default ``LOGGING`` block — +operators add it to their own ``settings.py``. Configure the parent +``askbot`` logger at ``WARNING`` and let Django's default +``propagate=True`` chain catch both children in one entry. Do NOT set +``propagate=False`` on the parent or on either child, or the +parent-only setup silently breaks:: + + LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'ratelimit_file': { + 'class': 'logging.FileHandler', + 'filename': '/var/log/askbot/ratelimit.log', + }, + }, + 'loggers': { + 'askbot': { + 'handlers': ['ratelimit_file'], + 'level': 'WARNING', + # propagate defaults to True; do NOT set it to False + # here or on the askbot.utils.ratelimit / + # askbot.middleware.ratelimit children. + }, + }, + } + +Pin the level to ``'WARNING'`` — setting it below produces noise, and +setting it above trips ``askbot.W003`` for the +``askbot.utils.ratelimit`` child. Operators who prefer per-module +control can configure ``askbot.utils.ratelimit`` and +``askbot.middleware.ratelimit`` as two separate ``loggers`` entries +instead of configuring the parent. + +**Two operator volume knobs:** + +1. ``REQUEST_RATE_LIMIT_ENABLED`` livesetting (admin UI) — disables + the limiter entirely; no events to log. +2. ``LOGGING`` level on the askbot rate-limit loggers — silences + emission while keeping the limiter active. ``manage.py check`` + raises ``askbot.W003`` if the ``askbot.utils.ratelimit`` logger is + set above ``WARNING``, so operators do not accidentally mute their + own attack record. Note that W003 does NOT cover + ``askbot.middleware.ratelimit`` — muting the misconfig logger is + silently destructive. + +**W003 fail-level.** Like W001/W002/W004/W005, ``askbot.W003`` is a +Warning-level system check. It does NOT fail ``manage.py check`` +unless invoked as ``manage.py check --fail-level=WARNING --deploy`` +(Django's default ``--fail-level`` is ``ERROR``). The same caveat is +documented for W001/W002/W004/W005 in +:ref:`rate-limit-reverse-proxies`. + +**Headline fail2ban recipe.** Drop a ``jail.d`` snippet matching the +anchor and capturing the ``ip=`` field. Two regex variants — pick the +one matching your threat model: + +.. code-block:: ini + + # /etc/fail2ban/filter.d/askbot-ratelimit.conf + [Definition] + # Request-only ban (recommended starting point). + failregex = askbot\.ratelimit hit policy=request ip=(?P\S+?)\s + # Or, to also ban registration spam, replace the line above with: + # failregex = askbot\.ratelimit hit policy=(request|registration) ip=(?P\S+?)\s + +.. code-block:: ini + + # /etc/fail2ban/jail.d/askbot.conf + [askbot-ratelimit] + enabled = true + filter = askbot-ratelimit + logpath = /var/log/askbot/ratelimit.log + # findtime / bantime / maxretry are starting points — tune for + # your threat model. + findtime = 600 + bantime = 3600 + maxretry = 5 + action = iptables-multiport[name=askbot, port="http,https"] + +The ``\S+?`` capture handles both IPv4 and IPv6 addresses; a +``[\d.]+`` capture would silently drop every IPv6 hit. Anchoring on +``policy=request`` keeps allowlisted passes (which never emit) and +``ip=-`` placeholder rows out of the actioner's input. + +**Other consumers.** Any regex log-tailer works the same way — +CrowdSec, Wazuh, Filebeat → SIEM. There is no askbot-specific +plumbing required beyond the regex anchor above. + +**Removal of the bundled ban command.** Earlier branches shipped +``RATE_LIMIT_BAN_ENABLED`` and ``RATE_LIMIT_BAN_COMMAND`` livesettings +that fanned out to a configurable shell command per ban event. Both +are removed; this log-tailer recipe is the supported integration +path. + +.. _rate-limit-high-rep-bypass: + +High-reputation bypass for the watched-user post limit +------------------------------------------------------ + +Two livesettings let trusted contributors bypass the watched-user +post rate limit while still being treated as watched for every +other moderation purpose: + +* ``RATE_LIMIT_BYPASS_HIGH_REP_USERS`` — master switch. Off by + default. When on, users at or above the reputation threshold + below skip the watched-user post limit. +* ``MIN_REP_TO_BYPASS_RATE_LIMIT`` — integer reputation threshold, + default 200. Only consulted when the master switch is on. + +**Scope.** The bypass applies ONLY to the watched-user post limit. +The per-IP ``request`` policy and the ``registration`` policy +remain uniform for everyone — admins, moderators, high-reputation +users, and anonymous traffic are all subject to the same caps. + +**Interaction with auto-approval.** Askbot promotes a watched user +out of the watched status when their reputation crosses +``MIN_REP_TO_AUTOAPPROVE_USER``. Once approved, the watched-user +limit no longer applies to them and this bypass is never consulted. +For the bypass to ever take effect, set ``MIN_REP_TO_BYPASS_RATE_LIMIT`` +strictly LOWER than ``MIN_REP_TO_AUTOAPPROVE_USER``. The bypass is +useful when auto-approval is set very high (or effectively disabled), +or when an admin has manually re-watched a high-reputation user. + Setting up file access permissions ---------------------------------- diff --git a/askbot/doc/source/intranet-setup.rst b/askbot/doc/source/intranet-setup.rst index 2711b37624..adb71d8886 100644 --- a/askbot/doc/source/intranet-setup.rst +++ b/askbot/doc/source/intranet-setup.rst @@ -13,9 +13,27 @@ Please change the following settings in your ``settings.py`` file:: In addition, in the "live settings": * disable gravatar in "settings->User settings" -If you would like to password/protect your site +If you would like to password/protect your site (achievable via "access control settings" -> "allow only registered users..."), -and at the same time be able to have some dedicated service +and at the same time be able to have some dedicated service to read your site without authentication, add -IP addresses of that service to a tuple ``ASKBOT_INTERNAL_IPS`` -in your ``settings.py`` file. +IPs or CIDR ranges of that service to a tuple ``ASKBOT_INTERNAL_IPS`` +in your ``settings.py`` file (see :ref:`rate-limit-allowlist` for +the full value semantics). + +.. _rate-limit-allowlist: + +Internal IP allowlist +--------------------- + +``ASKBOT_INTERNAL_IPS`` has a second role: it is the deploy-time +half of the unified rate-limit allowlist. IPs and CIDR ranges listed +here bypass every rate-limit policy (the request, registration, and +watched-user-post limiters). The runtime half of the allowlist is +the ``RATE_LIMIT_IP_ALLOWLIST`` livesetting, editable from the admin +UI under "Rate limiting"; the two sources combine by union (an IP +bypasses if matched by either). Plain IPs are auto-promoted to ``/32`` +or ``/128``; CIDR notation is supported on both tiers. The +rate-limiter resolves the client IP through ``RATELIMIT_IP_META_KEY`` +(typically ``HTTP_X_FORWARDED_FOR`` behind a reverse proxy), so +allowlist matches respect proxy headers. diff --git a/askbot/jinja2/429.html b/askbot/jinja2/429.html new file mode 100644 index 0000000000..1306374f1d --- /dev/null +++ b/askbot/jinja2/429.html @@ -0,0 +1,41 @@ +{% extends "one_column_body.html" %} + +{% block title %}{% filter trim %}{% trans %}Too many requests{% endtrans %}{% endfilter %}{% endblock %} +{% block layout_class %}error-page error-429-page{% endblock %} +{% block content %} +

{% trans %}Too many requests{% endtrans %}

+

+ {% trans %}You have made too many requests in a short period of time.{% endtrans %} + {% trans wait=retry_after_human %}Please wait {{ wait }} before trying again.{% endtrans %} +

+{% if retry_after <= 60 %} +
{{ retry_after }}
+{% endif %} +{% endblock %} +{% if retry_after <= 60 %} +{% block endjs %} + +{% endblock %} +{% endif %} diff --git a/askbot/jinja2/authopenid/signup_with_password.html b/askbot/jinja2/authopenid/signup_with_password.html index 05a5da76bc..d092e7b2be 100644 --- a/askbot/jinja2/authopenid/signup_with_password.html +++ b/askbot/jinja2/authopenid/signup_with_password.html @@ -22,6 +22,13 @@

{% trans %}New user registrations are disabled{% endtr {% else %}

{% trans %}Create login name and password{% endtrans %}

+{% if form.non_field_errors() %} +
    + {% for error in form.non_field_errors() %} +
  • {{ error }}
  • + {% endfor %} +
+{% endif %}