Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ Petite application PHP (sans dépendance) et base SQLite qui recueille les signa
6. `public/signataires.php` expose en JSON la liste publique : signatures confirmées, avec consentement à la publication et validées, **sans adresse e-mail**. Le total n'inclut pas les signatures en attente de modération. La page du manifeste de www.otspi.org la lit en direct (en-tête CORS limité à `https://www.otspi.org`) : une signature apparaît quelques minutes après sa validation (cache de 5 minutes).
7. `cron/purge.php` (tâche quotidienne) supprime les demandes non confirmées de plus de 7 jours, ainsi que les invitations, défis et sessions d'administration expirés.

Protections : jeton de formulaire signé avec délai minimal (aucun cookie, aucune session), champ piège pour les robots, refus des envois venant d'un autre site (`Sec-Fetch-Site`, à défaut `Origin`), limitation de débit par empreinte salée de l'adresse IP ou du préfixe /64 en IPv6 (conservée une heure), plafond global de 200 e-mails de confirmation par heure (`mail_hourly_cap`), e-mails sans texte libre hors du récapitulatif et liens refusés dans les champs, une seule signature par boîte (les alias `+…` sont acceptés mais ignorés pour l'unicité), un seul e-mail de confirmation toutes les 10 minutes par adresse, réponse identique que l'adresse soit connue ou non, refus des caractères de contrôle, bidirectionnels et de largeur nulle dans les noms, requêtes préparées, échappement HTML, erreurs journalisées et jamais affichées, en-têtes de sécurité et CSP stricte.
Protections : jeton de formulaire signé avec délai minimal, à usage unique (aucun cookie, aucune session), **preuve de travail** calculée par le navigateur pendant la saisie (`assets/pow.js` : SHA-256 du jeton avec 18 bits nuls en tête, soit moins d'une seconde sur un ordinateur ; réglable par `pow_bits` ; aucun service tiers, JavaScript requis pour signer), refus des adresses d'e-mail jetables (`src/disposable-domains.txt`, sous-domaines compris), champ piège pour les robots, refus des envois venant d'un autre site (`Sec-Fetch-Site`, à défaut `Origin`), limitation de débit par empreinte salée de l'adresse IP ou du préfixe /64 en IPv6 (conservée une heure), plafond global de 200 e-mails de confirmation par heure (`mail_hourly_cap`), e-mails sans texte libre hors du récapitulatif et liens refusés dans les champs, une seule signature par boîte (les alias `+…` sont acceptés mais ignorés pour l'unicité), un seul e-mail de confirmation toutes les 10 minutes par adresse, réponse identique que l'adresse soit connue ou non, refus des caractères de contrôle, bidirectionnels et de largeur nulle dans les noms, requêtes préparées, échappement HTML, erreurs journalisées et jamais affichées, en-têtes de sécurité et CSP stricte.

Mesure d'audience : seuls le formulaire (`index.php`) et la page « signature confirmée » chargent `assets/analytics.js` (Matomo Tag Manager de `stats.otspi.org`, sans cookie) et ouvrent leur CSP à ce domaine. Les pages dont l'adresse porte un jeton personnel (lien de confirmation, retrait, modération) ne sont pas mesurées. Le formulaire déclare aussi des événements anonymes (attributs `data-track` et `data-track-load`, voir `analytics.js`) : clic sur « Envoyer », code d'erreur éventuel (`err_…`, jamais les valeurs saisies), page « demande envoyée » et page « signature confirmée » (réponse au POST de confirmation, dont l'adresse `confirm.php?lang=…` ne porte pas le jeton). `analytics.js` est commun aux sites d'OTSPI : sa source est dans le dépôt `vitrine` ; après une copie, incrémenter `?v=` dans `page()` (`src/lib.php`).
Mesure d'audience : seuls le formulaire (`index.php`) et la page « signature confirmée » chargent `assets/analytics.js` (Matomo Tag Manager de `stats.otspi.org`, sans cookie) et ouvrent leur CSP à ce domaine. Le formulaire charge aussi `assets/pow.js` (preuve de travail, sans appel extérieur). Les pages dont l'adresse porte un jeton personnel (lien de confirmation, retrait, modération) ne sont pas mesurées. Le formulaire déclare aussi des événements anonymes (attributs `data-track` et `data-track-load`, voir `analytics.js`) : clic sur « Envoyer », code d'erreur éventuel (`err_…`, jamais les valeurs saisies), page « demande envoyée » et page « signature confirmée » (réponse au POST de confirmation, dont l'adresse `confirm.php?lang=…` ne porte pas le jeton). `analytics.js` est commun aux sites d'OTSPI : sa source est dans le dépôt `vitrine` ; après une copie, incrémenter `?v=` dans `page()` (`src/lib.php`).

## Installation

Expand Down
2 changes: 2 additions & 0 deletions config.example.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
// Facultatif : modèles de clé de sécurité acceptés pour l'administration (AAGUID en minuscules, affiché
// dans la page « Clés de sécurité »). Vide : toute clé externe avec code PIN.
'admin_aaguids' => [],
// Facultatif : difficulté de la preuve de travail anti-robots (bits nuls, 18 par défaut ; +1 double le calcul).
// 'pow_bits' => 18,
// Pour les essais : ne pas envoyer, journaliser dans mail_log.
'mail_dry_run' => false,
'mail_log' => __DIR__ . '/data/mail.log',
Expand Down
1 change: 1 addition & 0 deletions cron/purge.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@
$old->execute([time() - UNCONFIRMED_TTL]);
$pdo->prepare('DELETE FROM hits WHERE at < ?')->execute([time() - 3600]);
$pdo->prepare('DELETE FROM admin_tokens WHERE expires_at < ?')->execute([time()]);
$pdo->prepare('DELETE FROM form_tokens WHERE at < ?')->execute([time() - MAX_FILL_SECONDS]);
echo date('c') . ' purge : ' . $old->rowCount() . " demande(s) non confirmée(s) supprimée(s)\n";
64 changes: 64 additions & 0 deletions public/assets/pow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: EUPL-1.2
// Preuve de travail anti-robots du formulaire, sans service tiers : dès l'affichage, le navigateur cherche un
// entier n tel que SHA-256(jeton:n) commence par data-pow bits nuls (voir pow_ok() dans src/lib.php). Le calcul
// se fait pendant que la personne remplit le formulaire ; s'il n'est pas fini à l'envoi, l'envoi l'attend.
'use strict';

(() => {
const form = document.querySelector('form[data-pow]');
if (!form) {
return;
}
const bits = Number(form.dataset.pow);
const token = form.elements.ft.value;
const field = form.elements.pow;
const status = form.querySelector('.pow-status');
const encoder = new TextEncoder();

const enough = (buffer) => {
let left = bits;
for (const byte of new Uint8Array(buffer)) {
if (left <= 0) {
return true;
}
const need = Math.min(8, left);
if (byte >> (8 - need) !== 0) {
return false;
}
left -= 8;
}
return true;
};

const search = async () => {
for (let start = 0; ; start += 512) {
const batch = [];
for (let n = start; n < start + 512; n++) {
batch.push(crypto.subtle.digest('SHA-256', encoder.encode(`${token}:${n}`)));
}
const found = (await Promise.all(batch)).findIndex(enough);
if (found !== -1) {
return String(start + found);
}
}
};

const done = window.crypto && crypto.subtle
? search().then((nonce) => { field.value = nonce; })
: Promise.reject(new Error('WebCrypto indisponible'));
done.catch(() => { status.textContent = form.dataset.powError; });

let waiting = false;
form.addEventListener('submit', (event) => {
if (field.value !== '') {
return;
}
event.preventDefault();
if (waiting) {
return;
}
waiting = true;
status.textContent = form.dataset.powWait;
done.then(() => form.submit(), () => { waiting = false; });
});
})();
19 changes: 14 additions & 5 deletions public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
'email' => (string) ($_POST['email'] ?? ''), 'fonction' => (string) ($_POST['fonction'] ?? ''),
'organisation' => (string) ($_POST['organisation'] ?? ''), 'publier' => isset($_POST['publier']),
];
$state = form_token_state((string) ($_POST['ft'] ?? ''));
$formToken = (string) ($_POST['ft'] ?? '');
$state = form_token_state($formToken);

