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
+108 -11
View File
@@ -1143,12 +1143,79 @@ def list_cells() -> list[dict]:
def delete_cell(cell_id: str):
cell_dir = CHARTS_DIR / cell_id.upper()
cell_dir = (CHARTS_DIR / cell_id.upper()).resolve()
if CHARTS_DIR.resolve() not in cell_dir.parents:
raise ValueError(f"Invalid cell_id: {cell_id}")
if cell_dir.exists():
shutil.rmtree(cell_dir)
def get_all_features() -> dict:
# Per-(cell, file) coverage bbox cache. Populated lazily the first time a
# cell's GeoJSON file is read; subsequent bbox queries can skip the file
# entirely if its coverage doesn't intersect the query bbox. Keyed by the
# absolute path of the cache file. Invalidated implicitly on cache rebuild
# because rebuilt files get a fresh mtime, which we include in the key.
_cell_coverage_cache: dict[str, tuple[float, float, float, float]] = {}
def _coverage_bbox(features: list) -> tuple[float, float, float, float] | None:
"""Return (min_lon, min_lat, max_lon, max_lat) covering all features.
Falls back to None when no coordinates are extractable."""
min_lon = min_lat = float("inf")
max_lon = max_lat = float("-inf")
found = False
for f in features:
# Prefer the explicit per-feature bbox if the build wrote one
fb = (f.get("properties") or {}).get("bbox")
if fb and len(fb) == 4:
min_lon = min(min_lon, fb[0]); min_lat = min(min_lat, fb[1])
max_lon = max(max_lon, fb[2]); max_lat = max(max_lat, fb[3])
found = True
continue
geom = f.get("geometry") or {}
gt = geom.get("type")
coords = geom.get("coordinates")
if not coords:
continue
# Fast path for Point — by far the most common
if gt == "Point":
lon, lat = coords[0], coords[1]
min_lon = min(min_lon, lon); max_lon = max(max_lon, lon)
min_lat = min(min_lat, lat); max_lat = max(max_lat, lat)
found = True
else:
# Walk the nested coordinate arrays (LineString, Polygon, Multi*)
stack = [coords]
while stack:
x = stack.pop()
if isinstance(x, (list, tuple)) and len(x) >= 2 and not isinstance(x[0], (list, tuple)):
lon, lat = x[0], x[1]
if isinstance(lon, (int, float)) and isinstance(lat, (int, float)):
min_lon = min(min_lon, lon); max_lon = max(max_lon, lon)
min_lat = min(min_lat, lat); max_lat = max(max_lat, lat)
found = True
elif isinstance(x, (list, tuple)):
stack.extend(x)
return (min_lon, min_lat, max_lon, max_lat) if found else None
def _feature_in_bbox(feat: dict, w: float, s: float, e: float, n: float) -> bool:
"""Spatial filter: return True iff the feature intersects (w,s,e,n).
Prefers a pre-computed properties.bbox, falls back to Point geometry."""
fb = (feat.get("properties") or {}).get("bbox")
if fb and len(fb) == 4:
return not (fb[2] < w or fb[0] > e or fb[3] < s or fb[1] > n)
geom = feat.get("geometry") or {}
if geom.get("type") == "Point":
c = geom.get("coordinates") or [None, None]
if c[0] is None or c[1] is None:
return True # malformed — keep, don't lose it
return w <= c[0] <= e and s <= c[1] <= n
# No bbox + non-point geometry — keep it (better to render than to lose).
return True
def get_all_features(bbox: tuple[float, float, float, float] | None = None) -> dict:
all_features = []
for cell_dir in CHARTS_DIR.iterdir():
cache = cell_dir / "features.geojson"
@@ -1167,30 +1234,60 @@ def get_all_features() -> dict:
# Backfill aid_type for old caches that pre-date the classifier.
if "aid_type" not in p:
p["aid_type"] = classify(p.get("layer", ""), p)
all_features.extend(fc["features"])
if bbox is not None and not _feature_in_bbox(f, *bbox):
continue
all_features.append(f)
return {"type": "FeatureCollection", "features": all_features}
def _aggregate_cache(filename: str, bbox=None) -> dict:
"""Generic aggregator: read <filename> from every installed cell."""
"""Generic aggregator: read <filename> from every installed cell.
Uses a per-file coverage-bbox cache to skip cells that don't intersect
the query bbox without reading their GeoJSON content."""
all_features = []
w = s = e = n = None
if bbox is not None:
w, s, e, n = bbox
for cell_dir in CHARTS_DIR.iterdir():
if not cell_dir.is_dir():
continue
cache = cell_dir / filename
if not cache.exists():
continue
# Pre-skip via cached cell-coverage bbox. Key includes mtime so
# the entry self-invalidates when the file is rebuilt.
if bbox is not None:
try:
mtime = cache.stat().st_mtime
except OSError:
mtime = 0
cov_key = f"{cache}|{mtime}"
cov = _cell_coverage_cache.get(cov_key)
if cov is not None:
if cov[2] < w or cov[0] > e or cov[3] < s or cov[1] > n:
continue # cell entirely outside query — skip file open
try:
fc = json.loads(cache.read_text())
except Exception:
continue
for f in (fc.get("features") or []):
feats = fc.get("features") or []
# Populate coverage cache on first read so subsequent bbox queries
# can short-circuit.
if bbox is not None and cov_key not in _cell_coverage_cache:
cov2 = _coverage_bbox(feats)
if cov2 is not None:
_cell_coverage_cache[cov_key] = cov2
if cov2[2] < w or cov2[0] > e or cov2[3] < s or cov2[1] > n:
continue # just learned: cell outside query
for f in feats:
p = f.setdefault("properties", {})
p["cell"] = cell_dir.name
if bbox is not None:
fb = p.get("bbox")
if fb and (fb[2] < w or fb[0] > e or fb[3] < s or fb[1] > n):
continue
if bbox is not None and not _feature_in_bbox(f, w, s, e, n):
continue
all_features.append(f)
return {"type": "FeatureCollection", "features": all_features}
@@ -1201,8 +1298,8 @@ def get_all_depths(bbox: tuple[float, float, float, float] | None = None) -> dic
def get_all_land(bbox: tuple[float, float, float, float] | None = None) -> dict:
return _aggregate_cache("land.geojson", bbox)
def get_all_hazards() -> dict:
return _aggregate_cache("hazards.geojson")
def get_all_hazards(bbox: tuple[float, float, float, float] | None = None) -> dict:
return _aggregate_cache("hazards.geojson", bbox)
def get_all_zones(bbox: tuple[float, float, float, float] | None = None) -> dict:
return _aggregate_cache("zones.geojson", bbox)