Compare commits

...

2 Commits

Author SHA1 Message Date
Claude b1d7209836 🔒️(backend) throttle the start recording endpoint
Each recording start writes a Recording row and spins up a recording
worker, so a user or buggy client hammering the endpoint is costly even
when the calls end in a conflict or a worker failure.

Throttle it per authenticated user, mirroring room creation: a short-term
limit of 10/minute absorbs bursts and a daily cap of 100/day bounds
sustained use. Both are configurable via START_RECORDING_THROTTLE_RATES
and START_RECORDING_DAILY_THROTTLE_RATES.

Only starting a recording is throttled, as it is the costly action.
Stopping a recording only releases resources, so it is left unthrottled
and a throttled user can always stop an ongoing recording.
2026-10-14 00:02:43 +02:00
leo 4854fc0c13 🔒️(devex) bind ports in compose.yaml to localhost
Some containers were being exposed to local networks. Bind them
to localhost, to tighten security. Don't apply this binding to
LiveKit (exposed on 7880), as we access it using 127.0.0.1.nip.io:7880.
2026-10-13 18:21:04 +02:00
8 changed files with 255 additions and 14 deletions
+1
View File
@@ -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
+13 -13
View File
@@ -5,17 +5,17 @@ services:
env_file:
- env.d/development/postgresql
ports:
- "15432:5432"
- "127.0.0.1:15432:5432"
redis:
image: redis:5
ports:
- "6379:6379"
- "127.0.0.1:6379:6379"
mailcatcher:
image: sj26/mailcatcher:latest
ports:
- "1081:1080"
- "127.0.0.1:1081:1080"
garage:
user: ${DOCKER_USER:-1000}
@@ -71,7 +71,7 @@ services:
- env.d/development/common
- env.d/development/postgresql
ports:
- "8071:8000"
- "127.0.0.1:8071:8000"
volumes:
- ./src/backend:/app
- ./data/static:/data/static
@@ -137,7 +137,7 @@ services:
nginx:
image: nginx:1.25
ports:
- "8083:8083"
- "127.0.0.1:8083:8083"
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
@@ -159,7 +159,7 @@ services:
VITE_APP_TITLE: "LaSuite Meet"
image: meet:frontend-development
ports:
- "3000:8080"
- "127.0.0.1:3000:8080"
dockerize:
image: jwilder/dockerize
@@ -185,7 +185,7 @@ services:
kc_postgresql:
image: postgres:14.3
ports:
- "5433:5432"
- "127.0.0.1:5433:5432"
env_file:
- env.d/development/kc_postgresql
@@ -213,7 +213,7 @@ services:
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
ports:
- "8080:8080"
- "127.0.0.1:8080:8080"
depends_on:
- kc_postgresql
@@ -222,10 +222,10 @@ services:
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
ports:
- "7880:7880"
- "7881:7881"
- "7882:7882/udp"
- "3478:3478/udp"
- "30000-30100:30000-30100/udp"
- "127.0.0.1:7881:7881"
- "127.0.0.1:7882:7882/udp"
- "127.0.0.1:3478:3478/udp"
- "127.0.0.1:30000-30100:30000-30100/udp"
volumes:
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
depends_on:
@@ -283,7 +283,7 @@ services:
env_file:
- env.d/development/summary
ports:
- "8001:8000"
- "127.0.0.1:8001:8000"
volumes:
- ./src/summary:/app
depends_on:
+1 -1
View File
@@ -45,4 +45,4 @@ services:
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
keycloak:
ports: !override
- "8081:8080"
- "127.0.0.1:8081:8080"
+2
View File
@@ -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 |
+12
View File
@@ -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"""
+4
View File
@@ -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
@@ -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
+10
View File
@@ -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",