Coverage for app/venv/lib/python3.14/site-packages/weblate/utils/ratelimit.py: 48%
125 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5from __future__ import annotations
7from contextlib import suppress
8from functools import wraps
9from typing import TYPE_CHECKING
11from django.conf import settings
12from django.contrib.auth import logout
13from django.core.cache import cache
14from django.middleware.csrf import rotate_token
15from django.shortcuts import redirect
16from django.template.loader import render_to_string
17from siphashc import siphash
19from weblate.logger import LOGGER
20from weblate.utils import messages
21from weblate.utils.docs import get_doc_url
22from weblate.utils.hash import calculate_checksum
23from weblate.utils.request import get_ip_address
25if TYPE_CHECKING: 25 ↛ 26line 25 didn't jump to line 26 because the condition on line 25 was never true
26 from django.http import HttpResponse
28 from weblate.auth.models import AuthenticatedHttpRequest, User
31def get_rate_setting(scope: str, suffix: str):
32 key = f"RATELIMIT_{scope.upper()}_{suffix}"
33 if hasattr(settings, key):
34 return getattr(settings, key)
35 return getattr(settings, f"RATELIMIT_{suffix}")
38def reset_rate_limit(
39 scope, request: AuthenticatedHttpRequest | None = None, address=None, user=None
40) -> None:
41 """Reset rate limit."""
42 limiter = RateLimitHttpRequest(scope, request, address, user)
43 limiter.reset()
46def revert_rate_limit(scope, request: AuthenticatedHttpRequest) -> None:
47 """
48 Revert rate limit to previous state.
50 This can be used when rate limiting POST, but ignoring some events.
51 """
52 limiter = RateLimitHttpRequest(scope, request)
53 limiter.revert()
56def check_rate_limit(scope: str, request: AuthenticatedHttpRequest) -> bool:
57 """Check authentication rate limit."""
58 if request.user.is_superuser:
59 return True
61 limiter = RateLimitHttpRequest(scope, request)
62 is_exceeded, _ = limiter.is_limit_exceeded()
64 if is_exceeded:
65 # Set key to longer expiry for lockout period
66 limiter.touch(get_rate_setting(scope, "LOCKOUT"))
67 LOGGER.info(
68 "rate-limit lockout for %s in %s scope from %s",
69 limiter.key,
70 scope,
71 get_ip_address(request),
72 )
73 return False
75 return True
78def session_ratelimit_post(scope: str, logout_user: bool = True):
79 """Session based rate limiting for POST requests."""
81 def _session_ratelimit_post_controller(function):
82 def _rate_wrap(
83 request: AuthenticatedHttpRequest, *args, **kwargs
84 ) -> HttpResponse:
85 if request.method == "POST" and not check_rate_limit(scope, request):
86 # Rotate session token
87 rotate_token(request)
88 if not logout_user:
89 messages.error(
90 request,
91 render_to_string(
92 "ratelimit.html", {"do_logout": False, "user": request.user}
93 ),
94 )
95 return redirect(request.get_full_path())
96 # Logout user
97 do_logout = request.user.is_authenticated
98 if do_logout:
99 logout(request)
100 messages.error(
101 request,
102 render_to_string(
103 "ratelimit.html", {"do_logout": do_logout, "user": request.user}
104 ),
105 )
106 return redirect("login")
107 return function(request, *args, **kwargs)
109 return wraps(function)(_rate_wrap)
111 return _session_ratelimit_post_controller
114def rate_limit_notify(address: str) -> tuple[bool, str]:
115 """
116 Multi-level rate limiting for email notifications.
118 Returns: tuple: (is_blocked, reason)
119 """
120 rate_limits = settings.RATELIMIT_NOTIFICATION_LIMITS
121 encoded_email = siphash("Weblate notifier", address)
122 limiter = RateLimitNotify(f"{encoded_email}", rate_limits)
123 return limiter.is_limit_exceeded()
126class RateLimitBase:
127 cache_items: list[CacheCounterItem]
128 key: str
130 def __init__(self, base_key: str, rate_limits: list[tuple[int, int]]):
131 self.key = base_key
133 self.cache_items = [
134 CacheCounterItem(self.key, attempts, window)
135 for attempts, window in rate_limits
136 ]
138 def is_limit_exceeded(self) -> tuple[bool, str]:
139 # Check all without decrementing
140 for cache_item in self.cache_items:
141 if cache_item.count_remaining <= 0:
142 return (
143 True,
144 f"rate limit exceeded ({cache_item.attempts}/{cache_item.window}s)",
145 )
146 # If we get here, we can allow the operation - so decrement all counters
147 for cache_item in self.cache_items:
148 cache_item.decrement()
149 return False, ""
151 def touch(self, timeout: int):
152 for cache_item in self.cache_items:
153 cache_item.touch(timeout)
155 def revert(self):
156 for cache_item in self.cache_items:
157 cache_item.increment()
159 def reset(self):
160 for cache_item in self.cache_items:
161 cache_item.delete()
164class RateLimitNotify(RateLimitBase):
165 def __init__(self, base_key: str, rate_limits: list[tuple[int, int]]):
166 RateLimitBase.__init__(self, f"notify:rate:{base_key}", rate_limits)
169class RateLimitHttpRequest(RateLimitBase):
170 def __init__(
171 self,
172 scope: str,
173 request: AuthenticatedHttpRequest | None = None,
174 address: str | None = None,
175 user: User | None = None,
176 ):
177 if request is not None and request.user.is_authenticated and user is None:
178 user = request.user
179 key: int | str
180 if user is not None:
181 key = user.id
182 origin = "user"
183 else:
184 if address is None:
185 address = get_ip_address(request)
186 if not address:
187 LOGGER.error(
188 "could not obtain remote IP address, see %s",
189 get_doc_url("admin/install", "reverse-proxy"),
190 )
191 origin = "ip"
192 key = calculate_checksum(address)
194 base_key = f"ratelimit-{origin}-{scope}-{key}"
195 window = get_rate_setting(scope, "WINDOW")
196 attempts = get_rate_setting(scope, "ATTEMPTS")
198 RateLimitBase.__init__(self, base_key, [(attempts, window)])
201class CacheCounterItem:
202 cache_key: str
203 attempts: int
204 window: int
206 def __init__(self, base_key: str, attempts: int, window: int):
207 self.cache_key = f"{base_key}:{attempts}:{window}"
208 self.attempts = attempts
209 self.window = window
210 cache.add(self.cache_key, attempts, window)
212 @property
213 def count_remaining(self) -> int:
214 return cache.get(self.cache_key, 0)
216 def increment(self) -> None:
217 with suppress(ValueError):
218 cache.incr(self.cache_key)
220 def decrement(self) -> None:
221 with suppress(ValueError):
222 cache.decr(self.cache_key)
224 def touch(self, timeout: int) -> None:
225 cache.touch(self.cache_key, timeout)
227 def delete(self) -> None:
228 cache.delete(self.cache_key)