Yann "Bug" Dubois

Développeur WordPress freelance à Paris
Flux RSS

URLs provisoires et non réutilisables en PHP

8 September 2010 Par : Yann Dubois Catégorie : Français, tech

Dans le cadre d’un projet pour un client, j’ai dû optimiser un site marchand qui donnait accès à des chargement de fichiers mp3 après paiement. Suite à un audit technique rapide du site (développé par un tiers), je m’étais rendu compte qu’il était assez facile pour des utilisateurs de trouver un accès direct aux fichiers mp3 dans l’arborescence du site, et donc de télécharger gratuitement tous les morceaux disponibles ! Il fallait donc remédier à ce problème. Voici ma solution qui utilise l’URL-Rewriting d’Apache, et une classe PHP sur mesure. Une version développée en procédural est également proposée pour les allergiques aux objets.

Etat des lieux

Le site propose une pré-écoute des morceaux à l’aide d’un composant développé en Flash (.swf). Le mécanisme tel qu’il existait présentait deux failles :

  • Le composant Flash accédait et chargeait la version complète du fichier mp3 dont il ne jouait que les 30 premières secondes. Dans la mesure où le composant Flash s’exécute dans le navigateur client, il était facile pour un internaute expérimenté de “capturer” dans le flux des échanges http l’adresse d’origine du fichier mp3 complet pour le rapatrier sans payer.
  • Le nom du fichier tel qu’il figure dans l’arborescence des fichiers sur le serveur était passé en paramètre “en clair” dans le code source (html) de la page : il suffisait donc de cliquer sur n’importe quel morceau en pré-écoute et d’afficher le code source de la page pour deviner le nom du fichier mp3 d’origine. Il n’y avait alors plus qu’à situer dans l’arborescence du site le dossier hébergeant les fichiers mp3 pour pouvoir télécharger gratuitement n’importe quelle chanson.

Le répertoire abritant les fichiers pouvait facilement être trouvé par déduction, ou en passant commande d’un morceau : le mécanisme de récupération des fichiers achetés présentait en effet deux autres failles :

  • Le chemin d’accès complet au fichier mp3 apparaissait en clair dans la page permettant de récupérer ses achats. On pouvait donc deviner la hiérarchie de l’arborescence du serveur, et en déduire l’emplacement des autres morceaux (dont le nom était facile à retrouver à cause de la faille explicitée ci-dessus)
  • L’URL de récupération d’un fichier mp3 était valide indéfiniment (puisqu’il pointait directement sur le fichier source) : il était donc très facile de communiquer cet URL à des tiers, voire de faire des liens directs depuis une page web pour contourner complètement le système de commande !

Une étude rapide du log des connexions du serveur web m’a d’ailleurs confirmé que certains fichiers mp3 étaient accédés directement (sans passer par le composant Flash de préécoute ni par la récupération d’un achat), ce qui semble confirmer que des individus ou des robots d’exploration avaient déjà réussi à exploiter ces différentes failles, mettant en danger le modèle économique du site.

Spécifications de la refonte partielle

Face à cet état des lieux préoccupant, j’ai proposé à mon client d’intervenir sur son site pour mettre en place les mécanisme de protection suivants :

  • Rendre impossible l’accès direct à un fichier de musique par son “nom physique” tel que présent dans le système de fichiers : ceci rendra caducs les liens vers tous les fichiers qui avaient déjà été “découverts” par des tiers (robots crawlers ou utilisateurs trop astucieux).
  • Permettre au composant Flash qui gère la préécoute d’accéder aux fichiers avec des urls “jetables” qui ne sont valables que pendant une heure, sont modifiés en permanence, et ne permettent aucunement de déduire ni le nom ni l’emplacement physique des fichiers mp3 dans l’arborescence du serveur.
  • Faire en sorte que ces url ne permettent de récupérer que les 35 premières secondes d’un morceau au maximum quoi qu’il advienne.
  • Mettre en place d’autres urls provisoires, également uniques et valables pendant une durée déterminée pour permettre aux clients de télécharger le fichier mp3 complet après une commande payée et validée (ce mécanisme nécessitait déjà que le client s’authentifie et fournisse un code commande unique).

