Browse Source

Security: generic order-success copy, file rate limits, HSTS/CSP, canonical mail origin

Co-authored-by: Cursor <cursoragent@cursor.com>
Josef Straßl 3 tuần trước cách đây
mục cha
commit
ec38e6151b
7 tập tin đã thay đổi với 327 bổ sung40 xóa
  1. 7 0
      .htaccess
  2. 23 16
      admin/login.php
  3. 4 1
      checkout.php
  4. 9 0
      config.sample.php
  5. 5 0
      docs/CONFIG_REFERENCE.md
  6. 278 22
      includes/functions.php
  7. 1 1
      order-success.php

+ 7 - 0
.htaccess

@@ -1,11 +1,18 @@
 Options -Indexes
 
+<IfModule mod_setenvif.c>
+    SetEnvIf HTTPS "on" HTTPS_ON=1
+    SetEnvIf X-Forwarded-Proto "^https$" HTTPS_ON=1
+</IfModule>
+
 <IfModule mod_headers.c>
     Header always set X-Content-Type-Options "nosniff"
     Header always set X-Frame-Options "SAMEORIGIN"
     Header always set Referrer-Policy "strict-origin-when-cross-origin"
     Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
     Header always set Cross-Origin-Resource-Policy "same-origin"
+    Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; object-src 'none'; upgrade-insecure-requests"
+    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" env=HTTPS_ON
 </IfModule>
 
 <IfModule mod_rewrite.c>

+ 23 - 16
admin/login.php

@@ -33,23 +33,30 @@ if ($_SERVER['REQUEST_METHOD'] === "POST") {
     if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
         $error = "Ungültiges Token. Bitte versuchen Sie es erneut.";
     } else {
-        $username = normalizeAdminUsername($_POST['username'] ?? "");
-        $password = $_POST['password'] ?? "";
-
-        $users = getAdminUsers();
-        if (
-            isset($users[$username]) &&
-            password_verify($password, $users[$username])
-        ) {
-            session_regenerate_id(true);
-            $_SESSION['admin_logged_in'] = true;
-            $_SESSION['admin_username'] = $username;
-            logAccess("Admin login successful", ["username" => $username]);
-            header("Location: index.php");
-            exit();
+        if (isAdminLoginRateLimited()) {
+            $error =
+                "Zu viele fehlgeschlagene Anmeldeversuche. Bitte versuchen Sie es später erneut.";
         } else {
-            logAccess("Admin login failed", ["username" => $username]);
-            $error = "Benutzername oder Passwort falsch.";
+            $username = normalizeAdminUsername($_POST["username"] ?? "");
+            $password = $_POST["password"] ?? "";
+
+            $users = getAdminUsers();
+            if (
+                isset($users[$username]) &&
+                password_verify($password, $users[$username])
+            ) {
+                clearAdminLoginRateLimitForCurrentIp();
+                session_regenerate_id(true);
+                $_SESSION["admin_logged_in"] = true;
+                $_SESSION["admin_username"] = $username;
+                logAccess("Admin login successful", ["username" => $username]);
+                header("Location: index.php");
+                exit();
+            } else {
+                recordAdminLoginFailure();
+                logAccess("Admin login failed", ["username" => $username]);
+                $error = "Benutzername oder Passwort falsch.";
+            }
         }
     }
 }

+ 4 - 1
checkout.php

