diff --git a/backend/services/liveuamap_parser.py b/backend/services/liveuamap_parser.py index fc4f97a..fd03729 100644 --- a/backend/services/liveuamap_parser.py +++ b/backend/services/liveuamap_parser.py @@ -113,14 +113,9 @@ def _collect( out.append(marker) return - # Common wrapper shapes returned by APIs or page-side serialization. - for key in _WRAPPER_KEYS: - if key in value and isinstance(value[key], (dict, list, str)): - _collect(value[key], out, depth=depth + 1, inherited_id=None) - return - - # A direct marker is accepted even if coordinates are absent here; the - # provider formatter performs the final coordinate/range validation. + # Coordinate-bearing objects are markers even if they also contain a field + # named `data`/`events`. Check them before generic wrapper traversal so a + # legitimate marker cannot be swallowed by wrapper heuristics. if _looks_like_marker(value): marker = dict(value) if inherited_id and not marker.get("id"): @@ -128,6 +123,12 @@ def _collect( out.append(marker) return + # Common wrapper shapes returned by APIs or page-side serialization. + for key in _WRAPPER_KEYS: + if key in value and isinstance(value[key], (dict, list, str)): + _collect(value[key], out, depth=depth + 1, inherited_id=None) + return + # Some versions expose a dictionary keyed by marker ID. Traverse mapping # values while preserving the key as a fallback identifier. traversable = [ diff --git a/backend/services/liveuamap_scraper.py b/backend/services/liveuamap_scraper.py index 8b57823..a1bac05 100644 --- a/backend/services/liveuamap_scraper.py +++ b/backend/services/liveuamap_scraper.py @@ -274,9 +274,9 @@ def _fetch_liveuamap_browser() -> list[dict[str, Any]]: page.wait_for_timeout(5_000) html = page.content() - # Try the useful payload before classifying the page as - # a challenge. Normal pages may legitimately load a - # Turnstile asset; valid marker data should win. + # Try useful marker state before classifying the page as + # a challenge. Normal pages may load Turnstile assets; + # valid marker data should win over that heuristic. payload = _read_page_payload(page, html) if payload is None: if _looks_like_challenge(html): @@ -341,6 +341,26 @@ def _fetch_liveuamap_browser() -> list[dict[str, Any]]: return [] +def _normalize_marker_link(raw: Any, base_url: str) -> str: + """Resolve only HTTP(S) marker links; reject active/non-web URL schemes.""" + text = _as_text(raw).strip() + if not text: + return "" + try: + parsed = urlparse(text) + except ValueError: + return "" + if parsed.scheme: + return text if parsed.scheme.lower() in {"http", "https"} else "" + + try: + resolved = urljoin(base_url.rstrip("/") + "/", text) + resolved_scheme = urlparse(resolved).scheme.lower() + except ValueError: + return "" + return resolved if resolved_scheme in {"http", "https"} else "" + + def _format_markers( candidates: list[dict[str, Any]], *, @@ -374,9 +394,7 @@ def _format_markers( image = _as_text(marker.get("img") or marker.get("image") or marker.get("photo") or "").strip() source = _as_text(marker.get("source") or marker.get("src") or "").strip() event_time = marker.get("time", marker.get("t", marker.get("timestamp", ""))) - link = _as_text(marker.get("link") or marker.get("url") or "").strip() - if link and not urlparse(link).scheme: - link = urljoin(base_url.rstrip("/") + "/", link.lstrip("/")) + link = _normalize_marker_link(marker.get("link") or marker.get("url") or "", base_url) raw_id = marker.get("id", marker.get("event_id")) marker_id = _as_text(raw_id).strip() if raw_id is not None else "" diff --git a/backend/tests/test_liveuamap_parser.py b/backend/tests/test_liveuamap_parser.py index 7474e0c..7924636 100644 --- a/backend/tests/test_liveuamap_parser.py +++ b/backend/tests/test_liveuamap_parser.py @@ -44,6 +44,19 @@ def test_common_wrapper_shape(): assert _ids(payload) == ["wrapped"] +def test_coordinate_marker_wins_over_wrapper_named_field(): + payload = { + "id": "direct", + "lat": 10, + "lng": 20, + "data": {"diagnostic": "metadata, not a marker wrapper"}, + } + markers = normalize_liveuamap_payload(payload) + assert len(markers) == 1 + assert markers[0]["id"] == "direct" + assert markers[0]["lat"] == 10 + + def test_legacy_urlencoded_base64_json(): raw = json.dumps([{"id": "legacy", "lat": 1, "lng": 2}]).encode() payload = quote(base64.b64encode(raw).decode()) diff --git a/backend/tests/test_liveuamap_provider.py b/backend/tests/test_liveuamap_provider.py index 0bd031c..94c3d91 100644 --- a/backend/tests/test_liveuamap_provider.py +++ b/backend/tests/test_liveuamap_provider.py @@ -73,6 +73,17 @@ def test_api_query_token_is_not_exposed_as_marker_fallback(monkeypatch): assert "super-secret" not in repr(markers) +def test_non_http_marker_link_is_rejected(): + markers = scraper._format_markers( + [{"id": "evt", "lat": 10, "lng": 20, "link": "javascript:alert(1)"}], + region="Ukraine", + base_url="https://liveuamap.com", + provider="browser", + ) + assert markers[0]["link"] == "https://liveuamap.com" + assert "javascript:" not in repr(markers) + + def test_api_redirect_is_refused_before_following_credentials(monkeypatch): monkeypatch.setenv("LIVEUAMAP_API_URL", "https://api.example.test/events") monkeypatch.setattr(