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 "