@@ -35,11 +35,14 @@ if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['create_order'])) {
         $validOrgIds = array_column($organizations, "id");
 
         if (!in_array($organizationId, $validOrgIds, true)) {
-            $validator->errors[] = "Die gewählte Organisation ist ungültig.";
+            $validator->addError("Die gewählte Organisation ist ungültig.");
         }
 
         if (!$validator->isValid()) {
             $errors = array_merge($errors, $validator->getErrors());
+        } elseif (!checkoutRateLimitTryConsume()) {
+            $errors[] =
+                "Zu viele Bestellversuche von dieser Verbindung. Bitte versuchen Sie es später erneut.";
         } else {
             $customerName = trim($_POST['customer_name']);
             $customerEmail = trim(strtolower($_POST['customer_email']));

+ 9 - 0
config.sample.php

@@ -12,6 +12,15 @@ define('SITE_PRIVACY_URL', 'https://www.freising.de/datenschutz');
 define('SITE_FULL_NAME', SITE_NAME . ' - ' . SITE_SERVICE_HEADER);
 define('SITE_URL', '/shop'); // Leave empty for root, or use absolute URL
 
+// Optional: scheme + host only (no path) for absolute links in e-mails when HTTP_HOST is wrong behind proxies.
+// define('SITE_CANONICAL_ORIGIN', 'https://www.example.org');
+
+// Optional: file-based rate limits (see includes/functions.php). Defaults apply if omitted.
+// define('ADMIN_LOGIN_RATE_LIMIT_MAX', 10);
+// define('ADMIN_LOGIN_RATE_LIMIT_WINDOW', 900);
+// define('CHECKOUT_RATE_LIMIT_MAX', 30);
+// define('CHECKOUT_RATE_LIMIT_WINDOW', 3600);
+
 define('DISCLAIMER_LINES', [
     'Dieses System dient der internen Bestellung persönlicher Schutzausrüstung der Stadt Freising.',
     'Die Bearbeitung erfolgt durch Amt 32 - Öffentliche Sicherheit und Ordnung.',

+ 5 - 0
docs/CONFIG_REFERENCE.md

@@ -13,6 +13,11 @@
 | `SITE_PRIVACY_URL` | Ziel-URL für den Datenschutzlink |
 | `SITE_FULL_NAME` | Kombinierter Anzeigename aus Marke und Service-Header |
 | `SITE_URL` | Basispfad oder Basis-URL für Links, Assets und Bestätigungslinks |
+| `SITE_CANONICAL_ORIGIN` | Optional: `https://hostname` (ohne Pfad) für absolute E-Mail-Links, falls `HTTP_HOST` unzuverlässig ist |
+| `ADMIN_LOGIN_RATE_LIMIT_MAX` | Optional: max. fehlgeschlagene Admin-Logins pro IP und Zeitfenster (Standard: 10) |
+| `ADMIN_LOGIN_RATE_LIMIT_WINDOW` | Optional: Zeitfenster in Sekunden für Admin-Login-Limit (Standard: 900) |
+| `CHECKOUT_RATE_LIMIT_MAX` | Optional: max. Checkout-Versuche pro IP und Fenster nach erfolgreicher Formularvalidierung (Standard: 30) |
+| `CHECKOUT_RATE_LIMIT_WINDOW` | Optional: Zeitfenster in Sekunden für Checkout-Limit (Standard: 3600) |
 | `DISCLAIMER_LINES` | Hinweistext auf der Startseite |
 | `ORDER_PREFIX` | Präfix für Bestellnummern |
 | `ORDER_RECIPIENT_EMAIL` | Standard-Empfänger für interne Bestellmails |

+ 278 - 22
includes/functions.php

@@ -90,6 +90,263 @@ function csrfField()
         '">';
 }
 
+/**
+ * File-based rate limiting (per client IP) for shared hosting without Redis.
+ * State lives under DATA_DIR/ratelimit/ (not web-accessible with default .htaccess).
+ */
+function getRateLimitClientIp(): string
+{
+    $ip = trim((string) ($_SERVER["REMOTE_ADDR"] ?? ""));
+    return $ip !== "" ? $ip : "unknown";
+}
+
+function getRateLimitStatePath(string $name): ?string
+{
+    $name = preg_replace("/[^a-z0-9_-]/", "", strtolower($name));
+    if ($name === "") {
+        $name = "default";
+    }
+    $dir = rtrim(DATA_DIR, "/\\") . "/ratelimit";
+    return $dir . "/" . $name . ".json";
+}
+
+/**
+ * @return bool true if under limit (and counter updated), false if blocked
+ */
+function rateLimitTryConsume(string $name, int $maxAttempts, int $windowSeconds): bool
+{
+    if ($maxAttempts < 1 || $windowSeconds < 1) {
+        return true;
+    }
+
+    $path = getRateLimitStatePath($name);
+    if ($path === null) {
+        return true;
+    }
+
+    $dir = dirname($path);
+    if (
+        !is_dir($dir) &&
+        !@mkdir($dir, 02775, true) &&
+        !is_dir($dir)
+    ) {
+        return true;
+    }
+    if (is_dir($dir)) {
+        @chmod($dir, 02775);
+    }
+
+    $fh = fopen($path, "c+");
+    if ($fh === false) {
+        return true;
+    }
+
+    if (!flock($fh, LOCK_EX)) {
+        fclose($fh);
+        return true;
+    }
+
+    rewind($fh);
+    $raw = stream_get_contents($fh);
+    $data = [];
+    if (is_string($raw) && trim($raw) !== "") {
+        $decoded = json_decode($raw, true);
+        if (is_array($decoded)) {
+            $data = $decoded;
+        }
+    }
+
+    $now = time();
+    $ip = getRateLimitClientIp();
+
+    foreach ($data as $key => $entry) {
+        if (!is_array($entry)) {
+            unset($data[$key]);
+            continue;
+        }
+        $w = (int) ($entry["w"] ?? 0);
+        if ($now - $w > $windowSeconds * 2) {
+            unset($data[$key]);
+        }
+    }
+
+    $entry = isset($data[$ip]) && is_array($data[$ip]) ? $data[$ip] : null;
+    $windowStart = (int) ($entry["w"] ?? $now);
+    $count = (int) ($entry["c"] ?? 0);
+
+    if ($entry === null || $now - $windowStart > $windowSeconds) {
+        $windowStart = $now;
+        $count = 0;
+    }
+
+    if ($count >= $maxAttempts) {
+        flock($fh, LOCK_UN);
+        fclose($fh);
+        return false;
+    }
+
+    $count++;
+    $data[$ip] = ["w" => $windowStart, "c" => $count];
+
+    $payload = json_encode($data, JSON_UNESCAPED_UNICODE);
+    if ($payload === false) {
+        flock($fh, LOCK_UN);
+        fclose($fh);
+        return true;
+    }
+
+    ftruncate($fh, 0);
+    rewind($fh);
+    fwrite($fh, $payload);
+    fflush($fh);
+    @chmod($path, 0660);
+
+    flock($fh, LOCK_UN);
+    fclose($fh);
+
+    return true;
+}
+
+function rateLimitClearIp(string $name, ?string $ip = null): void
+{
+    $path = getRateLimitStatePath($name);
+    if ($path === null || !is_file($path)) {
+        return;
+    }
+
+    $ip = $ip ?? getRateLimitClientIp();
+    $fh = fopen($path, "c+");
+    if ($fh === false) {
+        return;
+    }
+    if (!flock($fh, LOCK_EX)) {
+        fclose($fh);
+        return;
+    }
+
+    rewind($fh);
+    $raw = stream_get_contents($fh);
+    $data = [];
+    if (is_string($raw) && trim($raw) !== "") {
+        $decoded = json_decode($raw, true);
+        if (is_array($decoded)) {
+            $data = $decoded;
+        }
+    }
+
+    unset($data[$ip]);
+
+    $payload = json_encode($data, JSON_UNESCAPED_UNICODE);
+    if ($payload !== false) {
+        ftruncate($fh, 0);
+        rewind($fh);
+        fwrite($fh, $payload);
+        fflush($fh);
+    }
+
+    flock($fh, LOCK_UN);
+    fclose($fh);
+}
+
+/** Max failed admin login attempts per IP within the window. */
+function getAdminLoginRateLimitMax(): int
+{
+    return defined("ADMIN_LOGIN_RATE_LIMIT_MAX")
+        ? max(1, (int) ADMIN_LOGIN_RATE_LIMIT_MAX)
+        : 10;
+}
+
+function getAdminLoginRateLimitWindow(): int
+{
+    return defined("ADMIN_LOGIN_RATE_LIMIT_WINDOW")
+        ? max(60, (int) ADMIN_LOGIN_RATE_LIMIT_WINDOW)
+        : 900;
+}
+
+function isAdminLoginRateLimited(): bool
+{
+    $path = getRateLimitStatePath("admin_login");
+    if ($path === null || !is_file($path)) {
+        return false;
+    }
+
+    $fh = fopen($path, "r");
+    if ($fh === false) {
+        return false;
+    }
+    if (!flock($fh, LOCK_SH)) {
+        fclose($fh);
+        return false;
+    }
+
+    rewind($fh);
+    $raw = stream_get_contents($fh);
+    flock($fh, LOCK_UN);
+    fclose($fh);
+
+    $data = [];
+    if (is_string($raw) && trim($raw) !== "") {
+        $decoded = json_decode($raw, true);
+        if (is_array($decoded)) {
+            $data = $decoded;
+        }
+    }
+
+    $ip = getRateLimitClientIp();
+    $now = time();
+    $window = getAdminLoginRateLimitWindow();
+    $max = getAdminLoginRateLimitMax();
+
+    if (!isset($data[$ip]) || !is_array($data[$ip])) {
+        return false;
+    }
+
+    $windowStart = (int) ($data[$ip]["w"] ?? 0);
+    $count = (int) ($data[$ip]["c"] ?? 0);
+    if ($now - $windowStart > $window) {
+        return false;
+    }
+
+    return $count >= $max;
+}
+
+function recordAdminLoginFailure(): void
+{
+    rateLimitTryConsume(
+        "admin_login",
+        getAdminLoginRateLimitMax(),
+        getAdminLoginRateLimitWindow(),
+    );
+}
+
+function clearAdminLoginRateLimitForCurrentIp(): void
+{
+    rateLimitClearIp("admin_login", getRateLimitClientIp());
+}
+
+function getCheckoutRateLimitMax(): int
+{
+    return defined("CHECKOUT_RATE_LIMIT_MAX")
+        ? max(1, (int) CHECKOUT_RATE_LIMIT_MAX)
+        : 30;
+}
+
+function getCheckoutRateLimitWindow(): int
+{
+    return defined("CHECKOUT_RATE_LIMIT_WINDOW")
+        ? max(60, (int) CHECKOUT_RATE_LIMIT_WINDOW)
+        : 3600;
+}
+
+function checkoutRateLimitTryConsume(): bool
+{
+    return rateLimitTryConsume(
+        "checkout",
+        getCheckoutRateLimitMax(),
+        getCheckoutRateLimitWindow(),
+    );
+}
+
 function getUploadFilename($filename)
 {
     $filename = trim((string) $filename);
@@ -1444,6 +1701,16 @@ function buildAbsoluteUrl($path)
         $path = "/" . trim($basePath, "/") . $path;
     }
 
+    $canonical = defined("SITE_CANONICAL_ORIGIN")
+        ? trim((string) SITE_CANONICAL_ORIGIN)
+        : "";
+    if (
+        $canonical !== "" &&
+        preg_match("#\Ahttps?://[^\s/]+#i", $canonical)
+    ) {
+        return rtrim($canonical, "/") . $path;
+    }
+
     $scheme = isHttpsRequest() ? "https" : "http";
     $host = $_SERVER["HTTP_HOST"] ?? "";
     if ($host === "") {
@@ -2447,7 +2714,8 @@ function generateOrderPdf($order)
                 $writeWrapped($encodeText("   Hinweis: ") . $hintLabel, 76);
             }
 
-            $y -= 4;
+            $ensureSpace(40);
+            $y -= 40;
             $itemNumber++;
         }
     }
@@ -2463,27 +2731,6 @@ function generateOrderPdf($order)
         }
     }
 
