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
1 change: 1 addition & 0 deletions askbot/conf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def init():
import askbot.conf.access_control
import askbot.conf.site_modes
import askbot.conf.words
import askbot.conf.rate_limiting

#import main settings object
from askbot.conf.settings_wrapper import settings
Expand Down
155 changes: 155 additions & 0 deletions askbot/conf/rate_limiting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""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,
'RATE_LIMIT_ENABLED',
description=_('Enable rate limiting'),
default=True,
help_text=_('Master switch for per-IP request rate limiting.')
)
)

settings.register(
livesettings.IntegerValue(
RATE_LIMITING,
'RATE_LIMIT_REQUESTS_PER_WINDOW',
description=_('Max requests per IP per window'),
default=60,
help_text=_('Number of requests allowed per IP within the sliding window.')
)
)

settings.register(
livesettings.IntegerValue(
RATE_LIMITING,
'RATE_LIMIT_WINDOW_SECONDS',
description=_('Rate limit window (seconds)'),
default=60,
help_text=_('Duration of the sliding window in seconds.')
)
)

settings.register(
livesettings.IntegerValue(
RATE_LIMITING,
'RATE_LIMIT_CACHE_SIZE',
description=_('Max tracked IPs'),
default=200000,
help_text=_('Maximum number of IPs to track in memory. '
'Each tracked IP uses approximately 3KB of memory '
'(50,000 IPs ≈ 150MB, 200,000 ≈ 600MB). '
'Set based on available server RAM. IPs exceeding this '
'limit evict the oldest entries, so use the ban command '
'for persistent blocking.')
)
)

settings.register(
livesettings.BooleanValue(
RATE_LIMITING,
'RATE_LIMIT_BAN_ENABLED',
description=_('Enable ban command on rate limit'),
default=False,
help_text=_('When enabled, executes the ban command below '
'when an IP exceeds the rate limit. Note: the web '
'process typically lacks permissions to run '
'fail2ban-client directly. The recommended approach '
'for fail2ban is to enable request logging instead '
'and configure fail2ban to watch the log file for '
'"ratelimited=true" entries. Use this setting only '
'for commands the web process can run (e.g. writing '
'to a file or calling a local API).')
)
)

settings.register(
livesettings.StringValue(
RATE_LIMITING,
'RATE_LIMIT_BAN_COMMAND',
description=_('Ban command template'),
default='',
help_text=_('Command to execute when banning an IP. '
'Use {ip} as placeholder. Must be runnable by the '
'web process user without elevated privileges.')
)
)

# --- 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_PER_IP',
description=_('Max registrations per IP per window'),
default=3,
help_text=_('Number of registrations allowed per IP within '
'the sliding window.')
)
)

settings.register(
livesettings.IntegerValue(
RATE_LIMITING,
'REGISTRATION_RATE_LIMIT_WINDOW_SECONDS',
description=_('Registration rate limit window (seconds)'),
default=86400,
help_text=_('Duration of the sliding window in seconds. '
'Default: 86400 (1 day).')
)
)

# --- Content velocity limiting ---

settings.register(
livesettings.BooleanValue(
RATE_LIMITING,
'CONTENT_VELOCITY_ENABLED',
description=_('Enable content velocity limiting'),
default=False,
help_text=_('Per-user post limit for watched users to slow '
'sophisticated spammers.')
)
)

settings.register(
livesettings.IntegerValue(
RATE_LIMITING,
'CONTENT_VELOCITY_MAX_POSTS',
description=_('Max posts per window (watched users)'),
default=5,
help_text=_('Maximum posts a watched user can make within '
'the velocity window.')
)
)

settings.register(
livesettings.IntegerValue(
RATE_LIMITING,
'CONTENT_VELOCITY_WINDOW_MINUTES',
description=_('Content velocity window (minutes)'),
default=60,
help_text=_('Duration of the content velocity window in minutes.')
)
)
140 changes: 140 additions & 0 deletions askbot/middleware/ratelimit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Per-IP request rate limiting middleware using an in-memory sliding window."""
import collections
import threading
import time

from django.conf import settings as django_settings
from django.http import HttpResponse

from askbot.conf import settings as askbot_settings


# Module-level state: IP -> deque of timestamps
_request_log = {}
_registration_log = {}
_lock = threading.Lock()


def _cleanup_stale(now, window):
"""Remove IPs that haven't been seen within the window."""
stale = [ip for ip, times in _request_log.items()
if not times or (now - times[-1]) > window]
for ip in stale:
del _request_log[ip]


