Skip to content

Enforce authorization on DZI tile and thumbnail delivery #1064

Description

@kphunter

Security vulnerability remediation for tiles and thumbnails

Severity: High
Suggested CWE: CWE-862 — Missing Authorization; CWE-639 — Authorization Bypass Through User-Controlled Key

Description

The API correctly applies authentication and student program/group visibility rules when retrieving image metadata, but the actual DZI descriptors, thumbnails, and high-resolution image tiles are served through an unauthenticated static-file path.

This creates an authorization bypass: a user who is not permitted to view an image—or a completely unauthenticated client—can request its tile resources directly if the source image ID is known or discovered.

Source image IDs are numeric and embedded directly in predictable URLs such as:

/api/tiles/<source_image_id>/image.dzi
/api/tiles/<source_image_id>/thumbnail.jpeg
/api/tiles/<source_image_id>/image_files/<level>/<column>_<row>.jpeg

The visibility rules applied by /api/images are therefore not enforced on the resource containing the actual image data.

Affected files and lines

backend/app/routers/images.py

Lines 38–63

Image listing requires authentication and applies program/group restrictions to students:

_user: Annotated[User, Depends(get_current_user)]
...
if _user.role == "student":
    ...
    excluded = await get_student_excluded_category_ids(...)

backend/app/routers/images.py

Lines 66–84

Retrieving an individual image also requires authentication and checks whether students are permitted to access its category:

_user: Annotated[User, Depends(get_current_user)]
...
if not await is_category_visible_to_student(...):
    raise HTTPException(status_code=404, detail="Image not found")

backend/app/visibility.py

Lines 102–126

is_category_visible_to_student() correctly checks hidden categories and program/group restrictions across the category ancestry.

These controls establish that category visibility is intended to be an authorization boundary.

backend/app/main.py

Lines 262–264

The generated tile directory is nevertheless mounted directly with Starlette StaticFiles:

os.makedirs(settings.tiles_dir, exist_ok=True)
app.mount("/api/tiles", StaticFiles(directory=settings.tiles_dir), name="tiles")

No authentication or category visibility dependency is executed for requests handled by this mount.

backend/app/processing.py

Lines 544 and 625–627

Tiles are stored under the numeric source-image ID, and that ID is embedded directly in the public URL:

output_dir = os.path.join(settings.tiles_dir, str(src.id))

tile_sources_url = f"/api/tiles/{src.id}/{dzi_rel}"
thumb_url = f"/api/tiles/{src.id}/{thumb_rel}"

backend/app/processing.py

Lines 1090–1104

The source itself documents the predictable URL structure:

Tile URLs look like `/api/tiles/<source_image_id>/image.dzi`

and parses the identifier as an integer.

charts/backend/templates/configmap-nginx-tiles.yaml

Lines 14–34

In the Kubernetes deployment, the problem is even more explicit: nginx serves the PVC directly rather than passing the request through the authenticated API:

location /api/tiles/ {
    alias .../;
    autoindex off;

    add_header Cache-Control "public, max-age=2592000, immutable";
}

There is no authentication or authorization check.

charts/frontend/files/default.conf.template

Lines 90–121

The public frontend explicitly routes tile requests to the tile server:

location /api/tiles/ {
    ...
    proxy_pass $tile_server$request_uri;
    ...
    add_header Cache-Control "public, max-age=2592000, immutable" always;
}

The configuration notes that this path bypasses the Python process entirely.

Security impact

This bypass defeats the application's image-access controls.

For example, an image may correctly be hidden from a student because:

  • the student belongs to the wrong program;
  • the student does not belong to the required group;
  • the image/category is hidden;
  • the requester is not authenticated at all.

However, direct requests to the corresponding /api/tiles/... URL do not perform those checks.

Because the source identifiers are integers and filenames/paths are predictable, resource enumeration is also practical.

The Cache-Control: public headers compound the problem because protected image content may be stored by shared intermediary/CDN caches without regard to the application's authorization model.

Recommended fix

Do not expose protected tile storage as an unrestricted static directory.

Because OpenSeadragon can make many tile requests, performing a database authorization query for every individual JPEG is probably undesirable. A better architecture would be:

  1. Require normal application authorization when an image is opened.

  2. After authorization succeeds, issue a short-lived, image-scoped access credential.

  3. Use that credential to authorize the DZI descriptor, thumbnail, and tile hierarchy.

  4. Validate the credential at nginx or another lightweight edge component rather than performing a full database lookup for every tile.

  5. Scope the credential to the specific source/image and give it a short expiration.

  6. Continue to enforce:

    • active/inactive image state;
    • hidden ancestors;
    • program restrictions;
    • group restrictions.
  7. Do not use Cache-Control: public for protected resources unless the cache key itself is protected by an appropriate signed URL/token design.

An alternative is an authenticated FastAPI endpoint that performs the visibility check and then uses nginx X-Accel-Redirect to deliver the actual file efficiently.

Suggested acceptance criteria

  • An unauthenticated request to a protected DZI descriptor returns 401, 403, or preferably 404.
  • An unauthenticated request to a thumbnail or individual tile cannot retrieve image content.
  • A student outside the permitted program cannot retrieve any DZI resources for the image.
  • A student outside the permitted group cannot retrieve any DZI resources for the image.
  • Hidden categories and inactive images cannot be bypassed by requesting tile URLs directly.
  • Authorized users can continue to view DZI images without significant per-tile database overhead.
  • Authorization applies consistently to the descriptor, thumbnail, and every resolution tile.
  • Protected image resources are no longer marked as universally public cacheable unless a safe signed-resource caching design is implemented.
  • Automated integration tests cover direct tile-URL access, not only /api/images access.

Metadata

Metadata

Assignees

No one assigned

    Labels

    securityVulnerabilities, secret leakage or dependency exposures.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions