Compare commits

...

3 Commits

Author SHA1 Message Date
Paul Csiki cf3960db95 (backend) add Traefik reverse proxy support for media-auth
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>
2026-09-02 18:07:23 +02:00
lebaudantoine d80d31897c 🔒️(frontend) fix HIGH CVEs in libexpat 2.8.2-r0
Address the following HIGH severity CVEs in libexpat 2.8.2-r0,
reported by Trivy:

* CVE-2026-66046
* CVE-2026-76641
2026-09-02 15:05:01 +02:00
kaelvar 63a7751072 (frontend) add 1080p sending resolution option
The sending resolution selector stopped at 720p while `VideoPresets` already
exposes `h1080` (1920x1080), so publishers on a good uplink could not make use
of the capacity they had. Add "Very high definition (1080p)" above the existing
entries, translated in the five supported locales.

The default stays `h720`, so nothing changes unless a user goes and picks the
new entry. Being explicit about what that costs, since 1080p roughly doubles a
publisher's uplink: this is a per-user choice, and an instance operator has no
way today to decline it. Whether that warrants a server-side setting alongside
the existing `ApiConfig` flags is a call for maintainers — happy to add one if
you want it, rather than change the API contract unasked in a frontend PR.

While here, make the option list harder to get wrong. Resolutions now come from
a single `VIDEO_RESOLUTIONS` tuple that `VideoResolution` derives from, the
selector items are built by mapping over it against a
`Record<VideoResolution, string>` of labels — so a resolution cannot be added
to one and forgotten in the other — and a persisted value that is not in the
tuple falls back to `h720` instead of reaching `VideoPresets[...]` as
`undefined`, since `loadUserChoices` spreads localStorage without validating
it.

Known limitation, unchanged by this patch: `restartTrack` passes the resolution
as an `ideal` constraint, so a camera that cannot reach the selected height
degrades silently. That is already true of 720p on a 480p webcam; 1080p is the
first step where the gap is the common case rather than the edge one.
2026-09-02 15:05:01 +02:00
14 changed files with 176 additions and 24 deletions
+5
View File
@@ -8,6 +8,11 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Added
- ✨(frontend) add 1080p sending resolution option #1660
- ✨(backend) add Traefik support via configurable media-auth url header #1649
### Fixed ### Fixed
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667 - 🐛(frontend) keep the sending resolution picked while the camera is off #1667
+1
View File
@@ -65,6 +65,7 @@ RUN apk update && apk upgrade \
musl \ musl \
musl-utils \ musl-utils \
zlib>=1.3.2-r0 \ zlib>=1.3.2-r0 \
libexpat>=2.8.4-r0 \
&& apk del curl && apk del curl
USER nginx USER nginx
+17 -7
View File
@@ -1076,9 +1076,10 @@ class RecordingViewSet(
def _auth_get_original_url(self, request): def _auth_get_original_url(self, request):
""" """
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header. Extracts and parses the original URL from the configured header.
Raises PermissionDenied if the header is missing. Raises PermissionDenied if the header is missing.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header. The original url is passed by the reverse proxy in the header named by the
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
See corresponding ingress configuration in Helm chart and read about the See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this. is configured to do this.
@@ -1088,9 +1089,13 @@ class RecordingViewSet(
reasons. reasons.
""" """
# Extract the original URL from the request header # Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL") original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
if not original_url: if not original_url:
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest") logger.warning(
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
"to the header your reverse proxy sends.",
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
)
raise drf_exceptions.PermissionDenied() raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url) logger.debug("Original url: '%s'", original_url)
@@ -1415,7 +1420,8 @@ class FileViewSet(
Authorize access based on the original URL of an Nginx subrequest Authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized. and user permissions. Returns a dictionary of URL parameters if authorized.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header. The original url is passed by the reverse proxy in the header named by the
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
See corresponding ingress configuration in Helm chart and read about the See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this. is configured to do this.
@@ -1434,9 +1440,13 @@ class FileViewSet(
- PermissionDenied if authorization fails. - PermissionDenied if authorization fails.
""" """
# Extract the original URL from the request header # Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL") original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
if not original_url: if not original_url:
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest") logger.warning(
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
"to the header your reverse proxy sends.",
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
)
raise drf_exceptions.PermissionDenied() raise drf_exceptions.PermissionDenied()
parsed_url = urlparse(original_url) parsed_url = urlparse(original_url)
@@ -7,6 +7,7 @@ from urllib.parse import quote, urlparse
from django.conf import settings from django.conf import settings
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.test import override_settings
from django.utils import timezone from django.utils import timezone
import pytest import pytest
@@ -143,3 +144,59 @@ def test_api_files_media_auth_own_file_deleted():
) )
assert response.status_code == 403 assert response.status_code == 403
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_files_media_auth_custom_original_url_header():
"""
Authorization should honour the configured original-url header.
Covers the attachment subrequest path, which resolves the header separately
from the recording one. Reverse proxies other than nginx-ingress use
different headers: Traefik's ForwardAuth sends X-Forwarded-Uri and cannot
emit X-Original-URL at all.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
default_storage.save(file.file_key, BytesIO(b"my prose"))
original_url = f"http://localhost/media/{file.file_key:s}"
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_FORWARDED_URI=original_url
)
assert response.status_code == 200
assert "AWS4-HMAC-SHA256 Credential=" in response["Authorization"]
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_files_media_auth_default_header_ignored_when_reconfigured():
"""
Only the configured header should be honoured, never a hardcoded fallback.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/{file.file_key:s}"
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@@ -8,6 +8,7 @@ from uuid import uuid4
from django.conf import settings from django.conf import settings
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.test import override_settings
from django.utils import timezone from django.utils import timezone
import pytest import pytest
@@ -282,3 +283,63 @@ def test_api_recordings_media_auth_success_administrator(mode):
timeout=1, timeout=1,
) )
assert response.content.decode("utf-8") == "my prose" assert response.content.decode("utf-8") == "my prose"
def test_api_recordings_media_auth_missing_header():
"""
Test that a subrequest without the configured original-url header is rejected.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/recordings/media-auth/")
assert response.status_code == 403
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_recordings_media_auth_custom_original_url_header():
"""
Test that the header carrying the original URL can be configured.
Reverse proxies other than nginx-ingress use different headers: Traefik's
ForwardAuth sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_FORWARDED_URI=original_url
)
# The header was read and parsed: we get as far as looking the recording up,
# rather than being rejected for a missing header.
assert response.status_code == 404
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_recordings_media_auth_default_header_ignored_when_reconfigured():
"""
Test that only the configured header is honoured.
Guards against the header being read from a hardcoded name in parallel with
the setting.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
+9
View File
@@ -129,6 +129,15 @@ class Base(Configuration):
MEDIA_BASE_URL = values.Value( MEDIA_BASE_URL = values.Value(
"", environ_name="MEDIA_BASE_URL", environ_prefix=None "", environ_name="MEDIA_BASE_URL", environ_prefix=None
) )
# Header the reverse proxy uses to pass the original request URL to the
# media-auth subrequest views. nginx-ingress sends X-Original-URL, which is
# the default. Other proxies use different headers -- Traefik's ForwardAuth,
# for instance, sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
MEDIA_AUTH_ORIGINAL_URL_HEADER = values.Value(
default="HTTP_X_ORIGINAL_URL",
environ_name="MEDIA_AUTH_ORIGINAL_URL_HEADER",
environ_prefix=None,
)
SITE_ID = 1 SITE_ID = 1
+1
View File
@@ -53,6 +53,7 @@ RUN apk update && apk upgrade \
musl \ musl \
musl-utils \ musl-utils \
zlib>=1.3.2-r0 \ zlib>=1.3.2-r0 \
libexpat>=2.8.4-r0 \
&& apk del curl && apk del curl
USER nginx USER nginx
@@ -18,6 +18,7 @@ import {
saveVideoPublishResolution, saveVideoPublishResolution,
saveVideoSubscribeQuality, saveVideoSubscribeQuality,
userChoicesStore, userChoicesStore,
VIDEO_RESOLUTIONS,
VideoResolution, VideoResolution,
} from '@/stores/userChoices' } from '@/stores/userChoices'
import { RowWrapper } from './layout/RowWrapper' import { RowWrapper } from './layout/RowWrapper'
@@ -70,7 +71,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
isDisabled: true, isDisabled: true,
} }
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => { const handleVideoResolutionChange = async (key: VideoResolution) => {
saveVideoPublishResolution(key) saveVideoPublishResolution(key)
const videoTrack = localParticipant.getTrackPublication( const videoTrack = localParticipant.getTrackPublication(
Track.Source.Camera Track.Source.Camera
@@ -124,20 +125,13 @@ export const VideoTab = ({ id }: VideoTabProps) => {
}, [videoDeviceId, videoElement]) }, [videoDeviceId, videoElement])
const resolutionItems = useMemo(() => { const resolutionItems = useMemo(() => {
return [ const labels: Record<VideoResolution, string> = {
{ h1080: `${t('resolution.publish.items.veryHigh')} (1080p)`,
value: 'h720', h720: `${t('resolution.publish.items.high')} (720p)`,
label: `${t('resolution.publish.items.high')} (720p)`, h360: `${t('resolution.publish.items.medium')} (360p)`,
}, h180: `${t('resolution.publish.items.low')} (180p)`,
{ }
value: 'h360', return VIDEO_RESOLUTIONS.map((value) => ({ value, label: labels[value] }))
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]
}, [t]) }, [t])
const videoQualityItems = useMemo(() => { const videoQualityItems = useMemo(() => {
@@ -56,6 +56,7 @@
"publish": { "publish": {
"label": "Wähle die maximale Auflösung beim Senden", "label": "Wähle die maximale Auflösung beim Senden",
"items": { "items": {
"veryHigh": "Sehr hohe Auflösung",
"high": "Hohe Auflösung", "high": "Hohe Auflösung",
"medium": "Mittlere Auflösung", "medium": "Mittlere Auflösung",
"low": "Niedrige Auflösung" "low": "Niedrige Auflösung"
@@ -56,6 +56,7 @@
"publish": { "publish": {
"label": "Select your sending resolution (max.)", "label": "Select your sending resolution (max.)",
"items": { "items": {
"veryHigh": "Very high definition",
"high": "High definition", "high": "High definition",
"medium": "Standard definition", "medium": "Standard definition",
"low": "Low definition" "low": "Low definition"
@@ -56,6 +56,7 @@
"publish": { "publish": {
"label": "Selecciona tu resolución de envío (máx.)", "label": "Selecciona tu resolución de envío (máx.)",
"items": { "items": {
"veryHigh": "Muy alta definición",
"high": "Alta definición", "high": "Alta definición",
"medium": "Definición estándar", "medium": "Definición estándar",
"low": "Baja definición" "low": "Baja definición"
@@ -56,6 +56,7 @@
"publish": { "publish": {
"label": "Sélectionner votre résolution d'envoi (max.)", "label": "Sélectionner votre résolution d'envoi (max.)",
"items": { "items": {
"veryHigh": "Très haute définition",
"high": "Haute définition", "high": "Haute définition",
"medium": "Définition standard", "medium": "Définition standard",
"low": "Basse définition" "low": "Basse définition"
@@ -56,6 +56,7 @@
"publish": { "publish": {
"label": "Selecteer uw verzendresolutie (max.)", "label": "Selecteer uw verzendresolutie (max.)",
"items": { "items": {
"veryHigh": "Zeer hoge definitie",
"high": "Hoge definitie", "high": "Hoge definitie",
"medium": "Standaarddefinitie", "medium": "Standaarddefinitie",
"low": "Lage definitie" "low": "Lage definitie"
+11 -2
View File
@@ -10,7 +10,12 @@ import {
} from '@livekit/components-core' } from '@livekit/components-core'
import { VideoQuality } from 'livekit-client' import { VideoQuality } from 'livekit-client'
export type VideoResolution = 'h720' | 'h360' | 'h180' export const VIDEO_RESOLUTIONS = ['h1080', 'h720', 'h360', 'h180'] as const
export type VideoResolution = (typeof VIDEO_RESOLUTIONS)[number]
const isVideoResolution = (value: unknown): value is VideoResolution =>
VIDEO_RESOLUTIONS.includes(value as VideoResolution)
export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & { export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
processorConfig?: ProcessorConfig processorConfig?: ProcessorConfig
@@ -21,13 +26,17 @@ export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
} }
function getUserChoicesState(): LocalUserChoices { function getUserChoicesState(): LocalUserChoices {
return { const stored: LocalUserChoices = {
noiseReductionEnabled: false, noiseReductionEnabled: false,
audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior
videoPublishResolution: 'h720', videoPublishResolution: 'h720',
videoSubscribeQuality: VideoQuality.HIGH, videoSubscribeQuality: VideoQuality.HIGH,
...loadUserChoices(), ...loadUserChoices(),
} }
if (!isVideoResolution(stored.videoPublishResolution)) {
stored.videoPublishResolution = 'h720'
}
return stored
} }
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState()) export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())