-    $y -= 6;
-    $writeSectionTitle("Lagerbearbeitung");
-
-    $warehouseLines = [
-        "Ausgegeben am: ________________________",
-        "Ausgegeben durch: _____________________",
-        "Unterschrift: _________________________",
-        "",
-        "[ ] Vollständig ausgegeben",
-        "[ ] Teilweise ausgegeben",
-    ];
-
-    foreach ($warehouseLines as $line) {
-        if ($line === "") {
-            $ensureSpace($lineHeight);
-            $y -= $lineHeight;
-            continue;
-        }
-        $writeWrapped($encodeText($line), 80);
-    }
-
     if ($pageContent !== "") {
         $pages[] = $pageContent;
     }
@@ -2634,6 +2881,15 @@ class Validator
         return $this->data[$field] ?? null;
     }
 
+    public function addError(string $message): self
+    {
+        $message = trim($message);
+        if ($message !== "") {
+            $this->errors[] = $message;
+        }
+        return $this;
+    }
+
     public function getErrors()
     {
         return $this->errors;

+ 1 - 1
order-success.php

@@ -18,7 +18,7 @@ include __DIR__ . '/includes/header.php';
     <div class="panel">
         <p><strong>Bestellnummer:</strong> <span class="order-highlight"><?php echo escape($order['id']); ?></span></p>
         <?php if ($order['confirmation_status'] === 'pending'): ?>
-            <p>Bitte bestätigen Sie die Bestellung über den Link in der E-Mail an <strong><?php echo escape($order['customer_email']); ?></strong>.</p>
+            <p>Bitte bestätigen Sie die Bestellung über den Link in der E-Mail, die an die von Ihnen angegebene E-Mail-Adresse gesendet wurde.</p>
         <?php else: ?>
             <p>Ihre Bestellung wurde erfasst und an <?php echo escape(SITE_DEPARTMENT_NAME); ?> weitergeleitet.</p>
         <?php endif; ?>