if (($_POST['website'] ?? '') !== '') {
// Piège à robots : champ invisible rempli. On répond comme en cas de succès, sans rien faire.
Expand All @@ -24,6 +25,11 @@
}
if ($state !== 'ok' || cross_site_post()) {
$error = 'err_form';
} elseif (!pow_ok($formToken, (string) ($_POST['pow'] ?? ''))) {
$error = 'err_pow';
} elseif (!form_token_spend($formToken)) {
// Jeton et preuve de travail déjà utilisés : un robot rejoue un envoi.
$error = 'err_form';
} elseif (rate_limited(5, 3600)) {
$error = 'err_rate';
} else {
Expand All @@ -35,6 +41,8 @@
$email = strtolower(trim($old['email']));
if ($prenom === null || $nom === null || $fonction === null || $organisation === null || !valid_email($email)) {
$error = 'err_invalid';
} elseif (disposable_email($email)) {
$error = 'err_disposable';
} else {
$pdo = db();
$token = new_token();
Expand Down Expand Up @@ -87,15 +95,16 @@
$body = '<p class="lang"><a href="?lang=' . $other . '">' . strtoupper($other) . '</a></p>'
. '<h1>' . h(t('heading', $lang)) . '</h1>'
. ($error ? '<p class="error" role="alert" data-track-load="Manifeste|Erreur|' . h($error) . '">' . h(t($error, $lang)) . '</p>' : '')
. '<form method="post" action="?lang=' . $lang . '">'
. '<input type="hidden" name="lang" value="' . $lang . '"><input type="hidden" name="ft" value="' . h(form_token()) . '">'
. '<noscript><p class="error">' . h(t('noscript', $lang)) . '</p></noscript>'
. '<form method="post" action="?lang=' . $lang . '" data-pow="' . pow_bits() . '" data-pow-wait="' . h(t('pow_wait', $lang)) . '" data-pow-error="' . h(t('pow_error', $lang)) . '">'
. '<input type="hidden" name="lang" value="' . $lang . '"><input type="hidden" name="ft" value="' . h(form_token()) . '"><input type="hidden" name="pow" value="">'
. '<p class="hp" aria-hidden="true"><label>Website<input name="website" tabindex="-1" autocomplete="off"></label></p>'
. $field('prenom', t('firstname', $lang), 'text', true, 80)
. $field('nom', t('lastname', $lang), 'text', true, 80)
. $field('email', t('email', $lang), 'email', true, 254)
. $field('fonction', t('position', $lang), 'text', false, 120)
. $field('organisation', t('organisation', $lang), 'text', false, 120)
. '<p><label><input type="checkbox" name="publier" value="1"' . ($old['publier'] ? ' checked' : '') . '> ' . h(t('publish', $lang)) . '</label></p>'
. '<p><button type="submit" data-track="Manifeste|Envoyer le formulaire|' . $lang . '">' . h(t('submit', $lang)) . '</button></p></form>'
. '<p><button type="submit" data-track="Manifeste|Envoyer le formulaire|' . $lang . '">' . h(t('submit', $lang)) . '</button></p><p class="pow-status muted" role="status"></p></form>'
. '<p class="privacy">' . h(t('privacy', $lang)) . '</p>';
page(t('title', $lang), $body, $lang, audience: true);
page(t('title', $lang), $body, $lang, audience: true, script: 'assets/pow.js');
58 changes: 58 additions & 0 deletions src/disposable-domains.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Services d'e-mail jetables refusés par le formulaire (un domaine par ligne ; ses sous-domaines sont refusés aussi).
# Liste courte, tenue à la main : une signature doit rester joignable pour recevoir son lien de retrait.
10minutemail.com
10minutemail.net
1secmail.com
1secmail.net
1secmail.org
burnermail.io
crazymailing.com
discard.email
dispostable.com
dropmail.me
emailfake.com
emailondeck.com
fakeinbox.com
getnada.com
grr.la
guerrillamail.biz
guerrillamail.com
guerrillamail.de
guerrillamail.info
guerrillamail.net
guerrillamail.org
guerrillamailblock.com
inboxkitten.com
jetable.org
mail-temporaire.fr
mailcatch.com
maildrop.cc
mailinator.com
mailinator.net
mailnesia.com
minuteinbox.com
mintemail.com
moakt.com
mohmal.com
mytemp.email
pokemail.net
sharklasers.com
spam4.me
spambog.com
spamgourmet.com
tempail.com
temp-mail.io
temp-mail.org
tempmail.com
tempmail.net
tempr.email
throwawaymail.com
tmpmail.net
tmpmail.org
trashmail.com
trashmail.de
trashmail.net
wegwerfmail.de
yopmail.com
yopmail.fr
yopmail.net
80 changes: 73 additions & 7 deletions src/lib.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
const MIN_FILL_SECONDS = 4; // un humain met plus de 4 s à remplir le formulaire
const MAX_FILL_SECONDS = 7200; // formulaire périmé au-delà de 2 h
const MAIL_HOURLY_CAP = 200; // plafond global d'e-mails de confirmation par heure (surcharge : mail_hourly_cap)
const POW_BITS = 18; // preuve de travail : bits nuls en tête de l'empreinte (surcharge : pow_bits)

// Erreur inattendue (base verrouillée, disque plein…) : journalisée, jamais affichée.
set_exception_handler(static function (Throwable $e): void {
Expand Down Expand Up @@ -73,6 +74,10 @@ function db(): PDO
at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS hits_ip ON hits (ip_hash, at);
CREATE TABLE IF NOT EXISTS form_tokens (
hash TEXT PRIMARY KEY,
at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS admin_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_id TEXT NOT NULL UNIQUE,
Expand Down Expand Up @@ -149,21 +154,24 @@ function token_hash(string $token): string
return hash_hmac('sha256', $token, config()['secret']);
}

/** Jeton de formulaire signé : horodatage + HMAC (aucune session, aucun cookie). */
/**
* Jeton de formulaire signé : horodatage, aléa et HMAC (aucune session, aucun cookie). Il sert aussi de défi
* à la preuve de travail, et ne sert qu'une fois (form_token_spend).
*/
function form_token(): string
{
$ts = (string) time();
return $ts . '.' . hash_hmac('sha256', 'form|' . $ts, config()['secret']);
$body = time() . '.' . bin2hex(random_bytes(8));
return $body . '.' . hash_hmac('sha256', 'form|' . $body, config()['secret']);
}

function form_token_state(string $token): string
{
$parts = explode('.', $token, 2);
if (count($parts) !== 2 || !ctype_digit($parts[0])) {
$parts = explode('.', $token);
if (count($parts) !== 3 || !ctype_digit($parts[0]) || preg_match('/^[0-9a-f]{16}$/', $parts[1]) !== 1) {
return 'invalid';
}
$expected = hash_hmac('sha256', 'form|' . $parts[0], config()['secret']);
if (!hash_equals($expected, $parts[1])) {
$expected = hash_hmac('sha256', 'form|' . $parts[0] . '.' . $parts[1], config()['secret']);
if (!hash_equals($expected, $parts[2])) {
return 'invalid';
}
$age = time() - (int) $parts[0];
Expand All @@ -173,6 +181,46 @@ function form_token_state(string $token): string
return $age > MAX_FILL_SECONDS ? 'expired' : 'ok';
}

/** Marque un jeton de formulaire comme utilisé : faux si il l'était déjà (preuve de travail rejouée). */
function form_token_spend(string $token): bool
{
$pdo = db();
$pdo->prepare('DELETE FROM form_tokens WHERE at < ?')->execute([time() - MAX_FILL_SECONDS]);
$insert = $pdo->prepare('INSERT OR IGNORE INTO form_tokens (hash, at) VALUES (?, ?)');
$insert->execute([token_hash($token), time()]);
return $insert->rowCount() === 1;
}

function pow_bits(): int
{
return (int) (config()['pow_bits'] ?? POW_BITS);
}

/**
* Preuve de travail (assets/pow.js) : le navigateur cherche un entier tel que SHA-256(jeton:entier) commence
* par pow_bits() bits nuls, soit environ 2^18 essais (moins d'une seconde sur un ordinateur, quelques secondes
* sur un téléphone lent), calculés pendant que la personne remplit le formulaire. Négligeable pour une personne,
* coûteux pour un robot qui envoie en masse, qui doit en outre exécuter le calcul. Aucun service tiers.
*/
function pow_ok(string $token, string $nonce): bool
{
if (preg_match('/^[0-9]{1,12}$/', $nonce) !== 1) {
return false;
}
$bits = pow_bits();
foreach (str_split(hash('sha256', $token . ':' . $nonce, true)) as $byte) {
if ($bits <= 0) {
return true;
}
for ($zeros = 0, $value = ord($byte); $zeros < 8 && ($value & (0x80 >> $zeros)) === 0; $zeros++);
if ($zeros < min(8, $bits)) {
return false;
}
$bits -= 8;
}
return true;
}

// ---- Limitation de débit ------------------------------------------------------------------------

/** Empreinte salée de l'adresse IP, conservée une heure au plus. */
Expand Down Expand Up @@ -295,6 +343,24 @@ function email_key(string $email): string
return ($plus === false || $plus === 0 ? $local : substr($local, 0, $plus)) . substr($email, $at);
}

/** Adresse d'un service d'e-mail jetable (src/disposable-domains.txt), domaine ou sous-domaine. */
function disposable_email(string $email): bool
{
static $domains = null;
$domains ??= array_flip(array_filter(array_map('trim', file(__DIR__ . '/disposable-domains.txt') ?: []),
static fn (string $line): bool => $line !== '' && $line[0] !== '#'));
$domain = strtolower(substr($email, strrpos($email, '@') + 1));
for ($d = $domain; $d !== ''; $d = (string) substr($d, (int) strpos($d, '.') + 1)) {
if (isset($domains[$d])) {
return true;
}
if (!str_contains($d, '.')) {
break;
}
}
return false;
}

function valid_email(string $email): bool
{
return strlen($email) <= 254 && filter_var($email, FILTER_VALIDATE_EMAIL) !== false
Expand Down
5 changes: 5 additions & 0 deletions src/texts.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
'en' => 'Data collected: first name, last name, e-mail address and, optionally, position and organisation. Purposes: recording your signature, verifying it and, with your consent and after a check intended to rule out impersonation, publishing it. Legal basis: your consent, which may be withdrawn at any time. Your e-mail address is never published. An unconfirmed request is deleted within 7 days; signatures are kept during the campaign and then for at most two years after it closes. Data controller: Philippe Vienne, founding member of the OTSPI initiative, until the association is formed. Hosting: o2switch (France). Contact and exercise of your rights: contact@otspi.org.',
],
'err_invalid' => ['fr' => 'Certains champs sont manquants ou invalides. Vérifiez le prénom, le nom et l’adresse e-mail ; les liens ne sont pas acceptés.', 'en' => 'Some fields are missing or invalid. Please check your first name, last name and e-mail address; links are not accepted.'],
'err_pow' => ['fr' => 'La vérification anti-robot n’a pas abouti. Rechargez la page et réessayez ; JavaScript doit être activé.', 'en' => 'The anti-bot check did not complete. Reload the page and try again; JavaScript must be enabled.'],
'err_disposable' => ['fr' => 'Les adresses e-mail jetables ne sont pas acceptées : utilisez une adresse durable, qui vous permettra aussi de retirer votre signature.', 'en' => 'Disposable e-mail addresses are not accepted: please use a lasting address, which will also let you withdraw your signature.'],
'pow_wait' => ['fr' => 'Vérification anti-robot en cours, un instant…', 'en' => 'Anti-bot check in progress, one moment…'],
'pow_error' => ['fr' => 'Ce navigateur ne permet pas la vérification anti-robot. Écrivez à contact@otspi.org pour signer.', 'en' => 'This browser cannot run the anti-bot check. Write to contact@otspi.org to sign.'],
'noscript' => ['fr' => 'JavaScript est nécessaire pour la vérification anti-robot (calcul dans votre navigateur, sans service tiers). Sans JavaScript, écrivez à contact@otspi.org pour signer.', 'en' => 'JavaScript is required for the anti-bot check (computed in your browser, with no third-party service). Without JavaScript, write to contact@otspi.org to sign.'],
'err_form' => ['fr' => 'Le formulaire a expiré ou a été envoyé trop vite. Rechargez la page et réessayez.', 'en' => 'The form has expired or was sent too fast. Reload the page and try again.'],
'err_rate' => ['fr' => 'Trop de tentatives. Réessayez plus tard.', 'en' => 'Too many attempts. Please try again later.'],
'err_mail' => ['fr' => 'L’e-mail de confirmation n’a pas pu être envoyé. Réessayez plus tard ou écrivez à contact@otspi.org.', 'en' => 'The confirmation e-mail could not be sent. Try again later or write to contact@otspi.org.'],
Expand Down
Loading
Loading