diff --git a/CHANGELOG.md b/CHANGELOG.md index b637b12fc..ab50520e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Added - ⚡️(backend) add UPPER(email) index for case-insensitive user lookups +- 🔒️(backend) throttle the start recording endpoint ## [1.35.0] - 2026-10-13 diff --git a/docs/installation/kubernetes.md b/docs/installation/kubernetes.md index 008cac6db..64ff4092f 100644 --- a/docs/installation/kubernetes.md +++ b/docs/installation/kubernetes.md @@ -335,6 +335,8 @@ These are the environmental options available on meet backend. | SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) | | ROOM_CREATION_THROTTLE_RATES | Room creation throttle rate per authenticated user | 50/minute | 50/minute | | ROOM_CREATION_DAILY_THROTTLE_RATES | Daily room creation cap per authenticated user | 1000/day | +| START_RECORDING_THROTTLE_RATES | Recording start throttle rate per authenticated user | 10/minute | +| START_RECORDING_DAILY_THROTTLE_RATES | Daily recording start cap per authenticated user | 100/day | | REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute | | CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute | | SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false | diff --git a/src/backend/core/api/throttling.py b/src/backend/core/api/throttling.py index 34af49238..7868c7194 100644 --- a/src/backend/core/api/throttling.py +++ b/src/backend/core/api/throttling.py @@ -47,6 +47,18 @@ class RoomCreationDailyUserRateThrottle(RoomCreationUserRateThrottle): scope = "room_creation_daily" +class StartRecordingUserRateThrottle(MonitoredUserRateThrottle): + """Throttle recording starts per authenticated user.""" + + scope = "start_recording" + + +class StartRecordingDailyUserRateThrottle(StartRecordingUserRateThrottle): + """Cap recording starts per authenticated user over a day.""" + + scope = "start_recording_daily" + + class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle): """Throttle authenticated user requesting room entry""" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index b0ec01522..0d965a9d6 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -406,6 +406,10 @@ class RoomViewSet( permission_classes=[ permissions.HasPrivilegesOnRoom, ], + throttle_classes=[ + throttling.StartRecordingUserRateThrottle, + throttling.StartRecordingDailyUserRateThrottle, + ], ) @FeatureFlag.require("recording") def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument diff --git a/src/backend/core/tests/rooms/test_api_rooms_start_recording_throttling.py b/src/backend/core/tests/rooms/test_api_rooms_start_recording_throttling.py new file mode 100644 index 000000000..33466d375 --- /dev/null +++ b/src/backend/core/tests/rooms/test_api_rooms_start_recording_throttling.py @@ -0,0 +1,212 @@ +""" +Test rooms API endpoints in the Meet core app: start recording throttling. +""" + +# pylint: disable=redefined-outer-name,unused-argument + +from unittest import mock + +import pytest +from rest_framework.test import APIClient + +from ...api.throttling import ( + StartRecordingDailyUserRateThrottle, + StartRecordingUserRateThrottle, +) +from ...factories import RoomFactory, UserFactory +from ...models import Recording + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def start_recording_throttle(monkeypatch): + """Lower the start recording rates.""" + rates = StartRecordingUserRateThrottle.THROTTLE_RATES + monkeypatch.setitem(rates, "start_recording", "2/minute") + monkeypatch.setitem(rates, "start_recording_daily", "100/day") + + +@pytest.fixture +def daily_start_recording_throttle(monkeypatch): + """Lower the daily start recording rate and control the clock.""" + rates = StartRecordingDailyUserRateThrottle.THROTTLE_RATES + monkeypatch.setitem(rates, "start_recording", "100/minute") + monkeypatch.setitem(rates, "start_recording_daily", "3/day") + now = [1_000_000.0] + monkeypatch.setattr(StartRecordingUserRateThrottle, "timer", lambda self: now[0]) + return now + + +@mock.patch("core.api.viewsets.WorkerServiceMediator") +@mock.patch("core.api.viewsets.get_worker_service") +def test_start_recording_throttled( + mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings +): + """Starting a recording should return 429 when exceeding the per-minute rate.""" + settings.RECORDING_ENABLE = True + user = UserFactory() + client = APIClient() + client.force_login(user) + + for _ in range(2): + room = RoomFactory(users=[(user, "owner")]) + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", + {"mode": "screen_recording"}, + ) + assert response.status_code == 201 + + room = RoomFactory(users=[(user, "owner")]) + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", + {"mode": "screen_recording"}, + ) + + assert response.status_code == 429 + assert 0 < int(response["Retry-After"]) <= 60 + assert Recording.objects.count() == 2 + assert mock_mediator_class.return_value.start.call_count == 2 + + +@mock.patch("core.api.viewsets.WorkerServiceMediator") +@mock.patch("core.api.viewsets.get_worker_service") +def test_start_recording_throttle_counts_failed_attempts( + mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings +): + """Failed recording starts should count toward the rate.""" + settings.RECORDING_ENABLE = True + user = UserFactory() + room = RoomFactory(users=[(user, "owner")]) + Recording.objects.create(room=room, mode="screen_recording", status="active") + client = APIClient() + client.force_login(user) + + for _ in range(2): + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", + {"mode": "screen_recording"}, + ) + assert response.status_code == 409 + + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", + {"mode": "screen_recording"}, + ) + + assert response.status_code == 429 + + +@mock.patch("core.api.viewsets.WorkerServiceMediator") +@mock.patch("core.api.viewsets.get_worker_service") +def test_start_recording_throttle_per_user( + mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings +): + """Starting a recording should succeed when another user is throttled.""" + settings.RECORDING_ENABLE = True + client = APIClient() + + user = UserFactory() + client.force_login(user) + for _ in range(2): + room = RoomFactory(users=[(user, "owner")]) + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", + {"mode": "screen_recording"}, + ) + assert response.status_code == 201 + + room = RoomFactory(users=[(user, "owner")]) + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", + {"mode": "screen_recording"}, + ) + assert response.status_code == 429 + + other_user = UserFactory() + client.force_login(other_user) + room = RoomFactory(users=[(other_user, "owner")]) + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", + {"mode": "screen_recording"}, + ) + + assert response.status_code == 201 + + +@mock.patch("core.api.viewsets.WorkerServiceMediator") +@mock.patch("core.api.viewsets.get_worker_service") +def test_start_recording_throttle_does_not_limit_other_actions( + mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings +): + """Other room actions should succeed when starting a recording is throttled.""" + settings.RECORDING_ENABLE = True + user = UserFactory() + client = APIClient() + client.force_login(user) + + rooms = RoomFactory.create_batch(3, users=[(user, "owner")]) + for room in rooms[:2]: + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", + {"mode": "screen_recording"}, + ) + assert response.status_code == 201 + + response = client.post( + f"/api/v1.0/rooms/{rooms[2].id}/start-recording/", + {"mode": "screen_recording"}, + ) + assert response.status_code == 429 + + Recording.objects.filter(room=rooms[0]).update(status="active") + response = client.post(f"/api/v1.0/rooms/{rooms[0].id}/stop-recording/") + assert response.status_code == 200 + + response = client.get("/api/v1.0/rooms/") + assert response.status_code == 200 + + response = client.post("/api/v1.0/rooms/", {"name": "New room"}) + assert response.status_code == 201 + + +@mock.patch("core.api.viewsets.WorkerServiceMediator") +@mock.patch("core.api.viewsets.get_worker_service") +def test_start_recording_daily_throttled( + mock_get_worker_service, + mock_mediator_class, + daily_start_recording_throttle, + settings, +): + """Starting a recording should return 429 when exceeding the daily rate.""" + settings.RECORDING_ENABLE = True + now = daily_start_recording_throttle + user = UserFactory() + client = APIClient() + client.force_login(user) + + for _ in range(3): + room = RoomFactory(users=[(user, "owner")]) + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", + {"mode": "screen_recording"}, + ) + assert response.status_code == 201 + now[0] += 120 + + room = RoomFactory(users=[(user, "owner")]) + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", + {"mode": "screen_recording"}, + ) + assert response.status_code == 429 + assert 60 < int(response["Retry-After"]) <= 86400 + + now[0] += 86400 + room = RoomFactory(users=[(user, "owner")]) + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", + {"mode": "screen_recording"}, + ) + + assert response.status_code == 201 diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index b1cfd4043..1182e8b64 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -395,6 +395,16 @@ class Base(Configuration): environ_name="ROOM_CREATION_DAILY_THROTTLE_RATES", environ_prefix=None, ), + "start_recording": values.Value( + default="10/minute", + environ_name="START_RECORDING_THROTTLE_RATES", + environ_prefix=None, + ), + "start_recording_daily": values.Value( + default="100/day", + environ_name="START_RECORDING_DAILY_THROTTLE_RATES", + environ_prefix=None, + ), "request_entry": values.Value( default="150/minute", environ_name="REQUEST_ENTRY_THROTTLE_RATES",