Harden for public launch: rate limiting, CORS, error display, Gitea webhook sig

- User::login() locks an account for 15 min after 5 failed attempts
- CORS now restricted to an explicit origin whitelist instead of *
- display_errors disabled in production (errors still logged server-side)
- webhook.php now checks Gitea's actual signature header (X-Gitea-Signature,
  raw hex) instead of GitHub's format, which never matched on this Gitea instance

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 17:53:28 +02:00
parent 4c61b7f276
commit 42a388cbe1
3 changed files with 66 additions and 23 deletions
+16 -4
View File
@@ -1,12 +1,24 @@
<?php <?php
// index.php // index.php
ini_set('display_errors', 1); // En prod on ne balance plus les erreurs PHP au visiteur (fuite d'infos internes) ;
ini_set('display_startup_errors', 1); // elles restent loguées côté serveur via error_log.
ini_set('display_errors', 0);
ini_set('display_startup_errors', 0);
ini_set('log_errors', 1);
error_reporting(E_ALL); error_reporting(E_ALL);
// --- HEADERS CORS (Indispensables pour ton site et ton appli mobile) --- // --- HEADERS CORS ---
header("Access-Control-Allow-Origin: *"); // Les apps mobiles natives ne sont pas concernées par CORS (c'est une restriction de navigateur),
// donc restreindre ici n'affecte que les navigateurs web.
$allowed_origins = [
'https://watchgether.whykioh.fr',
'null', // ouverture du HTML en double-clic (file://) pendant le dev local
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowed_origins, true)) {
header("Access-Control-Allow-Origin: $origin");
}
header("Content-Type: application/json; charset=UTF-8"); header("Content-Type: application/json; charset=UTF-8");
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS"); header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With"); header("Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With");
+46 -17
View File
@@ -4,6 +4,8 @@
class User { class User {
private $conn; private $conn;
private $table_name = "users"; private $table_name = "users";
private $max_attempts = 5;
private $lockout_minutes = 15;
public function __construct($db) { public function __construct($db) {
$this->conn = $db; $this->conn = $db;
@@ -44,28 +46,55 @@ class User {
// --- 2. CONNEXION --- // --- 2. CONNEXION ---
public function login($email, $password) { public function login($email, $password) {
$query = "SELECT id, username, email, password, pairing_code, partner_id FROM " . $this->table_name . " WHERE email = :email LIMIT 0,1"; $query = "SELECT id, username, email, password, pairing_code, partner_id, failed_attempts, locked_until FROM " . $this->table_name . " WHERE email = :email LIMIT 0,1";
$stmt = $this->conn->prepare($query); $stmt = $this->conn->prepare($query);
$stmt->bindParam(":email", $email); $stmt->bindParam(":email", $email);
$stmt->execute(); $stmt->execute();
if ($stmt->rowCount() > 0) { if ($stmt->rowCount() === 0) {
$row = $stmt->fetch(); return ["success" => false, "message" => "Identifiants incorrects."];
// Vérification du mot de passe
if (password_verify($password, $row['password'])) {
return [
"success" => true,
"user" => [
"id" => $row['id'],
"username" => $row['username'],
"email" => $row['email'],
"pairing_code" => $row['pairing_code'],
"partner_id" => $row['partner_id']
]
];
}
} }
$row = $stmt->fetch();
// Compte temporairement verrouillé après trop d'échecs
if ($row['locked_until'] && strtotime($row['locked_until']) > time()) {
return ["success" => false, "message" => "Trop de tentatives échouées. Réessaie dans quelques minutes."];
}
// Vérification du mot de passe
if (password_verify($password, $row['password'])) {
$reset = $this->conn->prepare("UPDATE " . $this->table_name . " SET failed_attempts = 0, locked_until = NULL WHERE id = :id");
$reset->bindParam(":id", $row['id']);
$reset->execute();
return [
"success" => true,
"user" => [
"id" => $row['id'],
"username" => $row['username'],
"email" => $row['email'],
"pairing_code" => $row['pairing_code'],
"partner_id" => $row['partner_id']
]
];
}
// Échec : on incrémente le compteur et on verrouille si le seuil est atteint
$attempts = $row['failed_attempts'] + 1;
if ($attempts >= $this->max_attempts) {
$lock = $this->conn->prepare("UPDATE " . $this->table_name . " SET failed_attempts = :attempts, locked_until = DATE_ADD(NOW(), INTERVAL :minutes MINUTE) WHERE id = :id");
$lock->bindParam(":attempts", $attempts, PDO::PARAM_INT);
$lock->bindParam(":minutes", $this->lockout_minutes, PDO::PARAM_INT);
$lock->bindParam(":id", $row['id']);
$lock->execute();
} else {
$incr = $this->conn->prepare("UPDATE " . $this->table_name . " SET failed_attempts = :attempts WHERE id = :id");
$incr->bindParam(":attempts", $attempts, PDO::PARAM_INT);
$incr->bindParam(":id", $row['id']);
$incr->execute();
}
return ["success" => false, "message" => "Identifiants incorrects."]; return ["success" => false, "message" => "Identifiants incorrects."];
} }
+4 -2
View File
@@ -14,8 +14,10 @@ $database = new Database();
$secret = $database->webhook_secret; $secret = $database->webhook_secret;
$payload = file_get_contents('php://input'); $payload = file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? ''; // Gitea (auto-hébergé) envoie un hex brut dans X-Gitea-Signature, sans préfixe "sha256="
$expectedSignature = 'sha256=' . hash_hmac('sha256', $payload, $secret); // (contrairement à GitHub qui utilise X-Hub-Signature-256 avec le préfixe)
$signatureHeader = $_SERVER['HTTP_X_GITEA_SIGNATURE'] ?? '';
$expectedSignature = hash_hmac('sha256', $payload, $secret);
// Comparaison en temps constant pour éviter les attaques par timing // Comparaison en temps constant pour éviter les attaques par timing
if (!$signatureHeader || !hash_equals($expectedSignature, $signatureHeader)) { if (!$signatureHeader || !hash_equals($expectedSignature, $signatureHeader)) {