Skip to content

Media Access Control

Every FileField and ImageField in Waldur is stored by DatabaseStorage, the global default storage. Uploads do not go to disk or S3 — they become rows in waldur_core.media.models.File, and they are all served by a single endpoint:

1
GET /api/media/<uuid>/

That endpoint has permission_classes = (). The only thing it knows about a file is its storage path, so access rules are keyed by the upload_to prefix, and they are deny by default: a file whose prefix has no registered rule is served to nobody, staff included.

How files are served

Uploads are untrusted content served from the portal's own origin, where homeport keeps the API token in localStorage. So that a file opened directly can never run as a page, every response carries:

  • Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox
  • X-Content-Type-Options: nosniff

Raster images are served inline; SVG and everything else as attachment. <img src> ignores Content-Disposition, so an SVG logo still renders where it is embedded.

The type is sniffed from the content at upload time (File.mime_type), not taken from the file name. Restrict what a field accepts with a FileTypeValidator on its serializer: ImageValidator for images, DocumentValidator for documents.

Adding a file field

Whenever you add a FileField or ImageField, you must also declare who may download it. Skipping this step is not silently insecure — it is silently broken, and CoverageTest in waldur_core/media/tests/test_access_registry.py fails until you do.

Declare rules in a media_access.py module at the root of your app. It is imported automatically at startup; no registration in apps.py is needed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# src/waldur_mastermind/invoices/media_access.py
"""Media access rules for files owned by the invoices app.

See :mod:`waldur_core.media.access`.
"""

from waldur_core.media import access
from waldur_core.structure.managers import filter_queryset_for_user
from waldur_mastermind.invoices.models import Payment

# Payment.Permissions routes through profile__organization, which is what
# PaymentViewSet's GenericRoleFilter applies.
access.register(
    access.upload_prefix(Payment, "proof"),
    access.queryset_rule(Payment, ["proof"], filter_queryset_for_user),
)

That is the real rule for payment proofs, verbatim.

Choosing a rule

Helper Who gets the file
register_public(prefix) Everyone, including anonymous users
register_authenticated(prefix) Any logged-in user, no per-object check
register_staff_only(prefix) Staff and support only
register(prefix, check) Custom check(file, user) -> bool
queryset_rule(...) Whoever the app's own read rule allows

Prefer queryset_rule. It looks the file up in its owning table and hands the result to the same filter the ViewSet uses — usually filter_queryset_for_user or a manager's filter_for_user — so a download cannot outlive the permission the API itself enforces.

Use register_public only for files already reachable through an anonymous API endpoint, and say which one in a comment. Everything on the unauthenticated marketplace catalogue and the public call-for-proposals page falls in this category.

Deriving the prefix

Never write the prefix as a literal. Derive it, so renaming an upload_to cannot leave a stale rule registered and the new path wide open:

  • access.upload_prefix(Model, "field") — for a string upload_to. A strftime-formatted value such as matrix_exports/%Y/%m/ is truncated at the first format token.
  • access.image_prefix(Model) — for a field using get_upload_path from waldur_core.media.mixins, including anything using ImageModelMixin.

get_upload_path builds paths from the bare model name with no app label, so two image-bearing models with the same class name in different apps would share a prefix. test_model_names_used_as_prefixes_are_unique guards against that.

Constraints on media_access modules

MediaConfig.ready() runs before constance and before the plugin apps' own ready(). A media_access module therefore must not:

  • touch the database or constance.config at import time;
  • import its own app's views or serializers.

Building a closure is fine; evaluating one is not. If you need a queryset helper that currently lives in views.py, move it to managers.py — see matrix_chat.managers.get_accessible_room_ids.

A media_access module that fails to import raises rather than warning. Under default-deny a swallowed import error would silently stop serving every file that app owns.

Out-of-tree plugins

Waldur loads any plugin registered under the waldur_extensions entry point, including packages outside this repository. CoverageTest only walks the models of installed apps in CI, so it cannot warn a third-party plugin author.

If you maintain a Waldur plugin with a FileField and it is not in this repository, add a media_access.py to it. Without one, its files return 404 for every user after upgrading. The failure is fail-closed rather than a data leak, but it is still an outage for that plugin's downloads.

Testing

For a test that needs a synthetic registry, use the context manager — never clear the registry directly. Autodiscovery runs once per process, so an unrestored registry would leave every later test in the same worker denying everything:

1
2
3
with access.override_rules():
    access.register_public("my_test_prefix/")
    ...

Reference

  • src/waldur_core/media/access.py — the registry
  • src/waldur_core/media/apps.py — autodiscovery
  • src/waldur_core/media/tests/test_access_registry.py — registry and coverage tests
  • src/waldur_mastermind/marketplace/media_access.py — a worked example covering public, authenticated and scope-checked prefixes