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:
-
Require normal application authorization when an image is opened.
-
After authorization succeeds, issue a short-lived, image-scoped access credential.
-
Use that credential to authorize the DZI descriptor, thumbnail, and tile hierarchy.
-
Validate the credential at nginx or another lightweight edge component rather than performing a full database lookup for every tile.
-
Scope the credential to the specific source/image and give it a short expiration.
-
Continue to enforce:
- active/inactive image state;
- hidden ancestors;
- program restrictions;
- group restrictions.
-
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
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:
The visibility rules applied by
/api/imagesare therefore not enforced on the resource containing the actual image data.Affected files and lines
backend/app/routers/images.pyLines 38–63
Image listing requires authentication and applies program/group restrictions to students:
backend/app/routers/images.pyLines 66–84
Retrieving an individual image also requires authentication and checks whether students are permitted to access its category:
backend/app/visibility.pyLines 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.pyLines 262–264
The generated tile directory is nevertheless mounted directly with Starlette
StaticFiles:No authentication or category visibility dependency is executed for requests handled by this mount.
backend/app/processing.pyLines 544 and 625–627
Tiles are stored under the numeric source-image ID, and that ID is embedded directly in the public URL:
backend/app/processing.pyLines 1090–1104
The source itself documents the predictable URL structure:
and parses the identifier as an integer.
charts/backend/templates/configmap-nginx-tiles.yamlLines 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:
There is no authentication or authorization check.
charts/frontend/files/default.conf.templateLines 90–121
The public frontend explicitly routes tile requests to the tile server:
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:
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: publicheaders 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:
Require normal application authorization when an image is opened.
After authorization succeeds, issue a short-lived, image-scoped access credential.
Use that credential to authorize the DZI descriptor, thumbnail, and tile hierarchy.
Validate the credential at nginx or another lightweight edge component rather than performing a full database lookup for every tile.
Scope the credential to the specific source/image and give it a short expiration.
Continue to enforce:
Do not use
Cache-Control: publicfor 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-Redirectto deliver the actual file efficiently.Suggested acceptance criteria
401,403, or preferably404.publiccacheable unless a safe signed-resource caching design is implemented./api/imagesaccess.