Skip to content

Commit d0979c0

Browse files
committed
🐛(backend) give media-auth its own throttle scope
media-auth is the nginx auth_request subrequest, called once per attachment displayed in a document. It shared the 80/minute "document" bucket with every other document endpoint, so a document with many images, or anonymous readers behind a shared NAT (keyed by IP), got 429 answers and broken images, reported to Sentry as "Rate limit exceeded for scope document". DRF's ScopedRateThrottle reads the scope from the view's throttle_scope (the class attribute of DocumentThrottle is overwritten), so the action now sets throttle_scope="media_auth", with its own rate configurable by API_MEDIA_AUTH_THROTTLE_RATE. The default, 200/minute, no longer shares its budget with the other document endpoints and leaves room for a document holding many images to be opened several times a minute. The check is a read-only authorization (two indexed queries and a HEAD on the object storage), whose real protection is the permission check. Signed-off-by: Anthony LC <anthony.le-courric@mail.numerique.gouv.fr>
1 parent e456ece commit d0979c0

6 files changed

Lines changed: 89 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ and this project adheres to
3333
- 🐛(backend) mark only the top duplicate as a copy, after its title #2855
3434
- 🐛(yhub) measure migration durations with a monotonic clock #2814
3535
- 🐛(backend) send the mention email when the cache is unreachable #2803
36+
- 🐛(backend) give media-auth its own throttle scope
3637
- 💄(frontend) remove the vertical line next to nested blocks #2825
3738

3839
## [v6.0.1] - 2026-10-15

‎documentation/env.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ These are the environment variables you can set for the `impress-backend` contai
2020
| AI_MODEL | AI Model to use | |
2121
| AI_VERCEL_SDK_VERSION | The vercel AI SDK version used | 6 |
2222
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
23+
| API_MEDIA_AUTH_THROTTLE_RATE | Throttle rate for the media-auth endpoint, called by the ingress for each attachment displayed | 200/minute |
2324
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
2425
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
2526
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |

‎env.d/development/common.e2e‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ API_DOCUMENT_ACCESS_THROTTLE_RATE=2000/min
99
API_DOCUMENT_ASK_FOR_ACCESS_THROTTLE_RATE=2000/min
1010
API_DOCUMENT_THROTTLE_RATE=2000/min
1111
API_INVITATION_THROTTLE_RATE=2000/min
12+
API_MEDIA_AUTH_THROTTLE_RATE=2000/min
1213
API_USERS_LIST_THROTTLE_RATE_BURST=2000/min
1314
API_USERS_LIST_THROTTLE_RATE_SUSTAINED=2000/min

‎src/backend/core/api/viewsets.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2132,7 +2132,12 @@ def _auth_get_url_params(self, pattern, fragment):
21322132
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
21332133
raise drf.exceptions.PermissionDenied() from exc
21342134

2135-
@drf.decorators.action(detail=False, methods=["get"], url_path="media-auth")
2135+
@drf.decorators.action(
2136+
detail=False,
2137+
methods=["get"],
2138+
url_path="media-auth",
2139+
throttle_scope="media_auth",
2140+
)
21362141
def media_auth(self, request, *args, **kwargs):
21372142
"""
21382143
This view is used by an Nginx subrequest to control access to a document's

‎src/backend/core/tests/documents/test_api_documents_media_auth.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,3 +578,78 @@ def test_api_documents_media_auth_authorization_cost_is_bounded(
578578

579579
with django_assert_num_queries(MEDIA_AUTH_QUERY_COUNT):
580580
assert _media_auth_ready(user, key).status_code == 200
581+
582+
583+
def _public_attachment_key():
584+
"""Create a public document holding one attachment and return its key."""
585+
document = factories.DocumentFactory(link_reach="public")
586+
key = f"{document.id!s}/attachments/{uuid4()!s}.jpg"
587+
document.attachments = [key]
588+
document.save()
589+
return key
590+
591+
592+
def test_api_documents_media_auth_throttling_own_scope(monkeypatch, settings):
593+
"""
594+
Media-auth is throttled by its own "media_auth" scope: the throttle failure
595+
is reported under that scope once its rate is exceeded.
596+
"""
597+
rates = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]
598+
monkeypatch.setitem(rates, "media_auth", "3/minute")
599+
monkeypatch.setitem(rates, "document", "1000/minute")
600+
key = _public_attachment_key()
601+
602+
for _i in range(3):
603+
assert _media_auth_ready(None, key).status_code == 200
604+
605+
with patch("core.api.throttling.capture_message") as mock_capture_message:
606+
assert _media_auth_ready(None, key).status_code == 429
607+
mock_capture_message.assert_called_once_with(
608+
"Rate limit exceeded for scope media_auth", "warning"
609+
)
610+
611+
612+
def test_api_documents_media_auth_throttling_does_not_consume_document_scope(
613+
monkeypatch, settings
614+
):
615+
"""
616+
Displaying many attachments must not use up the "document" bucket shared by
617+
the other document endpoints (retrieve, children...).
618+
"""
619+
rates = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]
620+
monkeypatch.setitem(rates, "media_auth", "1000/minute")
621+
monkeypatch.setitem(rates, "document", "2/minute")
622+
key = _public_attachment_key()
623+
document_id = key.split("/", maxsplit=1)[0]
624+
625+
# Far more media-auth calls than the "document" rate allows all succeed.
626+
for _i in range(10):
627+
assert _media_auth_ready(None, key).status_code == 200
628+
629+
# The "document" bucket of the same anonymous client is still untouched.
630+
client = APIClient()
631+
for _i in range(2):
632+
response = client.get(f"/api/v1.0/documents/{document_id:s}/")
633+
assert response.status_code == 200
634+
response = client.get(f"/api/v1.0/documents/{document_id:s}/")
635+
assert response.status_code == 429
636+
637+
638+
def test_api_documents_media_auth_throttling_not_blocked_by_document_scope(
639+
monkeypatch, settings
640+
):
641+
"""
642+
An exhausted "document" bucket must not break the attachments of the
643+
document, since nginx shows a broken image when media-auth answers 429.
644+
"""
645+
rates = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]
646+
monkeypatch.setitem(rates, "media_auth", "1000/minute")
647+
monkeypatch.setitem(rates, "document", "1/minute")
648+
key = _public_attachment_key()
649+
document_id = key.split("/", maxsplit=1)[0]
650+
651+
client = APIClient()
652+
assert client.get(f"/api/v1.0/documents/{document_id:s}/").status_code == 200
653+
assert client.get(f"/api/v1.0/documents/{document_id:s}/").status_code == 429
654+
655+
assert _media_auth_ready(None, key).status_code == 200

‎src/backend/impress/settings.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,11 @@ class Base(Configuration):
471471
environ_name="API_DOCUMENT_THROTTLE_RATE",
472472
environ_prefix=None,
473473
),
474+
"media_auth": values.Value(
475+
default="200/minute",
476+
environ_name="API_MEDIA_AUTH_THROTTLE_RATE",
477+
environ_prefix=None,
478+
),
474479
"document_access": values.Value(
475480
default="50/minute",
476481
environ_name="API_DOCUMENT_ACCESS_THROTTLE_RATE",

0 commit comments

Comments
 (0)