diff --git a/karldns/routers/assets.py b/karldns/routers/assets.py index c862420..4413d4f 100644 --- a/karldns/routers/assets.py +++ b/karldns/routers/assets.py @@ -5,7 +5,7 @@ from pathlib import Path from fastapi import APIRouter, Response from fastapi.responses import FileResponse, RedirectResponse -from ..web.scripts import ADMIN_TABLES_JS, CLIPBOARD_JS, THEME_JS +from ..web.scripts import ADMIN_TABLES_JS, CLIPBOARD_JS, THEME_JS, VISITOR_IP_JS from ..web.styles import ADMIN_CSS, BASE_CSS ASSET_DIR = Path(__file__).parents[1] / "assets" @@ -107,6 +107,11 @@ def admin_tables_js() -> Response: return Response(ADMIN_TABLES_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS) +@router.get("/visitor-ip.js") +def visitor_ip_js() -> Response: + return Response(VISITOR_IP_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS) + + @router.get("/app.css") def app_css() -> Response: return Response(MINIFIED_BASE_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS) diff --git a/karldns/settings.py b/karldns/settings.py index a411410..f4d3f6a 100644 --- a/karldns/settings.py +++ b/karldns/settings.py @@ -85,6 +85,7 @@ class DdnsSettings(BaseModel): "/social-preview.webp", "/theme.js", "/uninstall.sh", + "/visitor-ip.js", } if any(path == reserved or path.startswith(f"{reserved}/") for reserved in reserved_paths): raise ValueError("admin_path must not conflict with a public route") diff --git a/karldns/web/layout.py b/karldns/web/layout.py index 378a23e..f54693e 100644 --- a/karldns/web/layout.py +++ b/karldns/web/layout.py @@ -137,6 +137,7 @@ def _page( {preload_logo_html} + {extra_scripts_html} diff --git a/karldns/web/scripts.py b/karldns/web/scripts.py index 1dc2cd9..319d41a 100644 --- a/karldns/web/scripts.py +++ b/karldns/web/scripts.py @@ -190,3 +190,55 @@ document.querySelectorAll("[data-admin-table]").forEach((tableShell) => { render(); }); """.strip() + + +VISITOR_IP_JS = """ +(() => { + const ipv4Placeholder = "203.0.113.10"; + const ipv6Placeholder = "2001:db8::10"; + const needsIpv4 = () => document.body.textContent.includes(ipv4Placeholder) || document.querySelector(`[data-copy*="${ipv4Placeholder}"]`); + const needsIpv6 = () => document.body.textContent.includes(ipv6Placeholder) || document.querySelector(`[data-copy*="${ipv6Placeholder}"]`); + + const fetchIp = async (url) => { + try { + const response = await fetch(url, { cache: "no-store", mode: "cors" }); + if (!response.ok) return ""; + const payload = await response.json(); + return typeof payload.ip === "string" ? payload.ip : ""; + } catch (_) { + return ""; + } + }; + + const replaceInTextNodes = (search, replacement) => { + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + const nodes = []; + while (walker.nextNode()) { + if (walker.currentNode.nodeValue.includes(search)) nodes.push(walker.currentNode); + } + nodes.forEach((node) => { + node.nodeValue = node.nodeValue.split(search).join(replacement); + }); + }; + + const replaceInCopyValues = (search, replacement) => { + document.querySelectorAll("[data-copy]").forEach((element) => { + const value = element.dataset.copy || ""; + if (value.includes(search)) element.dataset.copy = value.split(search).join(replacement); + }); + }; + + const replaceEverywhere = (search, replacement) => { + if (!replacement || replacement === search) return; + replaceInTextNodes(search, replacement); + replaceInCopyValues(search, replacement); + }; + + document.addEventListener("DOMContentLoaded", async () => { + const tasks = []; + if (needsIpv4()) tasks.push(fetchIp("https://api4.ipify.org?format=json").then((ip) => replaceEverywhere(ipv4Placeholder, ip))); + if (needsIpv6()) tasks.push(fetchIp("https://api6.ipify.org?format=json").then((ip) => replaceEverywhere(ipv6Placeholder, ip))); + await Promise.allSettled(tasks); + }); +})(); +""".strip() diff --git a/karldns/web/security_headers.py b/karldns/web/security_headers.py index 08429bf..f7f6563 100644 --- a/karldns/web/security_headers.py +++ b/karldns/web/security_headers.py @@ -132,7 +132,7 @@ def content_security_policy(path: str) -> str: "style-src 'self'; " "img-src 'self' data:; " "font-src 'self'; " - "connect-src 'self'; " + "connect-src 'self' https://api4.ipify.org https://api6.ipify.org; " "form-action 'self'; " "frame-ancestors 'none'" ) diff --git a/tests/test_ui.py b/tests/test_ui.py index add55dc..2514018 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -552,6 +552,7 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None: assert response.status_code == 200 assert '' in response.text assert '' in response.text + assert '' in response.text assert '' not in response.text assert '' in response.text assert '' in response.text @@ -560,6 +561,7 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None: assert "