Toutes ces modifications ne demanderont que la mise en place d’une nouvelle classe à inclure dans les scripts php qui gèrent les pages qui comportaient des failles, l’ajout d’un nouveau script gérant tous les accès aux fichiers musicaux, et l’adjonction de deux règles de ré-écriture dans le fichier .htaccess de la racine du site. Je ne modifie donc que le strict minimum dans le fonctionnement du système de navigation, de pré-écoute, de commande et d’administration (back-office) existant. Si jamais le développeur d’origine du site devait ré-intervenir (sur les parties développées en Flash par exemple), il ne serait pas dérangé par les modifications “non intrusives” introduites à la marge de son code, mais qui contribuent cependant à nettement le sécuriser. Aucun fichier existant n’a été déplacé ou renommé.

Les principes techniques

  • A chaque fois que l’on doit afficher un URL donnant accès à un fichier mp3, on passera par l’encodage préalable du nom de fichier avec une fonction qui génère à chaque seconde un nouvel url crypté et impossible à deviner. Cet URL possède un marqueur temporel (timestamp) qui permet de le “périmer” au bout d’une période déterminée. Une méthode (ou une fonction) PHP s’occupe de générer cet URL “crypté”.
  • Une règle de réécriture s’occupe de “capturer” tous les url de ce type et de les achemnier vers un script PHP spécial qui s’occupe du “contrôle de validité” à deux niveaux :
    • Vérification que l’URL n’est pas encore périmé (validité temporelle) en comparant son marqueur temporel avec l’instant t. Au delà d’une heure, l’URL n’est plus valide et ne retournera rien (ou une erreur “accès refusé”).
    • Vérification que l’URL n’est pas “bricolé” (chaque URL est signé avec une clé secrète qui en garantit l’intégrité et interdit d’en “forger” des nouveaux, quand bien même on aurait deviné le mécanisme)
  • Si l’URL est valide, le script PHP de contrôle donne accès, soit aux données correspondant aux 35 premières secondes du fichier audio physique (cas d’une pré-écoute dans le player Flash), soit à l’intégralité du fichier (cas d’une commande payée et validée par un client authentifié).

Structure d’un URL crypté avec péremption

Les url provisoires uniques sont générés avec une structure en quatre parties :

  • Un timestamp UN*X standard, en clair (suite de dix chiffres représentant le nombre de secondes écoulées depuis le 1er janvier 1970), suivi d’une virgule (“,”).
  • Un hachage MD5 de ce timestamp auquel on a préalablement concaténé une clé secrète (un “salt”, c’est à dire un ingrédient secret qui vient épicer la complexité de l’algorithme pour en interdire la falsification), suivi d’une autre virgule.
  • Le nom du fichier mp3, encodé avec l’algorithme de cryptage symétrique BLOWFISH, utilisant deux mécanismes qui en augmentent l’entropie : une clé secrète (encore un “salt”), et un vecteur d’initialisation qui est dépendant de l’instant de création de l’URL.
  • L’extension ‘.mp3’ qui ne change jamais.

Cette structure bien particulière nous permet facilement de construire une règle de réécriture Apache fondée sur des expressions rationelles pour réorienter les URL qui répondent à ce motif vers notre script PHP de contrôle et de décodage.

Les règles de ré-écriture Apache

Voici les règles de réécriture à faire figurer dans le fichier .htaccess à la racine du serveur. On part du principe que notre script php de contrôle et de décodage s’appelle controleur.php mais vous pouvez bien entendu l’appeler comme bon vous semble.

RewriteEngine On
RewriteRule ^/?repertoire_mp3/([0-9]{10}+,[0-9a-f]{32},[a-zA-Z0-9_=-]+\.mp3)$    /controleur.php?filename=$1 [L]
RewriteRule ^/?repertoire_mp3/.*$ / [R=403]

La première règle interceptera tous les URL qui possèdent le bon motif pour être vérifiés par notre contrôleur. La deuxième règle rejettera tout autre URL, malformé, bidouillé, ou toute tentative d’accès direct aux fichiers mp3 par leur nom avec une erreur “accès interdit” (code d’erreur HTTP 403).

Les fonctions de codage des URL

