diff --git a/CLAUDE.md b/CLAUDE.md index 20c0b40..0f21abc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,9 @@ SQL wird durchgehend per String-Verkettung gebaut. Escaping ist Handarbeit über Klassischer Login plus WebAuthn/FIDO2. Server-Seite: `authServer.php` mit `incs/WebAuthn` (Bibliothek von Lukas Buchs) und `incs/authHelper.php`; Client-Seite `js/auth.js`. Schlüsselverwaltung über `addKey.php` und `sites/userkeys.php`. +- **Passwort-Login nur aus dem lokalen Netz**, von außen nur per Passkey. „Lokal“ entscheidet `isLocal()` in `incs/authHelper.php` anhand von `$lokale_netze` (CIDR-Liste) aus der externen Config. Anfragen mit Proxy-Headern (`X-Forwarded-For`, `Forwarded` …) gelten nie als lokal. Webserver und MariaDB laufen in Docker auf dem NAS: **Das Docker-Netz gehört nicht in die Liste**, sonst gilt jede über den Docker- oder Reverse-Proxy eingehende Anfrage als lokal. +- **Passkeys anlegen** geht nur über eine Einmal-Freigabe: Ein Superadmin erzeugt in `addKey.php` einen `addKey`, `sites/userkeys.php` löst ihn ein und legt `$_SESSION["passkey_freigabe"]` (Benutzer-id, 5 Minuten) ab. `authServer.php` akzeptiert `getCreateArgs`/`processCreate` nur mit dieser Freigabe und nimmt die Benutzer-id von dort, nie aus der URL. + ## Historie Am 2026-08-28 wurden tote Pfade entfernt: `smarty3/` (ungenutztes Smarty 3.1.6), `incs/tcpdf_old/`, `incs/fpdf.php` samt FPDF-Schriftmetriken und `incs/font/makefont/`, `sites/makepdf_OLD.php`, `incs/users_old.php` und `oldindex.php`. Auf keines davon verwies noch Code. diff --git a/incs/authHelper.php b/incs/authHelper.php index cfeb1ec..cfbbcaa 100644 --- a/incs/authHelper.php +++ b/incs/authHelper.php @@ -14,15 +14,74 @@ if($settings == "valid") $_SESSION["local"] =false; -function isLocal(){ - $ipv6_prefix = explode(":",$_SERVER['SERVER_ADDR'],5); - $ipv6 = explode(":",$_SERVER['REMOTE_ADDR'],5); - if(($ipv6_prefix[0] == $ipv6[0] && $ipv6_prefix[1] == $ipv6[1] && $ipv6_prefix[2] == $ipv6[2] && $ipv6_prefix[3] == $ipv6[3]) || str_starts_with($_SERVER['REMOTE_ADDR'],"192.168.147.")){ - $_SESSION["local"] =true; - return true; - }else{ +/* + * Liegt $ip im Netz $netz (CIDR-Schreibweise, IPv4 oder IPv6)? Eine Adresse + * ohne /Präfix gilt als einzelner Host. + */ +function ipImNetz($ip, $netz) +{ + if(!str_contains($netz, "/")) + $netz .= str_contains($netz, ":") ? "/128" : "/32"; + list($adresse, $praefix) = explode("/", $netz, 2); + $ipBin = @inet_pton($ip); + $netzBin = @inet_pton($adresse); + if($ipBin === false || $netzBin === false || !ctype_digit($praefix)) return false; + // IPv4 in IPv6-Schreibweise (::ffff:192.168.1.2) wie IPv4 behandeln + if(strlen($ipBin) == 16 && strlen($netzBin) == 4 && substr($ipBin, 0, 12) === str_repeat("\0", 10)."\xff\xff") + $ipBin = substr($ipBin, 12); + $praefix = (int)$praefix; + if(strlen($ipBin) != strlen($netzBin) || $praefix > strlen($ipBin) * 8) + return false; + $bytes = intdiv($praefix, 8); + if(substr($ipBin, 0, $bytes) !== substr($netzBin, 0, $bytes)) + return false; + $bits = $praefix % 8; + if($bits == 0) + return true; + $maske = (0xFF << (8 - $bits)) & 0xFF; + return (ord($ipBin[$bytes]) & $maske) == (ord($netzBin[$bytes]) & $maske); +} + +/* + * Kommt die Anfrage aus dem lokalen Netz? Davon hängen Passwort-Login und das + * Vergeben von Passkeys ab. + * + * Maßgeblich ist $lokale_netze aus der externen Config, z.B. + * $lokale_netze = array("192.168.179.0/24", "2001:db8:1:2::/64"); + * Fehlt die Liste, gelten nur Geräte im selben IPv6-/64-Netz wie der Server. + * + * Das Docker-Netz darf dort nicht stehen: Anfragen, die über den Docker- + * Proxy oder einen Reverse Proxy kommen, tragen dessen Adresse aus diesem + * Netz - dann wäre jeder Zugriff aus dem Internet "lokal". + */ +function isLocal(){ + global $lokale_netze; + $_SESSION["local"] = false; + + // Über einen Proxy weitergereicht: REMOTE_ADDR ist dann die Adresse des + // Proxys, nicht die des Browsers. Solche Anfragen sind nie lokal. + foreach(array("HTTP_X_FORWARDED_FOR", "HTTP_X_REAL_IP", "HTTP_FORWARDED", "HTTP_X_FORWARDED_HOST", "HTTP_CLIENT_IP") as $kopf){ + if(!empty($_SERVER[$kopf])) + return false; } + + $remote = $_SERVER["REMOTE_ADDR"] ?? ""; + $lokal = false; + if(is_array($lokale_netze)){ + foreach($lokale_netze as $netz){ + if(ipImNetz($remote, $netz)){ + $lokal = true; + break; + } + } + }else{ + $server = $_SERVER["SERVER_ADDR"] ?? ""; + $lokal = str_contains($server, ":") && !str_starts_with(strtolower($server), "fe80") && ipImNetz($remote, $server."/64"); + } + + $_SESSION["local"] = $lokal; + return $lokal; } function checkLogin(){ diff --git a/update/rebuild-settings.php b/update/rebuild-settings.php index dfc37ff..5a85b30 100644 --- a/update/rebuild-settings.php +++ b/update/rebuild-settings.php @@ -38,6 +38,7 @@ if(!file_put_contents (CONFIG_PATH.CONFIG_FILE, global $openweathermap_appid; global $aeris_client_id; global $aeris_client_secret; + global $lokale_netze; $instanz = '.var_export(INSTANZ, true).'; $dbconn = 0; @@ -60,6 +61,8 @@ if(!file_put_contents (CONFIG_PATH.CONFIG_FILE, $openweathermap_appid = ""; $aeris_client_id = ""; $aeris_client_secret = ""; + // Netze, aus denen Passwort-Login erlaubt ist (CIDR). Nie das Docker-Netz! + $lokale_netze = array(); ?>')) { $err= error("Konfiguration konnte nicht geschrieben werden!");