stroke-deepisles-demo / docs /bugs /004-staticfiles-cors-middleware-not-applied.md
VibecoderMcSwaggins's picture
docs(bugs): add BUG-004 - StaticFiles CORS middleware not applied
1977496
# Bug 004: CORS/CORP Middleware Not Applied to Mounted StaticFiles
**Status**: OPEN
**Date Found**: 2025-12-12
**Severity**: CRITICAL (blocks NiiVue from loading NIfTI files)
**Requires**: Senior Review
---
## Symptoms
1. Frontend loads successfully at `https://vibecodermcswaggins-stroke-viewer-frontend.hf.space`
2. Backend health check responds: `{"status":"healthy"}`
3. Case dropdown populates (GET /api/cases works)
4. Segmentation job runs successfully (39.5s, returns metrics)
5. Results panel shows: Case, Dice Score (0.000), Volume (0.00 mL), Time (39.5s)
6. **NiiVue viewer shows: "Failed to load volume: Failed to fetch"**
![Screenshot showing the error](../assets/niivue-failed-to-fetch-screenshot.png)
## Root Cause Analysis
### The Core Issue: Starlette Middleware Architecture
When FastAPI/Starlette mounts a sub-application (like `StaticFiles`), the parent app's middleware **does NOT propagate** to the mounted app.
```python
# main.py - Current implementation
app = FastAPI(...)
# These middlewares only apply to routes on `app` itself
app.add_middleware(CORPMiddleware) # For SharedArrayBuffer
app.add_middleware(CORSMiddleware, allow_origins=CORS_ORIGINS, ...)
# This creates a SEPARATE sub-application with its OWN middleware stack (empty!)
app.mount("/files", StaticFiles(directory=str(RESULTS_DIR)), name="files")
```
**Result**:
- API routes (`/api/*`) β†’ Get CORS headers βœ“
- Static files (`/files/*`) β†’ NO CORS headers βœ— (blocked by browser)
### Evidence
1. Backend logs show all requests succeed with 200 OK
2. Browser DevTools Network tab would show CORS preflight failure for `/files/*` requests
3. NiiVue's `loadVolumes()` throws "Failed to fetch" (generic browser CORS error)
### Starlette Documentation Reference
From [Starlette Middleware Docs](https://www.starlette.io/middleware/):
> "Each sub-app owns its routers/middleware/lifecycle"
From [FastAPI/Starlette Discussion #7319](https://github.com/fastapi/fastapi/discussions/7319):
> "When using `app.mount()`, middleware on the parent app may not apply to mounted sub-apps"
## Impact
- **NiiVue cannot load NIfTI files** - Core functionality completely broken
- **SharedArrayBuffer may not work** - CORP header also missing from static files
- **All production users affected** - Cross-origin fetch blocked
## Proposed Solutions
### Solution A: Custom Route Instead of StaticFiles (Recommended)
Replace `StaticFiles` mount with explicit route handlers that go through the main app's middleware:
```python
from fastapi.responses import FileResponse
@router.get("/files/{job_id}/{case_id}/{filename}")
async def get_result_file(job_id: str, case_id: str, filename: str):
"""Serve NIfTI result files through main app (gets CORS headers)."""
file_path = RESULTS_DIR / job_id / case_id / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(
file_path,
media_type="application/gzip", # .nii.gz
filename=filename,
)
```
**Pros**: Simple, uses existing middleware
**Cons**: Less efficient than StaticFiles for large files (no sendfile)
### Solution B: ASGI Middleware Wrapper for StaticFiles
Wrap `StaticFiles` in a custom ASGI app that adds CORS headers:
```python
class CORSStaticFiles:
"""StaticFiles wrapper that adds CORS headers."""
def __init__(self, directory: str, origins: list[str]):
self.static = StaticFiles(directory=directory)
self.origins = origins
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
# Add CORS headers to response
async def send_with_cors(message):
if message["type"] == "http.response.start":
headers = dict(message.get("headers", []))
origin = dict(scope.get("headers", [])).get(b"origin", b"").decode()
if origin in self.origins:
headers[b"access-control-allow-origin"] = origin.encode()
headers[b"cross-origin-resource-policy"] = b"cross-origin"
message["headers"] = list(headers.items())
await send(message)
return await self.static(scope, receive, send_with_cors)
return await self.static(scope, receive, send)
# Usage
app.mount("/files", CORSStaticFiles(str(RESULTS_DIR), CORS_ORIGINS))
```
**Pros**: Preserves StaticFiles efficiency
**Cons**: More complex, custom ASGI code
### Solution C: Nginx/Caddy Reverse Proxy
Add CORS headers at the reverse proxy level (HF Spaces would need configuration access):
**Pros**: Most efficient, proper separation of concerns
**Cons**: HF Spaces may not support custom proxy config
## Additional Issues Discovered
### Issue 1: BACKEND_PUBLIC_URL Not Set
The Dockerfile doesn't set `BACKEND_PUBLIC_URL`, relying on `--proxy-headers` and `request.base_url`. This is fragile:
```dockerfile
# Current (fragile)
CMD ["uvicorn", "...:app", "--proxy-headers"]
# Should add (robust)
ENV BACKEND_PUBLIC_URL=https://vibecodermcswaggins-stroke-deepisles-demo.hf.space
```
### Issue 2: chmod "Operation not permitted" Warnings
```
chmod: changing permissions of '/app/weights/SEALS/nnUNet_trained_models/...': Operation not permitted
```
**Status**: Harmless - DeepISLES tries to chmod model weights but fails. The container can still READ the files, which is all that's needed. These are benign warnings, not errors.
## Files Affected
- `src/stroke_deepisles_demo/api/main.py` - Needs fix for static file CORS
- `Dockerfile` - Should set `BACKEND_PUBLIC_URL` explicitly
## Verification Steps
After fix:
1. Deploy updated backend to HF Spaces
2. Clear browser cache
3. Open frontend, select case, run segmentation
4. Check browser DevTools β†’ Network tab:
- `/files/*` requests should show `access-control-allow-origin` header
5. NiiVue should load and display DWI + prediction overlay
## References
- [FastAPI/Starlette CORS Discussion #7319](https://github.com/fastapi/fastapi/discussions/7319)
- [Starlette Middleware Stack](https://www.starlette.io/middleware/)
- [FastAPI CORS Tutorial](https://fastapi.tiangolo.com/tutorial/cors/)
- [CORSMiddleware not working with mounted apps](https://github.com/fastapi/fastapi/issues/1663)
## Senior Review Questions
1. **Solution preference**: Route-based (A) vs ASGI wrapper (B)?
2. **BACKEND_PUBLIC_URL**: Set in Dockerfile or HF Space settings?
3. **Testing**: Add integration test for static file CORS headers?
4. **Monitoring**: How to detect this regression in future?