Voici un développement procédural à l’aide de fonctions pour coder les algorithmes de génération des URL uniques provisoires :

if( !defined( SALT ) ) define( 'SALT', 'Votregraindesel' );
function code_url( $url ) {
  // Un URL encodé est composé de 3 parties séparées par des virgules :
  // 1 - le timestamp unix
  // 2 - un hash md5 de ce même timestamp avec un SALT secret
  // 3 - le nom du fichier crypté en blowfish avec le salt + les 8 derniers chiffres du timestamp en vecteur d'init
  // Les url varient donc à chaque seconde
  $timestamp = time();
  $ts_verif = md5( $timestamp . SALT );
  $url_code = yd_encrypt( $url, substr( $timestamp, -8, 8 ) );
  return $timestamp . ',' . $ts_verif . ',' . $url_code . '.mp3';
}
function yd_encrypt( $text, $iv ) {
  return trim( ydurlencode( base64_encode( mcrypt_encrypt( MCRYPT_BLOWFISH, SALT, $text, MCRYPT_MODE_CBC, $iv ) ) ) );
}
function ydurlencode( $text ) {
  // cette fonction est nécessaire car les séquences répétées de + ou / peuvent poser problème
  // (elles sont souvent bloquées par le mod_secure de l'hébergeur)
    	$text =  str_replace( array( '+', '/' ), array( '-', '_' ), $text );
    	return $text;
}

Voici à quoi peut ressembler un URL généré avec ces fonctions, sachant qu’il change complètement à chaque seconde :

1283893146,96e339e1a73c1201441e957c4b736b77,QxHcn8Di5SGrLPiuUsOUndtWXD6CLr_8Yy0NMwSMC7o=.mp3

…Vous constaterez que même en connaissant le “mode de fabrication” ci-dessus il n’est pas trivial de reconstituer l’emplacement physique et le nom du fichier mp3 demandé ! Il est également tout aussi difficile, voire impossible de générer artificiellement un URL de la même forme pour demander ce même fichier au bout d’une heure, puisque le “hachage” MD5 n’est pas réversible. Quand bien même on connaîtrait le nom d’un autre fichier, il est tout aussi impossible d’en trouver une clé d’accès, l’algorithme BLOWFISH n’étant pas codable/décodable sans connaître la clé secrète.

L’algorithme de contrôle et de décodage

Maintenant qu’on a bien “masqué” l’adresse réelle de nos fichiers, voici les différentes étapes (toujours en procédural) qui permettent le contrôle et le décodage de ces URL dans le fichier controleur.php :

  • Tout d’abord, on peut séparer les 3 composantes qui forment un URL crypté comme ceci :
$url=$_GET["filename"];
list( $timestamp, $ts_verif, $coded_url ) = split( ',', $url );
$coded_url = preg_replace( '/\.mp3$/', '', $coded_url );
  • Ensuite, on peut vérifier la validité du marqueur temporel comme ceci :
function yd_verify_ts( $timestamp, $ts_verif ) {
  // Les url ne sont plus utilisables au-delà de la durée de validite définie ci-dessous
  $duree_validite = 60 * 60; // en secondes (ici : une heure )
  $now = time();
  if( $timestamp > $now ) return false; // timestamp dans le futur !?
  if( ( $now - $timestamp ) > $duree_validite ) return false; // ts. périmé
  $tsv = md5( $timestamp . SALT );
  if( $tsv == $ts_verif ) {
    return true; // la signature MD5 est OK
  } else {
    return false; // timestamp bidouillé, signature secrete invalide
  }
}
 
if( yd_verify_ts( $timestamp, $ts_verif ) ) {
  //...decoder l'URL
  //...puis envoyer le contenu du fichier
} else {
  //...renvoyer un message d'erreur (header 403...)
}
  • Pour décoder le nom de fichier, il faut décrypter le message préalablement encodé avec l’algorithme BLOWFISH, voici les fonctions nécessaires :
function yd_decrypt( $text, $iv ) {
  return trim( mcrypt_decrypt( MCRYPT_BLOWFISH, SALT, base64_decode( ydurldecode( $text ) ), MCRYPT_MODE_CBC, $iv ) );
}
function ydurldecode( $text ) {
    	$text =  str_replace( array( '-', '_' ), array( '+', '/' ), $text );
    	return $text;
}
 