def _cleanup_stale_registrations(now, window):
"""Remove IPs that haven't registered within the window."""
stale = [ip for ip, times in _registration_log.items()
if not times or (now - times[-1]) > window]
for ip in stale:
del _registration_log[ip]


# Registration paths to match (suffix matching for i18n variants)
_REGISTRATION_SUFFIXES = ('/account/signup/', '/account/register/')


class RateLimitMiddleware:

def __init__(self, get_response):
self.get_response = get_response
self._cleanup_counter = 0

def __call__(self, request):
if not askbot_settings.RATE_LIMIT_ENABLED:
return self.get_response(request)

# Use X-Forwarded-For when behind a reverse proxy (e.g., nginx)
forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '')
ip = forwarded_for.split(',')[0].strip() if forwarded_for else request.META.get('REMOTE_ADDR', '')

# Whitelisted IPs bypass rate limiting
internal_ips = getattr(django_settings, 'ASKBOT_INTERNAL_IPS', None)
if internal_ips and ip in internal_ips:
return self.get_response(request)

now = time.monotonic()
window = askbot_settings.RATE_LIMIT_WINDOW_SECONDS
max_requests = askbot_settings.RATE_LIMIT_REQUESTS_PER_WINDOW
max_tracked = askbot_settings.RATE_LIMIT_CACHE_SIZE

with _lock:
# Periodic cleanup every 1000 requests
self._cleanup_counter += 1
if self._cleanup_counter >= 1000:
self._cleanup_counter = 0
_cleanup_stale(now, window)
reg_window = askbot_settings.REGISTRATION_RATE_LIMIT_WINDOW_SECONDS
_cleanup_stale_registrations(now, reg_window)
# Evict oldest entries if over capacity
while len(_request_log) > max_tracked:
oldest_ip = min(_request_log,
key=lambda k: _request_log[k][-1]
if _request_log[k] else 0)
del _request_log[oldest_ip]

timestamps = _request_log.get(ip)
if timestamps is None:
timestamps = collections.deque()
_request_log[ip] = timestamps

# Trim timestamps outside the window
cutoff = now - window
while timestamps and timestamps[0] < cutoff:
timestamps.popleft()

if len(timestamps) >= max_requests:
# Mark on the request so logging middleware can see it
request._ratelimited = True

# Optional ban command
if askbot_settings.RATE_LIMIT_BAN_ENABLED:
ban_cmd = askbot_settings.RATE_LIMIT_BAN_COMMAND
if ban_cmd and '{ip}' in ban_cmd:
import subprocess
try:
subprocess.Popen(
ban_cmd.format(ip=ip).split(),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
except OSError:
pass

return HttpResponse(
'Rate limit exceeded. Please slow down.',
status=429,
content_type='text/plain'
)

timestamps.append(now)

# Registration rate limiting (POST to signup/register paths)
if (askbot_settings.REGISTRATION_RATE_LIMIT_ENABLED
and request.method == 'POST'
and any(request.path.endswith(s)
for s in _REGISTRATION_SUFFIXES)):
reg_window = askbot_settings.REGISTRATION_RATE_LIMIT_WINDOW_SECONDS
reg_max = askbot_settings.REGISTRATION_RATE_LIMIT_PER_IP
reg_now = now

reg_timestamps = _registration_log.get(ip)
if reg_timestamps is None:
reg_timestamps = collections.deque()
_registration_log[ip] = reg_timestamps

reg_cutoff = reg_now - reg_window
while reg_timestamps and reg_timestamps[0] < reg_cutoff:
reg_timestamps.popleft()

if len(reg_timestamps) >= reg_max:
return HttpResponse(
'Too many registrations. Please try again later.',
status=429,
content_type='text/plain'
)

reg_timestamps.append(reg_now)

return self.get_response(request)
1 change: 1 addition & 0 deletions askbot/setup_templates/settings.py.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ MIDDLEWARE = (
'django.middleware.common.CommonMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',

'askbot.middleware.ratelimit.RateLimitMiddleware',
'askbot.middleware.analytics_session.AnalyticsSessionMiddleware',
'askbot.middleware.head_request.HeadRequestMiddleware',
'askbot.middleware.anon_user.ConnectToSessionMessagesMiddleware',
Expand Down
Loading