Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ac73f8d
Add per-IP rate limiting middleware with registration and content vel…
sdeibel Apr 9, 2026
96477e8
merged sdeibel:pr/01-rate-limiting PR 959
evgenyfadeev Apr 20, 2026
19a31bd
Add comprehensive rate limiting with cache-backed buckets and 429 res…
evgenyfadeev May 10, 2026
2977bdc
Fix test isolation holes in staged rate-limiting tests
evgenyfadeev May 10, 2026
dcd9c95
Convert whole-method settings guards to decorator form in rate-limit …
evgenyfadeev May 10, 2026
f6d5975
js notify.show() opens a SystemMessage identical in layout produced b…
evgenyfadeev May 17, 2026
98ac460
askbot/media/js/utils.js: changed signature -> notify.show(message, […
evgenyfadeev May 18, 2026
d56b915
Exempt UI-bookkeeping endpoints (markread, jsi18n) from per-request r…
evgenyfadeev May 18, 2026
1adb1dd
askbot/checks.py: added checks for the presence of the askbot ratelim…
evgenyfadeev May 18, 2026
e83a235
edited askbot/doc/source/changelog.rst - summarized the rate-limiting…
evgenyfadeev May 18, 2026
44f1058
askbot/media/sass/error.scss: error page takes 100vh and does not nee…
evgenyfadeev May 18, 2026
5851c50
jinja2/429.html: show countdown timer 60s -> 0 then reload the page
evgenyfadeev May 18, 2026
37b88f5
updated doc/source/deployment.rst
evgenyfadeev May 19, 2026
fe4cf52
Add high-reputation bypass for the watched-user post rate limit
evgenyfadeev May 21, 2026
861c387
Exempt livesettings admin routes from rate limiting
evgenyfadeev May 21, 2026
fe14cbb
Show registration rate-limit as a form error instead of a 429 page
evgenyfadeev May 22, 2026
d611014
askbot/urls.py: removed the avatar view from the ratelimit exemption
evgenyfadeev May 22, 2026
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
23 changes: 23 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -81,6 +84,14 @@ the said epic issue - i.e. `bd dep <X-id> --blocks <E-id>`

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:
Expand All @@ -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.
3 changes: 2 additions & 1 deletion askbot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions askbot/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
176 changes: 176 additions & 0 deletions askbot/checks.py
Original file line number Diff line number Diff line change
@@ -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',
)]
1 change: 1 addition & 0 deletions askbot/conf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading