fix(releases): Lizenzschluessel nicht mehr im Klartext, Selbsttest, Zielorte
Vier Befunde aus einer externen Durchsicht der 2.4-Integration.
1. Die .htpasswd war eine Klartext-Kundenliste
Das htpasswd-Format hasht nur die Passwortspalte. Benutzername UND Passwort
waren der Lizenzschluessel - der Schluessel stand also im Klartext direkt
neben seinem eigenen bcrypt-Hash, und der Hash war Dekoration. Geschuetzt
hat das Ganze nur die FilesMatch-Regel in derselben Datei.
Der Benutzername wird jetzt abgeleitet: lic_<sha256(schluessel), 16 Hex>.
Die Datei enthaelt damit nur noch eine Einwegableitung und einen Hash ueber
einen hochentropen Schluessel.
Server und SDK muessen dabei zeichengenau uebereinstimmen; ein Test prueft
die C#-Ableitung gegen die PHP-Formel.
2. Ein Formatwechsel blieb unbemerkt liegen
Beim Umbau auf 1. faellt auf: reconcile() sah keinen Anlass zur
Neuerzeugung, die Dateien behielten das alte Format, waehrend die Clients
bereits das neue schickten. Die erzeugten Dateien tragen deshalb jetzt eine
Formatkennung; weicht sie ab, wird neu erzeugt.
3. Doku beschrieb Nginx, der Schutz ist Apache-only
.htaccess wird von Nginx ignoriert - dort waeren die Verzeichnisse offen und
die .htpasswd oeffentlich abrufbar. Die Statusanzeige pruefte nur, ob die
Dateien existieren, und haette in dem Fall "GESCHUETZT" gemeldet.
Neu: ein echter Selbsttest ruft die eigene Paket-Adresse OHNE Zugangsdaten
ab und erwartet 401. Er laeuft beim manuellen Erzeugen und nach jeder
automatischen Neuerzeugung; das Ergebnis steht in der Oberflaeche, ein
Fehlschlag im Log. Er findet nebenbei auch abgeschaltetes AllowOverride und
Tippfehler in der erzeugten Datei. Doku korrigiert, Nginx-Vorlage ergaenzt.
4. Erstinstallation schrieb an einen Ort, an dem Linux-Anwendungen nicht lesen
setup.json-Ziele waren immer installationsrelativ. Eine Anwendung, die sich
unter Linux richtig verhaelt, liest aus $XDG_CONFIG_HOME - /opt/<app> ist
fuer den Dienstbenutzer meist nicht schreibbar. Der Installer legte die
Datei also dorthin, wo nie jemand nachsieht.
Ziele haben jetzt ein "location": install (Vorgabe), config, data, home,
plus ${VAR}- und %VAR%-Ersetzung in "file". Unbekannte Variablen bleiben
stehen statt leer zu werden - ein Platzhalter faellt auf, ein falscher Pfad
nicht. Der Installer gibt den aufgeloesten Pfad aus, weil bei config das
Konto entscheidet, unter dem er laeuft.
Ausserdem
- Doku zeigte "status": "ok" fuer update/delete; Http::ok() erzeugt
"status": "success".
- UPGRADE §16.1 deckte Neuprodukte nicht ab: Fuer ein Produkt ohne Release
existiert /releases/<slug>/ nicht und wird uebersprungen. Das Verzeichnis
entsteht erst mit dem ersten Upload, der naechste Tick schuetzt es. Der erste
ausgelieferte Build muss die Zugangsdaten also schon mitbringen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ceb977187e
commit
7a3a5dad69
@@ -47,6 +47,18 @@ final class ReleaseGuard
|
||||
/** Von Apache verstandene Hash-Praefixe. */
|
||||
private const APACHE_BCRYPT_PREFIXES = ['$2y$', '$2a$', '$2b$'];
|
||||
|
||||
/**
|
||||
* Leitet den Basic-Auth-Benutzernamen aus einem Lizenzschluessel ab.
|
||||
*
|
||||
* MUSS zeichengenau mit ReleaseCredentials.FromLicenseKey() im .NET-SDK
|
||||
* uebereinstimmen - weichen die beiden voneinander ab, kommt niemand mehr
|
||||
* an seine Updates.
|
||||
*/
|
||||
public static function licenseUsername(string $licenseKey): string
|
||||
{
|
||||
return 'lic_' . substr(hash('sha256', trim($licenseKey)), 0, 16);
|
||||
}
|
||||
|
||||
/** Wurzelverzeichnis der Releases im Dateisystem. */
|
||||
public static function releasesRoot(): string
|
||||
{
|
||||
@@ -103,10 +115,15 @@ final class ReleaseGuard
|
||||
}
|
||||
}
|
||||
|
||||
// Auch der Weg ueber die Oberflaeche vermerkt das Format - sonst
|
||||
// sieht der naechste Tick eine Formataenderung, die es nicht gibt.
|
||||
self::writeSetting($db, self::FORMAT_KEY, (string)self::FORMAT_VERSION);
|
||||
|
||||
Logger::info('Release-Zugangsschutz neu erzeugt', [
|
||||
'projects' => $result['projects'],
|
||||
'entries' => $result['entries'],
|
||||
'errors' => count($result['errors']),
|
||||
'format' => self::FORMAT_VERSION,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
@@ -165,9 +182,16 @@ final class ReleaseGuard
|
||||
|
||||
// 1. Gueltige Lizenzen dieses Produkts.
|
||||
//
|
||||
// Benutzername und Passwort sind beide der Lizenzschluessel: Basic Auth
|
||||
// verlangt zwei Felder, es gibt aber nur ein Geheimnis, und
|
||||
// Benutzernamen muessen eindeutig sein.
|
||||
// Der Benutzername wird aus dem Schluessel ABGELEITET, er ist nicht der
|
||||
// Schluessel selbst. Das htpasswd-Format hasht nur die Passwortspalte;
|
||||
// stuende der Schluessel als Benutzername daneben, waere die Datei eine
|
||||
// vollstaendige Klartext-Kundenliste und der bcrypt-Hash daneben blosse
|
||||
// Dekoration. So enthaelt sie nur eine Einwegableitung und einen Hash
|
||||
// ueber einen hochentropen Schluessel - selbst offengelegt ist damit
|
||||
// nichts anzufangen.
|
||||
//
|
||||
// Die Ableitung muss zeichengenau der clientseitigen in
|
||||
// ReleaseCredentials.FromLicenseKey() entsprechen.
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT l.license_key
|
||||
@@ -185,7 +209,8 @@ final class ReleaseGuard
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[$key] = password_hash($key, PASSWORD_BCRYPT, ['cost' => self::LICENSE_HASH_COST]);
|
||||
$entries[self::licenseUsername($key)] =
|
||||
password_hash($key, PASSWORD_BCRYPT, ['cost' => self::LICENSE_HASH_COST]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Logger::warning('Lizenzen fuer den Release-Schutz nicht lesbar', [
|
||||
@@ -304,6 +329,171 @@ final class ReleaseGuard
|
||||
/** Schluessel in dc_settings, unter dem der letzte Lauf vermerkt wird. */
|
||||
private const LAST_RUN_KEY = 'release_guard_last_run';
|
||||
|
||||
/** Schluessel in dc_settings fuer das Ergebnis des letzten Selbsttests. */
|
||||
private const SELFTEST_KEY = 'release_guard_selftest';
|
||||
|
||||
/** Schluessel in dc_settings fuer das Format der erzeugten Dateien. */
|
||||
private const FORMAT_KEY = 'release_guard_format';
|
||||
|
||||
/**
|
||||
* Format der erzeugten .htpasswd. Bei einer Aenderung muessen alle Dateien
|
||||
* neu geschrieben werden, sonst passen Server und Client nicht mehr
|
||||
* zusammen.
|
||||
*
|
||||
* 1 - Benutzername = Lizenzschluessel im Klartext
|
||||
* 2 - Benutzername = lic_<sha256(schluessel), 16 Hexzeichen>
|
||||
*
|
||||
* Ohne diese Kennung waere der Umstieg auf 2 unbemerkt liegengeblieben:
|
||||
* reconcile() sah keinen Anlass, und die Dateien behielten bis zum
|
||||
* naechsten turnusmaessigen Lauf das alte Format - waehrend die Clients
|
||||
* bereits das neue schicken.
|
||||
*/
|
||||
private const FORMAT_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Prueft, ob der Schutz tatsaechlich greift - durch einen echten Abruf.
|
||||
*
|
||||
* Dass .htaccess und .htpasswd vorhanden sind, beweist nichts. Unter Nginx
|
||||
* werden .htaccess-Dateien vollstaendig ignoriert; die Verzeichnisse
|
||||
* waeren dann offen und die .htpasswd oeffentlich abrufbar, waehrend die
|
||||
* Oberflaeche "geschuetzt" meldet. Dasselbe gilt bei abgeschaltetem
|
||||
* AllowOverride oder einem Tippfehler in der erzeugten Datei.
|
||||
*
|
||||
* Nur ein Abruf der eigenen Paket-Adresse ohne Zugangsdaten beantwortet
|
||||
* die Frage, und der findet alle drei Faelle auf einmal.
|
||||
*
|
||||
* @return array{checked:int,ok:int,failed:list<array<string,mixed>>,ran_at:string}
|
||||
*/
|
||||
public static function selfTest(PDO $db, ?string $slug = null): array
|
||||
{
|
||||
$baseUrl = rtrim((string)\Deploymentcenter\Core\Config::get('app.url', ''), '/');
|
||||
|
||||
$result = ['checked' => 0, 'ok' => 0, 'failed' => [], 'ran_at' => gmdate('Y-m-d H:i:s')];
|
||||
|
||||
if ($baseUrl === '') {
|
||||
$result['failed'][] = ['slug' => '-', 'reason' => 'app.url ist nicht konfiguriert.'];
|
||||
return $result;
|
||||
}
|
||||
|
||||
$sql = '
|
||||
SELECT product_slug, download_url
|
||||
FROM updateservice_releases
|
||||
WHERE download_url <> ""
|
||||
';
|
||||
$params = [];
|
||||
|
||||
if ($slug !== null && $slug !== '') {
|
||||
$sql .= ' AND product_slug = :slug';
|
||||
$params[':slug'] = $slug;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY product_slug ASC, created_at DESC';
|
||||
|
||||
try {
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll() ?: [];
|
||||
} catch (\Throwable $e) {
|
||||
$result['failed'][] = ['slug' => '-', 'reason' => 'Releases nicht lesbar: ' . $e->getMessage()];
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Je Produkt genuegt eine Adresse.
|
||||
$seen = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$product = (string)$row['product_slug'];
|
||||
if (isset($seen[$product])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$product] = true;
|
||||
|
||||
$url = (string)$row['download_url'];
|
||||
$result['checked']++;
|
||||
|
||||
$status = self::probe($url);
|
||||
|
||||
if ($status === 401) {
|
||||
$result['ok']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$result['failed'][] = [
|
||||
'slug' => $product,
|
||||
'status' => $status,
|
||||
'url' => $url,
|
||||
'reason' => $status === 200
|
||||
? 'Das Paket ist OHNE Zugangsdaten abrufbar. Wird .htaccess ausgewertet? '
|
||||
. 'Unter Nginx wirkungslos, unter Apache AllowOverride pruefen.'
|
||||
: sprintf('Unerwarteter Status %s - erwartet wurde 401.', $status === null ? 'keiner' : (string)$status),
|
||||
];
|
||||
}
|
||||
|
||||
self::writeSetting($db, self::SELFTEST_KEY, json_encode($result) ?: '');
|
||||
|
||||
Logger::info('Release-Selbsttest', [
|
||||
'checked' => $result['checked'],
|
||||
'ok' => $result['ok'],
|
||||
'failed' => count($result['failed']),
|
||||
]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Ergebnis des letzten Selbsttests, oder null. */
|
||||
public static function lastSelfTest(PDO $db): ?array
|
||||
{
|
||||
$raw = self::readSetting($db, self::SELFTEST_KEY);
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft eine Adresse ohne Zugangsdaten ab und liefert den Statuscode.
|
||||
* Bewusst nur der Kopf der Antwort - es geht um den Status, nicht um
|
||||
* mehrere Megabyte Paket.
|
||||
*/
|
||||
private static function probe(string $url): ?int
|
||||
{
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
// Bewusst kein Zugangsdaten-Header: geprueft wird ja gerade,
|
||||
// ob der Abruf ohne scheitert.
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $status > 0 ? $status : null;
|
||||
}
|
||||
|
||||
$context = stream_context_create(['http' => [
|
||||
'method' => 'HEAD',
|
||||
'timeout' => 10,
|
||||
'ignore_errors' => true,
|
||||
]]);
|
||||
|
||||
@file_get_contents($url, false, $context);
|
||||
|
||||
foreach ($http_response_header ?? [] as $header) {
|
||||
if (preg_match('#^HTTP/\S+\s+(\d{3})#', $header, $m) === 1) {
|
||||
return (int)$m[1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuer den Cron gedacht: erzeugt nur dann neu, wenn es noetig ist.
|
||||
*
|
||||
@@ -325,6 +515,8 @@ final class ReleaseGuard
|
||||
|
||||
if ($lastRun === null) {
|
||||
$reason = 'noch nie gelaufen';
|
||||
} elseif ((int)(self::readSetting($db, self::FORMAT_KEY) ?? '1') !== self::FORMAT_VERSION) {
|
||||
$reason = 'Dateiformat geaendert';
|
||||
} elseif (self::hasExpiredSince($db, $lastRun)) {
|
||||
$reason = 'Lizenz abgelaufen';
|
||||
} elseif ((time() - strtotime($lastRun . ' UTC')) >= $maxAge) {
|
||||
@@ -366,10 +558,21 @@ final class ReleaseGuard
|
||||
}
|
||||
|
||||
private static function readLastRun(PDO $db): ?string
|
||||
{
|
||||
return self::readSetting($db, self::LAST_RUN_KEY);
|
||||
}
|
||||
|
||||
private static function writeLastRun(PDO $db): void
|
||||
{
|
||||
self::writeSetting($db, self::LAST_RUN_KEY, gmdate('Y-m-d H:i:s'));
|
||||
self::writeSetting($db, self::FORMAT_KEY, (string)self::FORMAT_VERSION);
|
||||
}
|
||||
|
||||
private static function readSetting(PDO $db, string $key): ?string
|
||||
{
|
||||
try {
|
||||
$stmt = $db->prepare('SELECT svalue FROM dc_settings WHERE skey = :k LIMIT 1');
|
||||
$stmt->execute([':k' => self::LAST_RUN_KEY]);
|
||||
$stmt->execute([':k' => $key]);
|
||||
$value = $stmt->fetchColumn();
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
} catch (\Throwable $e) {
|
||||
@@ -377,16 +580,16 @@ final class ReleaseGuard
|
||||
}
|
||||
}
|
||||
|
||||
private static function writeLastRun(PDO $db): void
|
||||
private static function writeSetting(PDO $db, string $key, string $value): void
|
||||
{
|
||||
try {
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO dc_settings (skey, svalue) VALUES (:k, :v)
|
||||
ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)
|
||||
');
|
||||
$stmt->execute([':k' => self::LAST_RUN_KEY, ':v' => gmdate('Y-m-d H:i:s')]);
|
||||
$stmt->execute([':k' => $key, ':v' => $value]);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::warning('Zeitpunkt des Release-Schutzes nicht vermerkt', ['error' => $e->getMessage()]);
|
||||
Logger::warning('Einstellung nicht gespeichert', ['key' => $key, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user