Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine e5c9dc1fa8 🦺(backend) optionally validate room names using a regex
In production, room names follow a specific pattern that was only
enforced on the frontend. This caused minor bounties during the bug
bounty.

Move validation to the API to ensure the field is always validated
server-side.

Following OWASP recommendations for DRF, treat invalid room names
as suspicious input, since such requests cannot originate from the
official frontend client.
2026-03-11 16:14:07 +01:00
13 changed files with 78 additions and 229 deletions
+1 -3
View File
@@ -12,7 +12,6 @@ and this project adheres to
- ✨(helm) support celery with our Django backend #1124
- ✨(helm) support ingress for custom background image #1124
- ✨(backend) add authenticated user rate throttling on request-entry #1129
### Changed
@@ -22,12 +21,11 @@ and this project adheres to
- ♿(frontend) improve chat toast a11y for screen readers #1109
- ♿(frontend) improve ui and aria labels for help article links #1108
- 🌐(frontend) improve German translation #1125
- 🦺(backend) optionally validate room names using a regex #1132
### Fixed
- 🐛(frontend) fix hand icon and queue position alignment and position #1119
- 🩹(backend) add page_size to pagination for room endpoints #1131
- 🐛(backend) refactor lobby throttling to use participant id #1129
## [1.10.0] - 2026-03-05
+12 -20
View File
@@ -2,6 +2,7 @@
# pylint: disable=abstract-method,no-name-in-module
import logging
import re
from os.path import splitext
from typing import Literal
from urllib.parse import quote
@@ -13,7 +14,7 @@ from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field, ValidationError
from pydantic import BaseModel, Field
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
@@ -123,16 +124,6 @@ class ListRoomSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "slug"]
class RoomConfiguration(BaseModel):
"""Wip"""
can_publish_sources: list[Literal[
"microphone", "screen_share", "screen_share_audio", "camera"
]] | None = None
model_config = {"extra": "forbid"}
class RoomSerializer(serializers.ModelSerializer):
"""Serialize Room model for the API."""
@@ -141,19 +132,20 @@ class RoomSerializer(serializers.ModelSerializer):
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
read_only_fields = ["id", "slug", "pin_code"]
def validate_configuration(self, configuration):
"""Wip."""
def validate_name(self, value):
"""Validate the name matches the optionally configured ROOM_NAME_REGEX"""
if configuration is None:
return configuration
if not value:
raise serializers.ValidationError("Name cannot be empty.")
try:
RoomConfiguration.model_validate(configuration)
except ValidationError as e:
raise SuspiciousOperation("Wip, invalid room configuration")
room_name_regex = settings.ROOM_NAME_REGEX
if room_name_regex is None:
return value
return configuration
if not re.fullmatch(room_name_regex, value):
raise SuspiciousOperation("Name does not match the expected format.")
return value
def to_representation(self, instance):
"""
+1 -50
View File
@@ -1,9 +1,7 @@
"""Throttling modules for the API."""
from django.conf import settings
from lasuite.drf.throttling import MonitoredThrottleMixin
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
from rest_framework.throttling import AnonRateThrottle
from sentry_sdk import capture_message
@@ -16,58 +14,11 @@ class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class MonitoredUserRateThrottle(MonitoredThrottleMixin, UserRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated user requesting room entry"""
scope = "request_entry"
def get_cache_key(self, request, view):
"""Use the authenticated user ID as the throttle cache key."""
if request.user and not request.user.is_authenticated:
return None # Defer to RequestEntryAnonRateThrottle for anonymous users.
return super().get_cache_key(request, view)
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
def get_cache_key(self, request, view):
"""Use the lobby participant cookie ID as the throttle cache key.
Only throttle if a cookie is already set. If no cookie exists yet,
return None to skip throttling — the cookie will be set on the first
response, and throttling will apply from the second request onward.
Keying on the cookie rather than the IP address prevents penalising
multiple users behind the same NAT/proxy, and is consistent with how
LobbyService identifies participants.
Note: as per DRF documentation, application-level throttling is not a
security measure against brute-force or DoS attacks. This throttle exists
solely to guard against accidental hammering from buggy clients.
"""
if request.user and request.user.is_authenticated:
return None # Only throttle unauthenticated requests.
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
if participant_id is None:
return None # No throttling for cookieless requests
return self.cache_format % {
"scope": self.scope,
"ident": participant_id,
}
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
+1 -5
View File
@@ -224,7 +224,6 @@ class RoomViewSet(
API endpoints to access and perform actions on rooms.
"""
pagination_class = Pagination
permission_classes = [permissions.RoomPermissions]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
@@ -393,10 +392,7 @@ class RoomViewSet(
methods=["post"],
url_path="request-entry",
permission_classes=[],
throttle_classes=[
throttling.RequestEntryAuthenticatedUserRateThrottle,
throttling.RequestEntryAnonRateThrottle,
],
throttle_classes=[throttling.RequestEntryAnonRateThrottle],
)
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
"""Request entry to a room"""
@@ -3,7 +3,10 @@ Test rooms API endpoints in the Meet core app: create.
"""
# pylint: disable=redefined-outer-name,unused-argument
from unittest import mock
from django.core.cache import cache
from django.core.exceptions import SuspiciousOperation
import pytest
from rest_framework.test import APIClient
@@ -109,3 +112,47 @@ def test_api_rooms_create_authenticated_existing_slug():
assert response.status_code == 400
assert response.json() == {"slug": ["Room with this Slug already exists."]}
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
def test_api_rooms_create_invalid_regex(mock_suspicious, settings):
"""A room can not be created when its name doesn't match the configured pattern."""
settings.ROOM_NAME_REGEX = r"[a-z]{3}-[a-z]{4}-[a-z]{3}"
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
},
)
assert response.status_code == 400
assert Room.objects.exists() is False
mock_suspicious.assert_called_once_with("Name does not match the expected format.")
def test_api_rooms_create_valid_regex(settings):
"""A room can be created when its name matches the configured pattern."""
settings.ROOM_NAME_REGEX = r"[a-z]{3}-[a-z]{4}-[a-z]{3}"
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "foo-barn-baz",
},
)
assert response.status_code == 201
assert Room.objects.count() == 1
assert Room.objects.filter(name="foo-barn-baz").exists()
@@ -120,39 +120,3 @@ def test_api_rooms_list_authenticated_distinct():
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(room.id)
def test_api_rooms_list_pagination_page_size():
"""Users should be able to customize the number of results per page via page_size param."""
user = UserFactory()
client = APIClient()
client.force_login(user)
RoomFactory.create_batch(11, users=[user])
response = client.get("/api/v1.0/rooms/?page_size=2")
assert response.status_code == 200
content = response.json()
assert content["count"] == 11
assert len(content["results"]) == 2
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=2"
assert content["previous"] is None
response = client.get("/api/v1.0/rooms/?page=6&page_size=2")
assert response.status_code == 200
content = response.json()
assert content["count"] == 11
assert len(content["results"]) == 1
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/rooms/?page=5&page_size=2"
response = client.get("/api/v1.0/rooms/?page_size=3")
assert response.status_code == 200
content = response.json()
assert content["count"] == 11
assert len(content["results"]) == 3
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
assert content["previous"] is None
@@ -631,109 +631,3 @@ def test_list_waiting_participants_empty(settings):
assert response.status_code == 200
assert response.json() == {"participants": []}
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_anonymous_without_cookie(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Anonymous users without a cookie should not be throttled."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
assert response.cookies.get("mocked-cookie") is not None
client.cookies.clear() # Simulate a new cookieless request
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_anonymous_with_cookie(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Anonymous users with a cookie should be throttled after exceeding the rate limit."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
participant_id = str(uuid.uuid4())
client.cookies.load({"mocked-cookie": participant_id})
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 429
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_authenticated_user(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Authenticated users should be throttled."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
client = APIClient()
client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 429
@@ -232,7 +232,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"""
room = RoomFactory(
access_level=RoomAccessLevel.PUBLIC,
configuration={"can_publish_sources": ["camera"]},
configuration={"can_publish_sources": ["mock-source"]},
)
user = UserFactory()
@@ -264,7 +264,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
user=user,
username=None,
color=None,
sources=["camera"],
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
)
@@ -363,7 +363,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
other_user = UserFactory()
room = RoomFactory(
configuration={"can_publish_sources": ["camera"]},
configuration={"can_publish_sources": ["mock-source"]},
)
UserResourceAccessFactory(resource=room, user=user, role="member")
UserResourceAccessFactory(resource=room, user=other_user, role="member")
@@ -401,7 +401,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
user=user,
username=None,
color=None,
sources=["camera"],
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
)
@@ -95,7 +95,7 @@ def test_api_rooms_update_administrators():
"name": "New name",
"slug": "should-be-ignored",
"access_level": RoomAccessLevel.PUBLIC,
"configuration": {"can_publish_sources": ["camera"]},
"configuration": {"the_key": "the_value"},
},
format="json",
)
@@ -104,7 +104,7 @@ def test_api_rooms_update_administrators():
assert room.name == "New name"
assert room.slug == "new-name"
assert room.access_level == RoomAccessLevel.PUBLIC
assert room.configuration == {"can_publish_sources": ["camera"]}
assert room.configuration == {"the_key": "the_value"}
def test_api_rooms_update_administrators_of_another():
+7
View File
@@ -752,6 +752,13 @@ class Base(Configuration):
environ_prefix=None,
)
# Room validation
ROOM_NAME_REGEX = values.RegexValue(
None,
environ_name="ROOM_NAME_REGEX",
environ_prefix=None,
)
# Calendar integrations
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
600, # 10 minutes
+1 -1
View File
@@ -47,7 +47,7 @@
},
"loginHint": {
"title": "Log in with your account",
"body": "Instead of waiting, log in with your account.",
"body": "Instead of waiting, log in with your ProConnect account.",
"button": {
"ariaLabel": "Close the suggestion",
"label": "OK"
+1 -1
View File
@@ -47,7 +47,7 @@
},
"loginHint": {
"title": "Connectez-vous avec votre compte",
"body": "Au lieu de patienter, connectez-vous avec votre compte.",
"body": "Au lieu de patienter, connectez-vous avec votre compte ProConnect.",
"button": {
"ariaLabel": "Fermer la suggestion",
"label": "OK"
+1 -1
View File
@@ -46,7 +46,7 @@
},
"loginHint": {
"title": "Log in met je account",
"body": "In plaats van te wachten, log in met je account.",
"body": "In plaats van te wachten, log in met je ProConnect-account.",
"button": {
"ariaLabel": "Sluit de suggestie",
"label": "OK"