Bind an accepted lobby entry to the username the participant had at
the moment of acceptance.
This prevents a participant, once accepted, from changing their
display name and reusing the same lobby grant to enter the room
under a different identity.
The lobby system relied on cookies to identify the participant
across the wait/enter cycle, which does not work in an iframe
context where our cookies are dropped.
Simplify the lobby behavior:
* The POST request that enters the lobby now returns the
participant id in the response.
* The frontend passes that id back on subsequent requests to keep a
sticky session while trying to enter the room.
This moves a bit more logic to the frontend but should be a
transparent refactoring, without decreasing the security of the
lobby flow.
We now use `Authorization: Bearer <token>` to authenticate users
from the token exchange flow (used for iframe embeds).
Until now, the `Bearer` scheme was also reused for the alternative
LiveKit authentication, where a client presents its LiveKit token
issued by the backend to prove room membership on actions open to
any room participant. Sharing the scheme between the two flows is
not viable anymore.
Switch the LiveKit token authentication to a dedicated
`Authorization` scheme, so `Bearer` stays reserved for the iframe /
token-exchange flow.
Follow-up: a broader effort should look into harmonizing and
hardening the backend authentication stack of the app.
Some integrators render our videoconference inside an iframe, where
our cookie-based authentication does not work: our cookies are
SameSite=Lax/Strict, so the iframe drops them.
We looked at what Jitsi offers: a shared secret used to sign JWTs
that authenticate users coming from external services. Since we
already expose an external API where third parties authenticate as
a given user, it was simpler for us to add an exchange mechanism on
top of that.
Flow:
* Through the external API, mint a short-lived, single-use exchange
code for a user.
* The third party hands that code to the frontend as a URL fragment.
* The frontend exchanges the code for a longer-lived JWT that can be
used to query the regular API viewsets.
Known limitations and follow-ups:
* At some point it would be nice to shorten the JWT lifetime and
add a refresh mechanism. This will be handled in a follow-up PR
when actually needed.
* CSP rules to control which origins are allowed to embed the app
in an iframe still need to be added.
* This alternative authentication cannot easily be scoped to a
subset of endpoints without adding a lot of complexity, so it is
accepted globally on the API for now.
Load allowed domains once and compare the lowercased email domain
in Python. For restricted applications with an existing user,
this reduces successful token requests from four queries to three.
Keep domain loading after credential validation so rejected
credentials require only the application lookup.
Add endpoint query-count tests for restricted and unrestricted
successes and invalid credentials.
The client id and client secret are auto-generated with high entropy
when an application is created. Allowing them to be edited from the
Django admin let any administrator replace them with a short or
weak value, undermining that guarantee.
Make both fields read-only in the admin so they can only be
regenerated through the intended flow.
Application token authentication currently uses Django's default
password hasher. On our production hardware, token requests take
at least ~500 ms. Authentication happens per user of each
application, so this cost accumulates across frequently used
integrations.
Password hashers deliberately make guessing expensive to protect
human-chosen passwords after a database leak. Our application
secrets are generated server-side using a cryptographically secure
random generator, with a default length of 128 alphanumeric
characters. Guessing these secrets is already computationally
infeasible, making password stretching an unnecessary CPU cost.
Use salted SHA-256 for application secrets while retaining
constant-time comparison, and keep user password hashing
unchanged. Existing secrets migrate after successful verification
without requiring key rotation. Conditional updates prevent
migration from overwriting a concurrent rotation.
Store the new hash in `client_secret_sha256` while preserving the
original Django hash in `client_secret`. New applications populate
both fields, allowing the previous release to authenticate both
existing and newly created applications if we need to roll back.
Once every application has migrated and the rollback window has
closed, complete the migration by removing the legacy verification
code and `client_secret` field.
This assumes securely generated, high-entropy secrets. Deployments
that reduce `APPLICATION_CLIENT_SECRET_LENGTH` or supply
predictable secrets lose the offline guessing protection that the
previous slow hasher provided.
When the user is updated, their lists on Brevo are overwritten with
the new value: this removes lists set by other products.
Switch to the common lib implementation from `django-lasuite`,
which manages this correctly.
This change was initially proposed by @qbey, but at the time our
deployment did not have a Celery worker running alongside the
backend. Since then, a Celery worker has been deployed, so the
switch to the common lib approach is now safe to adopt.
Highlight in the connection test when the user is connecting
through a TURN relay, especially over TLS or TCP. This usually
indicates that some network configuration is required on their
side, and gives them a concrete signal to pass to their IT team.
Suggested by a technical user, this is a first step toward making
users more autonomous when troubleshooting access to the tool.
Follow-up: show a similar warning in-product when we detect a
mid-meeting fallback to TURN/TLS. A one-time hint for first-time
users would likely be enough.
The home page only offers meeting creation to authenticated users, while the
backend already serves ephemeral rooms to anonymous visitors when
ALLOW_UNREGISTERED_ROOMS is enabled (the flag is checked in the room retrieve
view, not in the create one).
Expose the flag in the frontend configuration and, when it is on, show the
existing "Create a meeting" button to signed-out visitors. It navigates to a
freshly generated room id rather than calling POST /rooms/, which stays
reserved for registered rooms and for authenticated users.
The invite dialog opens for such a creator when the room is unregistered
(null id) and the navigation carries `create`. The `mode` computed in Room
is left untouched, so permissions and the join screen behave as before.
The home buttons row now wraps: signed-out visitors can see three controls
(create, join, login), and at the xsm breakpoint the fixed-width ProConnect
button leaves too little room for the other two.
Add new options to query start-start recording API. A resolution
("540p", "720p", "1080p") and a profile ("talking_heads", "text", "mixed")
are resolved to provide a width, height, fps and bitrate which
are passed on to the encoder. Using profiles allows for some flexibility
on quality if necessary without changing front facing user config.
Co-authored-by: sarthakbahal <sarthakbahal.45@gmail.com>
Address the following HIGH severity CVEs reported by Trivy on the
backend image:
* Django 5.2.16 → 5.2.17
- CVE-2026-15307 — remote code execution via GeoDjango spatial
lookups.
* urllib3 2.7.0 → 2.8.0
- CVE-2026-97687 — traffic interception via HTTPS proxy TLS
configuration override.
- CVE-2026-97689 — denial of service via unbounded memory
allocation in the chunk parser.
Get the spellcheck CI job to pass again by:
* Fixing the actual spelling issues it caught in the project.
* Excluding generated files from the scan, since they are not
written by us.
* Excluding translation files, whose content is not necessarily in
English and would trigger false positives.
The per-minute room creation throttle absorbs bursts but does not stop
a compromised account from steadily creating rooms over hours or days.
Add RoomCreationDailyUserRateThrottle, a per-user throttle with its own
"room_creation_daily" scope, applied to room creation only alongside
the existing short-term throttle. It defaults to 1000 rooms per day and
is configurable via ROOM_CREATION_DAILY_THROTTLE_RATES.
Tests use a controllable clock and patch rates with monkeypatch so they
are restored after each test.
Add throttling on the endpoint used to generate meeting links, so a
compromised authenticated account cannot silently generate thousands
of links without hitting any suspicious errors or alerts.
The limits are set high enough not to affect legitimate usage, while
capping the damage a leaked account can do.
Bump PyJWT from 2.13.0 to 2.14.0 to address the following CVEs
reported by Trivy:
* CVE-2026-102268 (CRITICAL)
* CVE-2026-102266 (HIGH) — authentication bypass via empty HMAC
key acceptance.
* CVE-2026-102267 (HIGH) — verification key substitution via
unvalidated JWKS redirects.
* CVE-2026-102271 (HIGH) — authentication bypass via acceptance
of DER public keys as HMAC.
* CVE-2026-102272 (HIGH) — token forgery via improper Unicode
byte-order mark handling.
* CVE-2026-102273 (HIGH) — token forgery via acceptance of
public JWK containers as HMAC.
Add a `purge_inactive_rooms` management command which permanently
deletes the rooms that were not started for
`ROOM_INACTIVITY_DELETION_DAYS` days. Rooms never started are aged from
their creation date.
Important points :
- The setting is unset by default, which disables the purge.
- Rooms holding a recording their users may still access are kept,
- It refuses to run while unregistered rooms are allowed, as the link
of a purged room would turn into a public unregistered room,
Rooms pile up in database with no way to tell the ones still in use from
the abandoned ones. Record on the room the last time LiveKit reported it
as started, in a new `last_started_at` field which stays NULL until the
first time the room is started on a LiveKit node.
The migration stamps existing rooms with the migration time to avoid
deleting preexisting rooms.
The development stacks now run Garage instead of MinIO which is
deprecated.
Garage is a bit stricter than MinIO:
- key IDs and secrets must be at least 8 and 16 characters long
- requests must be signed for its region, so every development env now sets
AWS_S3_REGION_NAME=local;
- cross-origin requests are denied unless the bucket CORS rules allow
them, so a one-shot aws-cli container allows the frontend origin to
upload files straight to the bucket.
Address the following MEDIUM severity CVEs reported against
`djangorestframework` 3.17.1:
* CVE-2026-73228 (CVSS 5.3)
* CVE-2026-73229 (CVSS 4.3)
Bump `djangorestframework` to the patched version to pick up the
fixes.
Most call sites of `update_metadata` already wrap the call in a
try/except that logs the failure at info level.
Remove the warning log inside `update_metadata` itself to avoid
redundant logs, without losing any information.
`EGRESS_ABORTED` and `EGRESS_FAILED` events were previously ignored, leaving
recordings indefinitely in `ACTIVE` state and potentially blocking subsequent
recordings with 409 errors. Add handling and logging for failed and aborted
egresses, discarding failed recordings while preserving the existing behavior
for savable recordings.
Rename `handle_complete` to `handle_savable` to reflect that it handles both
`EGRESS_COMPLETE` and `EGRESS_LIMIT_REACHED`.
Slight refactor to separate LiveKit event handling from recording concerns as
part of a general separation concern to allow for future SFU swapping.
NB:
- FAILED recordings are currently discarded although exploitable media files
may exist
- There is a theoretical hole: if stop observes EGRESS_FAILED before the
egress_ended webhook is processed, the recording is immediately marked as
FAILED. Since only ACTIVE and STOPPED recordings are savable, the webhook
then skips the failure notification and LiveKit error log. In that rare race
condition, participants may therefore not see the failure toast. We accept
this trade-off for now, as this should be very infrequent.
- Another theoretical hole: There is a short race window where the user
clicks stop while the limit-reached status is being processed. Since the user
explicitly requested the stop, we consider skipping the limit notification
acceptable and do not handle this case.
fix(recording): log aborted worker events at info level
`DockerflowMiddleware` serves the endpoints `/__heartbeat__`, `/__lbheartbeat__
that we use for the kubernetes probes. Sitting at the bottom of
MIDDLEWARE, every Kubernetes probe traversed all middlewares which is not
efficient.
get_release() read the version from a version.json file but nothing generates
it during the CI Docker image build, therefore the release reported to Sentry
was always "NA".
Read the version from pyproject.toml instead, which is bumped at each
release and copied into the image.
The resource server backend returned any user matching the token's
`sub` claim without checking `User.is_active`. The upstream lasuite
backend only validates the token's introspection `active` claim, so a
deactivated Django account kept API access until its token expired.
Raise `SuspiciousOperation` in `get_or_create_user` when the user is
inactive, which the authentication class turns into a 401, consistent
with `BaseJWTAuthentication`. Add unit and end-to-end tests.
Add a configuration knob for the trace sampling rate, so we can
enable tracing on middleware and cache spans when debugging slow
requests in production.
Sampling is set to 0 by default, so tracing stays fully off unless
explicitly enabled.
Around 0.76% of incoming LiveKit webhooks were being flagged as
unprocessable and returned a 422, even though LiveKit was sending
legitimate data — just with event types we do not handle. This
inflated error metrics and made real webhook issues harder to spot.
Return a 200 for these webhooks instead. When a new, unhandled
event type shows up, log a warning so we can decide whether it is
worth adding explicit handling.
The request summary info logs were spamming the log stream, making
around 46% of the total volume, without carrying any exploitable
information.
Silence them so the remaining logs are easier to explore and cheaper
to store; roughly halves the overall log volume.
On a busy morning, `/me` alone produced 72k warning logs — 97% of
all warnings. They all come from anonymous requests to `/me`
without credentials, which is normal: `/me` is how the app
determines the current auth status.
These warnings carry no diagnostic value on this endpoint, so
silence them there to cut down on log volume.
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME was only enforced at
LiveKit token generation and by hiding the name field in the frontend.
The `rooms/{id}/rename/` endpoint never checked it, so any authenticated
user with a valid room token could rename themselves via the API even
when the self-hoster had disabled it.
Return 403 from the rename action for authenticated users when the
setting is disabled, mirroring the `can_edit` rule in
`core.utils.generate_token`. Anonymous participants are unaffected, as
they have no account name to fall back on.
Add tests covering the disabled/enabled cases for authenticated users
and the anonymous exception.
Highlighted by a suggestion from @florent, the waiting participant
list was not sorted, so moderators could see participants in an
arbitrary order.
Add an explicit `entered_at` attribute on each waiting participant,
so the list can be sorted by arrival time. Participants are now
shown in a stable order of arrival, both across polls and across
moderators.
The previous presence cache lookup keyed off a scan over the whole
cache, so its cost was O(db_size) rather than O(room_size).
Combined with the recent switch to cursor-based `SCAN` at an
inappropriate page size, this caused a lot of Redis round-trips and
noticeably slowed down the backend pods under load.
Refactor the presence cache to keep a per-room set of all its
participant keys. Lookups now iterate that set instead of scanning
the whole database.
Complexity is now bounded by room size, not database size, which
should restore the backend performance to its previous levels while
keeping the lobby behavior unchanged.
The previous lobby lookup keyed off a scan over the whole cache, so
its cost was O(db_size) rather than O(room_size). Combined with the
recent switch to cursor-based `SCAN` at an inappropriate page size,
this caused a lot of Redis round-trips and noticeably slowed down
the backend pods under load.
Refactor the lobby storage to keep a per-room set of all its lobby
keys. Lookups now iterate that set instead of scanning the whole
database:
* Membership in the set acts as a memory of who is supposedly in
the lobby for a given room.
* Individual keys are then read to check who is actually still
waiting or accepted.
Complexity is now bounded by room size, not database size, which
should restore the backend performance to its previous levels while
keeping the lobby behavior unchanged.
Increase the polling interval used by the lobby feature, on both the
waiting participant side and the moderator side.
The goal is to reduce the volume of requests the lobby generates,
trading a bit of data freshness for better performance.
It will de facto reduce pressure on the backend.
We will observe the impact in production, and revisit these
intervals if the delays turn out to be too aggressive.
The user `sub` field was rejecting some ASCII characters that are
actually valid according to the OIDC spec.
Loosen the validation to accept the full ASCII range except control
characters, so the field is compliant with the RFC and works with
any spec-compliant identity provider.
Based on the Stack Overflow discussion in question 279832.
Closes#1609.
Adds support for serving media behind Traefik, which currently cannot work
at all.
The media-auth subrequest views read the original request URL from a
hardcoded HTTP_X_ORIGINAL_URL header. That header is an nginx-ingress
convention. Traefik's ForwardAuth middleware sends X-Forwarded-Uri instead
and has no mechanism to emit X-Original-URL, so behind Traefik every
recording download and file attachment is rejected with a bare 403 --
indistinguishable from a legitimate permission denial, which makes it
painful to diagnose.
Add MEDIA_AUTH_ORIGINAL_URL_HEADER, defaulting to HTTP_X_ORIGINAL_URL so
existing nginx-ingress deployments are unaffected. Traefik deployments set
it to HTTP_X_FORWARDED_URI. It is used in both places that resolve the
header: RecordingViewSet._auth_get_original_url and the file attachment
_authorize_subrequest. The log message on a missing header now names the
header actually expected, which is what makes the failure diagnosable.
This mirrors the setting the sibling Docs project already exposes
(suitenumerique/docs, MEDIA_AUTH_ORIGINAL_URL_HEADER) for the same reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>