$filename = 'repertoire_mp3/' . yd_decrypt( $coded_url, substr( $timestamp, -8, 8 ) );
  • Enfin, voici comment renvoyer, au choix, tout ou partie du fichier physique en PHP :
// Envoi du fichier
 header('Content-Transfer-Encoding: none');
 header('Content-Type: application/octetstream; name="'.$filename.'"');
 header('Content-Disposition: attachment; filename="'.$filename.'"');
 header('Content-length: '.filesize($filename));
 header("Pragma: no-cache");
 header("Cache-Control: must-revalidate, post-check=0, pre-check=0, public");
 header("Expires: 0"); 
 
 // décommenter ci-dessous pour envoyer le fichier complet :
 
  //@readfile( $filename ) OR die();
 
 // commenter ci-dessous si on renvoie le fichier complet :
 
 echo file_get_contents( $filename, null, null, 0, ( 35 * ( 160000 / 8 ) ) ); // 35 secondes à 160 kbps.

(à noter que pour simplifier cet exemple, j’ai triché sur la taille du fichier : en toute rigueur il faudrait corriger le header Content-length quand on tronque artificiellement le fichier mp3 ! – A noter aussi que c’est une façon un peu barbare d’interrompre le flux mp3, mais la plupart des navigateurs y survivent)

Une implémentation sous forme de classe PHP5

Bien que toutes les recettes ci-dessus puissent parfaitement être mises en oeuvre sous forme de fonctions en PHP4 et fonctionnent parfaitement, il peut être plus élégant de regrouper l’ensemble des outils dans une classe PHP5 qu’on pourra inclure depuis un fichier séparé et instancier sous forme d’objet. Voici un exemple d’implémentation (j’ai choisi de créer un unique objet “secret” dont on peut invoquer différentes méthodes de codage et décodage, et qui expose quelques attributs nécessaires à l’usage détaillé dans les spécifications; il y a bien d’autres implémentations possibles, et sans doute de meilleures !)

< ?php
/**
 *
 * Implémente un objet secret contenant un message qui peut être crypté ou décrypté
 *
 * @author ydubois
 * @copyright 09/2010 Yann Dubois
 *
 *  This program is free software; you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation; either version 2 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program; if not, write to the Free Software
 *  Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
 *
 */
class yd_secret {
	const SALT = 'Secret';			// clé secrète
	const SUFFIX = '.mp3';
	const DUREE_VALIDITE = 3600;	// en secondes
 
	public $msg = '';
	public $hidden_url = '';
	public $valid = FALSE;
	public $encrypted = FALSE;
 
	private $iv = '00000000';
	private $timestamp = 0;
	private $ts_verif = '';
 
