security: CORS hardening, path traversal fix, WebSocket auth + cleanup

- Restrict CORS to localhost origins (was allow_origins=[*])
- Require valid JWT on WebSocket /ws (anonymous no longer gets admin view)
- Fix path traversal in delete_cell(): resolve() + parent check
- Validate cell_id format in /charts/download-noaa/{cell_id}
- Exclude charts/ and Cartas/ from git (keep US1GC09M world overview)
- Add NOAA ENC Portal external link in charts catalog tab
- Untrack __pycache__/, .db, .claude/ session files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 12:45:43 -04:00
parent 3e04c4113f
commit cfd94f905a
47 changed files with 1847 additions and 427 deletions
+30 -10
View File
@@ -181,9 +181,14 @@ def noaa_nearby(west: float, south: float, east: float, north: float):
return matches
import re as _re
_CELL_RE = _re.compile(r'^US[1-5][A-Z]{2}\d{2}M$')
@router.post("/download-noaa/{cell_id}")
async def download_noaa(cell_id: str):
cell_id = cell_id.upper()
if not _CELL_RE.match(cell_id):
raise HTTPException(400, f"Invalid cell_id format: {cell_id}")
url = NOAA_BASE.format(cell=cell_id)
log.info("Downloading NOAA ENC %s from %s", cell_id, url)
try:
@@ -333,35 +338,50 @@ def set_cell_region(cell_id: str, region: str):
return {"id": cell_id.upper(), "region": region}
# Chart caches are rebuilt at server startup / explicit /rebuild-cache.
# Between rebuilds, GeoJSON content is stable, so we let the browser cache
# the response for an hour. ETag/304 would be safer but max-age is enough
# for typical operator sessions and avoids redownloading 350 MB of depths.
_CHART_CACHE_HEADERS = {"Cache-Control": "private, max-age=3600"}
def _bbox(w, s, e, n):
return (w, s, e, n) if None not in (w, s, e, n) else None
@router.get("/features")
def chart_features():
return JSONResponse(get_all_features())
def chart_features(w: float | None = None, s: float | None = None,
e: float | None = None, n: float | None = None):
return JSONResponse(get_all_features(_bbox(w, s, e, n)),
headers=_CHART_CACHE_HEADERS)
@router.get("/depths")
def chart_depths(w: float | None = None, s: float | None = None,
e: float | None = None, n: float | None = None):
bbox = (w, s, e, n) if None not in (w, s, e, n) else None
return JSONResponse(get_all_depths(bbox))
return JSONResponse(get_all_depths(_bbox(w, s, e, n)),
headers=_CHART_CACHE_HEADERS)
@router.get("/land")
def chart_land(w: float | None = None, s: float | None = None,
e: float | None = None, n: float | None = None):
bbox = (w, s, e, n) if None not in (w, s, e, n) else None
return JSONResponse(get_all_land(bbox))
return JSONResponse(get_all_land(_bbox(w, s, e, n)),
headers=_CHART_CACHE_HEADERS)
@router.get("/hazards")
def chart_hazards():
return JSONResponse(get_all_hazards())
def chart_hazards(w: float | None = None, s: float | None = None,
e: float | None = None, n: float | None = None):
return JSONResponse(get_all_hazards(_bbox(w, s, e, n)),
headers=_CHART_CACHE_HEADERS)
@router.get("/zones")
def chart_zones(w: float | None = None, s: float | None = None,
e: float | None = None, n: float | None = None):
bbox = (w, s, e, n) if None not in (w, s, e, n) else None
return JSONResponse(get_all_zones(bbox))
return JSONResponse(get_all_zones(_bbox(w, s, e, n)),
headers=_CHART_CACHE_HEADERS)
@router.post("/rebuild-cache")