Compare commits

..
3 Commits
Author SHA1 Message Date
adminandClaude Opus 5 c59e7fb393 Sicherheit: AJAX-Endpunkte und Statistik nur mit Anmeldung
Keiner der AJAX-Endpunkte hat einen Login geprueft. Ohne Anmeldung liessen
sich Kunden, Adressen und Rechnungen durchsuchen, und ajax/autosave.php
setzt $_POST ungeprueft in SQL (SQL-Injection ohne Login).

- incs/connectmysql.php und ajax/auto/connectmysql.php, die alle
  AJAX-Endpunkte laden, antworten ohne gueltige Session mit 403
- stats.php verlangt eine Anmeldung
- db-backup.php und clean_db.php laufen nur noch per Kommandozeile;
  ueber den Webserver konnte jeder einen Datenbank-Dump anstossen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:30:30 +02:00
adminandClaude Opus 5 7dd6580bcc Sicherheit: Erkennung des lokalen Netzes ueber konfigurierte Netze
isLocal() hielt jede Adresse aus 192.168.147.* fuer lokal. Das ist das
Docker-Netz auf dem NAS: Anfragen ueber den Docker- oder einen Reverse
Proxy tragen dessen Adresse und waeren damit als lokal durchgegangen
(Passwort-Login und Passkey-Freigabe von aussen). Der IPv6-Vergleich per
explode(":") scheiterte ausserdem an komprimierten Adressen.

- lokale Netze stehen als CIDR-Liste $lokale_netze in der externen Config
- ipImNetz() vergleicht IPv4/IPv6 korrekt per inet_pton, inkl. ::ffff:-Form
- Anfragen mit Proxy-Headern gelten nie als lokal
- ohne Konfiguration nur noch dasselbe IPv6-/64 wie der Server, kein IPv4

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:17:00 +02:00
adminandClaude Opus 5 a6758ae63b Sicherheit: Passkey-Registrierung nur mit Freigabe durch Superadmin
authServer.php nahm getCreateArgs/processCreate ohne jede Pruefung an und
uebernahm die Benutzer-id aus der URL. Jeder konnte damit aus dem Internet
einen Passkey fuer ein beliebiges Konto (auch Superadmin) registrieren und
sich anschliessend anmelden.

- sites/userkeys.php loest den addKey ein und legt eine Freigabe in der
  Session ab (5 Minuten, ein Benutzer, einmalig); der addKey wird geloescht
- authServer.php verlangt fuer getCreateArgs/processCreate Login,
  Superadmin-Recht und diese Freigabe; die Benutzer-id kommt aus der
  Freigabe, nicht aus der URL
- queryFidoMetaDataService nur fuer Superadmins
- Passkey-Login verweigert gesperrte Benutzer und meldet keinen Erfolg
  mehr, wenn der Benutzer fehlt
- addKey.php verlangt wie userkeys.php das Superadmin-Recht
- session_regenerate_id() nach Passwort- und Passkey-Login
- userkeys.php/addKey.php: $_GET["id"] als int statt ungequotet in SQL

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:09:54 +02:00
12 changed files with 141 additions and 16 deletions
+3
View File
@@ -102,6 +102,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`. 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 ## 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. 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.
+4 -3
View File
@@ -16,7 +16,8 @@ if($settings == "valid")
if(!@mysqli_select_db($dbconn,$mysql_db)) if(!@mysqli_select_db($dbconn,$mysql_db))
$err = error("Datenbank nicht gefunden!! ($mysql_db)"); $err = error("Datenbank nicht gefunden!! ($mysql_db)");
} }
if($_SESSION["login"] == "valid" && isset($_GET["id"])){ // Wie sites/userkeys.php: nur Superadmins dürfen Passkeys vergeben
if($_SESSION["login"] == "valid" && isset($_GET["id"]) && ((int)substr($_SESSION["rights"], RIGHTS_SUPERADMIN, 1) & RIGHTS_CHANGE)){
if(!mysqli_query($dbconn,"DELETE FROM addKey WHERE datetime < DATE_SUB(NOW(), INTERVAL 1 MINUTE);")){ if(!mysqli_query($dbconn,"DELETE FROM addKey WHERE datetime < DATE_SUB(NOW(), INTERVAL 1 MINUTE);")){
echo mysqli_error($dbconn); echo mysqli_error($dbconn);
} }
@@ -25,8 +26,8 @@ if($_SESSION["login"] == "valid" && isset($_GET["id"])){
if(!mysqli_query($dbconn,"INSERT INTO addKey SET accesskey=".$accessKey.", datetime=NOW();")){ if(!mysqli_query($dbconn,"INSERT INTO addKey SET accesskey=".$accessKey.", datetime=NOW();")){
echo mysqli_error($dbconn); echo mysqli_error($dbconn);
} }
header('Location: https://wm.el-wa.org/kunden?action=userkeys&id='.$_GET["id"].'&addKey='.$accessKey); header('Location: https://wm.el-wa.org/kunden?action=userkeys&id='.(int)$_GET["id"].'&addKey='.$accessKey);
echo 'https://wm.el-wa.org/kunden?action=userkeys&id='.$_GET["id"].'&addKey='.$accessKey; echo 'https://wm.el-wa.org/kunden?action=userkeys&id='.(int)$_GET["id"].'&addKey='.$accessKey;
}else{ }else{
echo "Keine Berechtigung."; echo "Keine Berechtigung.";
} }
+6
View File
@@ -2,6 +2,12 @@
require_once("../../incs/constants.php"); require_once("../../incs/constants.php");
session_name(SESSION_NAME); session_name(SESSION_NAME);
session_start(); session_start();
// Alle AJAX-Endpunkte laden diese Datei: ohne Anmeldung gibt es keine Daten.
if(($_SESSION["login"] ?? "") !== "valid")
{
http_response_code(403);
exit("Nicht angemeldet.");
}
require_once("../../incs/settings.php"); require_once("../../incs/settings.php");
$settings=readsettings(); $settings=readsettings();
if($settings == "valid") if($settings == "valid")
+29 -3
View File
@@ -107,6 +107,24 @@ try {
// request for create arguments // request for create arguments
// ------------------------------------ // ------------------------------------
// Passkeys anlegen darf nur ein angemeldeter Superadmin, der vorher in
// sites/userkeys.php eine gültige Einmal-Freigabe (addKey) eingelöst hat.
// Die Freigabe liegt in der Session und legt fest, für welchen Benutzer
// der Schlüssel gilt - die id aus der URL zählt nicht.
$passkeyFreigabe = null;
if (isset($_SESSION["passkey_freigabe"]) && $_SESSION["passkey_freigabe"]["bis"] >= time()
&& ($_SESSION["login"] ?? "") == "valid"
&& ((int)substr($_SESSION["rights"] ?? "", RIGHTS_SUPERADMIN, 1) & RIGHTS_CHANGE)) {
$passkeyFreigabe = $_SESSION["passkey_freigabe"];
}
if (($fn === 'getCreateArgs' || $fn === 'processCreate') && $passkeyFreigabe === null) {
throw new Exception('Keine Berechtigung, Passkeys anzulegen.');
}
if ($fn === 'queryFidoMetaDataService'
&& !(($_SESSION["login"] ?? "") == "valid" && ((int)substr($_SESSION["rights"] ?? "", RIGHTS_SUPERADMIN, 1) & RIGHTS_CHANGE))) {
throw new Exception('Keine Berechtigung.');
}
if ($fn === 'getCreateArgs') { if ($fn === 'getCreateArgs') {
$createArgs = $WebAuthn->getCreateArgs(\hex2bin($userId), $userName, $userDisplayName, 60*4, $requireResidentKey, $userVerification, $crossPlatformAttachment); $createArgs = $WebAuthn->getCreateArgs(\hex2bin($userId), $userName, $userDisplayName, 60*4, $requireResidentKey, $userVerification, $crossPlatformAttachment);
@@ -181,10 +199,12 @@ try {
if (!isset($_SESSION['registrations']) || !array_key_exists('registrations', $_SESSION) || !is_array($_SESSION['registrations'])) { if (!isset($_SESSION['registrations']) || !array_key_exists('registrations', $_SESSION) || !is_array($_SESSION['registrations'])) {
$_SESSION['registrations'] = []; $_SESSION['registrations'] = [];
}*/ }*/
if(!mysqli_query($dbconn,"INSERT INTO passkeys SET credentialId = '".base64_encode($data->credentialId)."', credentialPublicKey = '".base64_encode($data->credentialPublicKey)."', signatureCounter = '".base64_encode($data->signatureCounter)."', name = '".mysqli_real_escape_string($mysql,filter_input(INPUT_GET, 'name'))."', userId = '".mysqli_real_escape_string($mysql,filter_input(INPUT_GET, 'id'))."';")){ if(!mysqli_query($dbconn,"INSERT INTO passkeys SET credentialId = '".base64_encode($data->credentialId)."', credentialPublicKey = '".base64_encode($data->credentialPublicKey)."', signatureCounter = '".base64_encode($data->signatureCounter)."', name = '".mysqli_real_escape_string($mysql,filter_input(INPUT_GET, 'name'))."', userId = '".(int)$passkeyFreigabe["user"]."';")){
$msg = "Error:<br>".mysqli_error($dbconn)."<br />"; $msg = "Error:<br>".mysqli_error($dbconn)."<br />";
} }
else{ else{
// Freigabe gilt für genau einen Schlüssel
unset($_SESSION["passkey_freigabe"]);
if ($data->rootValid === false) { if ($data->rootValid === false) {
$msg = 'registration ok, but certificate does not match any of the selected root ca. NO PROBLEM FOR THIS APP'; $msg = 'registration ok, but certificate does not match any of the selected root ca. NO PROBLEM FOR THIS APP';
} }
@@ -253,10 +273,16 @@ try {
$authKey = strval(random_int(0,99999999)); $authKey = strval(random_int(0,99999999));
$result = mysqli_query($dbconn,"UPDATE passkeys SET authKey=".$authKey.", lastAuth=NOW() WHERE credentialId = '".base64_encode($id)."';"); $result = mysqli_query($dbconn,"UPDATE passkeys SET authKey=".$authKey.", lastAuth=NOW() WHERE credentialId = '".base64_encode($id)."';");
$qry= mysqli_query($dbconn, "SELECT * FROM user WHERE id = '".mysqli_real_escape_string($dbconn,$userId)."'"); // Gesperrte Benutzer bleiben gesperrt, auch mit gültigem Passkey
if(mysqli_num_rows($qry) == 1) $qry= mysqli_query($dbconn, "SELECT * FROM user WHERE id = '".mysqli_real_escape_string($dbconn,$userId)."' AND blocked = 0");
if(!$qry || mysqli_num_rows($qry) != 1)
{
throw new Exception('Benutzer nicht gefunden oder gesperrt.');
}
else
{ {
$udata = mysqli_fetch_assoc($qry); $udata = mysqli_fetch_assoc($qry);
session_regenerate_id(true);
$_SESSION["login"] = "valid"; $_SESSION["login"] = "valid";
$_SESSION["logedout"] = $udata["logedout"]; $_SESSION["logedout"] = $udata["logedout"];
$_SESSION["user"] = $udata["id"]; $_SESSION["user"] = $udata["id"];
+3
View File
@@ -1,4 +1,7 @@
<?php <?php
// Wartungsskript: nur per Kommandozeile, nie über den Webserver
if(PHP_SAPI != "cli")
exit;
require_once("./incs/constants.php"); require_once("./incs/constants.php");
session_name(SESSION_NAME); session_name(SESSION_NAME);
session_start(); session_start();
+3
View File
@@ -1,4 +1,7 @@
<?php <?php
// Nur per Kommandozeile (Cronjob), nie über den Webserver
if(PHP_SAPI != "cli")
exit;
/* /*
* Datenbank-Backup (Cronjob). * Datenbank-Backup (Cronjob).
* *
+66 -7
View File
@@ -16,15 +16,74 @@ if($settings == "valid")
$_SESSION["local"] =false; $_SESSION["local"] =false;
function isLocal(){ /*
$ipv6_prefix = explode(":",$_SERVER['SERVER_ADDR'],5); * Liegt $ip im Netz $netz (CIDR-Schreibweise, IPv4 oder IPv6)? Eine Adresse
$ipv6 = explode(":",$_SERVER['REMOTE_ADDR'],5); * ohne /Präfix gilt als einzelner Host.
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; function ipImNetz($ip, $netz)
return true; {
}else{ 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; 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(){ function checkLogin(){
+6
View File
@@ -2,6 +2,12 @@
require_once("../incs/constants.php"); require_once("../incs/constants.php");
session_name(SESSION_NAME); session_name(SESSION_NAME);
session_start(); session_start();
// Alle AJAX-Endpunkte laden diese Datei: ohne Anmeldung gibt es keine Daten.
if(($_SESSION["login"] ?? "") !== "valid")
{
http_response_code(403);
exit("Nicht angemeldet.");
}
require_once("../incs/settings.php"); require_once("../incs/settings.php");
$settings=readsettings(); $settings=readsettings();
if($settings == "valid") if($settings == "valid")
+2
View File
@@ -20,6 +20,8 @@ if(isset($_GET["formsent"]) && $_SESSION["login"] != "valid" && isLocal())
if(mysqli_num_rows($qry) == 1) if(mysqli_num_rows($qry) == 1)
{ {
$udata = mysqli_fetch_assoc($qry); $udata = mysqli_fetch_assoc($qry);
// neue Session-ID nach dem Login, sonst ist Session-Fixation möglich
session_regenerate_id(true);
$_SESSION["login"] = "valid"; $_SESSION["login"] = "valid";
$_SESSION["logedout"] = $udata["logedout"]; $_SESSION["logedout"] = $udata["logedout"];
$_SESSION["user"] = $udata["id"]; $_SESSION["user"] = $udata["id"];
+8 -3
View File
@@ -2,7 +2,12 @@
if(isset($_GET["id"]) && isset($_GET["addKey"])){ if(isset($_GET["id"]) && isset($_GET["addKey"])){
if (checkAdduser($_GET["addUser"])) { if (checkAdduser()) {
// Freigabe einlösen: gilt 5 Minuten, nur für diesen Benutzer und nur
// in dieser Session (geprüft in authServer.php). Der addKey ist damit
// verbraucht.
$_SESSION["passkey_freigabe"] = array("user" => (int)$_GET["id"], "bis" => time() + 300);
mysqli_query($mysql, "DELETE FROM addKey WHERE accesskey = '".mysqli_real_escape_string($mysql, $_GET["addKey"])."'");
echo <<<ENDE echo <<<ENDE
<html> <html>
<head></head> <head></head>
@@ -73,11 +78,11 @@ if(isset($_GET["id"]) && isset($_GET["addKey"])){
$smarty->assign("heading","Passkeys für externen Zugang verwalten"); $smarty->assign("heading","Passkeys für externen Zugang verwalten");
$smarty->display("heading.tpl"); $smarty->display("heading.tpl");
$qry = mysqli_query($_SESSION["dbconn"],"SELECT id, vname, nname FROM user WHERE blocked = 0 AND user.id = ".mysqli_real_escape_string($_SESSION["dbconn"],$_GET["id"])) or die("Datenbank fehler: ".mysqli_error($_SESSION["dbconn"])); $qry = mysqli_query($_SESSION["dbconn"],"SELECT id, vname, nname FROM user WHERE blocked = 0 AND user.id = ".(int)$_GET["id"]) or die("Datenbank fehler: ".mysqli_error($_SESSION["dbconn"]));
$row=mysqli_fetch_array($qry); $row=mysqli_fetch_array($qry);
$smarty->assign("user",$row); $smarty->assign("user",$row);
$qry = mysqli_query($_SESSION["dbconn"],"SELECT passkeys.id , passkeys.name FROM user JOIN passkeys ON user.id = passkeys.userId WHERE blocked = 0 AND user.id = ".mysqli_real_escape_string($_SESSION["dbconn"],$_GET["id"])) or die("Datenbank fehler: ".mysqli_error($_SESSION["dbconn"])); $qry = mysqli_query($_SESSION["dbconn"],"SELECT passkeys.id , passkeys.name FROM user JOIN passkeys ON user.id = passkeys.userId WHERE blocked = 0 AND user.id = ".(int)$_GET["id"]) or die("Datenbank fehler: ".mysqli_error($_SESSION["dbconn"]));
$keys = array(); $keys = array();
while($row=mysqli_fetch_array($qry)){ while($row=mysqli_fetch_array($qry)){
$keys[] = $row; $keys[] = $row;
+8
View File
@@ -1,4 +1,12 @@
<?php <?php
require_once("incs/constants.php");
session_name(SESSION_NAME);
session_start();
if((($_SESSION["login"] ?? "") !== "valid"))
{
http_response_code(403);
exit("Nicht angemeldet.");
}
error_reporting(E_ERROR | E_WARNING | E_PARSE); error_reporting(E_ERROR | E_WARNING | E_PARSE);
// Standard inclusions // Standard inclusions
+3
View File
@@ -38,6 +38,7 @@ if(!file_put_contents (CONFIG_PATH.CONFIG_FILE,
global $openweathermap_appid; global $openweathermap_appid;
global $aeris_client_id; global $aeris_client_id;
global $aeris_client_secret; global $aeris_client_secret;
global $lokale_netze;
$instanz = '.var_export(INSTANZ, true).'; $instanz = '.var_export(INSTANZ, true).';
$dbconn = 0; $dbconn = 0;
@@ -60,6 +61,8 @@ if(!file_put_contents (CONFIG_PATH.CONFIG_FILE,
$openweathermap_appid = ""; $openweathermap_appid = "";
$aeris_client_id = ""; $aeris_client_id = "";
$aeris_client_secret = ""; $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!"); $err= error("Konfiguration konnte nicht geschrieben werden!");