	/**
	 *
	 * Construit un objet secret avec le message passé en paramètre
	 *
	 * @param string $text texte du message secret
	 * @param string $iv (optional) 8 bytes initialization vector (IV) / FALSE to reset vector
	 */
	function __construct( $text = '', $iv = FALSE ) {
		$this->msg = $text;
		self::reset_iv( $iv );
	}
	/**
	 *
	 * Réinitialise ou fixe le vecteur d'initialisation (IV)
	 *
	 * @param string $iv (optional) 8 bytes initialization vector (IV) / FALSE to reset vector
	 */
	public function reset_iv( $iv = FALSE ) {
		if( $iv ) {
			$this->timestamp = $iv;
			$this->iv = substr( $iv, -8, 8 );
		} else {
			$this->timestamp = time();
			$this->iv = substr( $this->timestamp, -8, 8 );
		}
	}
	/**
	 *
	 * Encrypte le message et fournit un URL masqué dans $this->hidden_url
	 *
	 * @return TRUE si succès ou message déjà crypté
	 */
	public function encrypt() {
		if( $this->encrypted ) {
			return TRUE;
		} else {
			$this->encrypted = TRUE;
			$this->msg = trim(
				self::uencode(
					base64_encode(
						mcrypt_encrypt(
							MCRYPT_BLOWFISH,
							self::SALT,
							$this->msg,
							MCRYPT_MODE_CBC,
							$this->iv
						)
					)
				)
			);
			$this->ts_verif = md5( $this->timestamp . self::SALT );
			// Un URL encodé est composé de 3 parties séparées par des virgules :
			// 1 - le timestamp unix
			// 2 - un hash md5 de ce même timestamp avec un SALT secret
			// 3 - le nom du fichier crypté en blowfish avec le salt + les 8 derniers chiffres du timestamp en vecteur d'init
			// Les url varient donc à chaque seconde (à chaque instanciation)
			$this->hidden_url = $this->timestamp . ',' . $this->ts_verif . ',' . $this->msg . self::SUFFIX;
			return TRUE;
		}
	}
	/**
	 *
	 * Décrypte le message stocké dans $this->msg
	 *
	 * @return TRUE si succès ou message déjà décrypté
	 */
	public function decrypt() {
		if( !$this->encrypted ) {
			return TRUE;
		} else {
			$this->encrypted = FALSE;
			$this->msg = trim(
				mcrypt_decrypt(
					MCRYPT_BLOWFISH,
					self::SALT,
					base64_decode(
						self::udecode( $this->msg )
					),
					MCRYPT_MODE_CBC,
					$this->iv
				)
			);
			return TRUE;
		}
	}
	/**
	 *
	 * Charge un URL, vérifie sa validité, et stocke la partie cryptée dans msg
	 *
	 * @param string $url un URL masqué selon le motif décrit dans la fonction encrypt()
	 * @return boolean $valid yes/no
	 */
	public function load_url( $url ) {
		list( $timestamp, $ts_verif, $coded_url ) = split( ',', $url );
		$coded_url = preg_replace( '/' . quotemeta( self::SUFFIX ) . '$/', '', $coded_url );
		$now = time();
		if(
				$timestamp > $now
			||	( $now - $timestamp ) > self::DUREE_VALIDITE
			|| 	$ts_verif != md5( $timestamp . self::SALT )
		) {
			$this->valid = FALSE;
		} else {
			$this->msg = $coded_url;
			$this->encrypted = TRUE;
			$this->iv = substr( $timestamp, -8, 8 );
			$this->valid = TRUE;
		}
		return $this->valid;
	}
	/**
	 *
	 * Substitue les caractères qui posent problèmes de sécurité dans les URL
	 *
	 * @param string $text
	 * @return string $text chaine réencodée
	 */
	private function uencode( $text ) {
    	$text =  str_replace( array( '+', '/' ), array( '-', '_' ), $text );
    	return $text;
	}
	/**
	 *
	 * Rétablit les caractères substitués avec uencode
	 *
	 * @param string $text
	 * @return string $text chaine réencodée
	 */
	private function udecode( $text ) {
		$text =  str_replace( array( '-', '_' ), array( '+', '/' ), $text );
    	return $text;
	}
}
?>

Et voici comment utiliser cette classe pour coder vos URLs :

$my_secret = new yd_secret( $nom_de_fichier_en_clair );
$my_secret->encrypt();
$secret_url = $my_secret->hidden_url;

…Et pour les décoder (dans controleur.php par exemple ) :

$url=$_GET["filename"];
$secret = new yd_secret();
$secret->load_url( $url );
if( $secret->valid ) {
  $secret->decrypt();
  $filename = 'repertoire_mp3/' . $secret->msg;
  //... afficher le contenu...
} else {
  //... message d'injure violent...
}

Conclusion

Le mécanisme exposé ci-dessus a été développé pour sécuriser un minimum des accès direct à des fichiers mp3 payants. Mais il fonctionnerait tout aussi bien pour restreindre ou contrôler l’accès à toute autre type de ressource à télécharger sur un serveur web : fichiers vidéo, photo et autres images, documents pdf, etc avec “des urls qui changent tout le temps”… C’est un mécanisme totalement indépendant du type de fichier que l’on souhaite protéger par un “brouillage des URLs”.

Si vous avez des suggestions d’autres implémentations ou des améliorations à proposer, je suis ouvert à vos suggestions dans les commentaires ci-dessous !

A lire également...

WordPress › Error

There has been a critical error on your website.

Learn more about debugging in WordPress.