Requête Ajax Cross-domain avec la balise <script>

15 mai 2008Ajax, Javascript, XMLHttpRequest, cross-domain
 Cet article a été rédigé il y a plusieurs années et peut ne plus être tout à fait à jour…

La principale limitation que nous avons rencontré lorsque nous avons commencé à effectuer des requêtes Ajax est la contrainte de "Same Origin Policy" qui nous est imposée par les paramètres de sécurité des navigateurs : nos requêtes Ajax ne peuvent être effectuées que vers le domaine sur lequel notre site est déployé.

Typiquement, depuis "blog.pascal.martin.fr", il n'est pas permis d'effectuer de requête Ajax vers "www.google.com" : le navigateur l'interdit.

Cet article va nous permettre d'apprendre à contourner cette sécurité, en effectuant des requêtes Ajax sans passer par l'objet XmlHttpRequest, mais en écrivant dynamiquement des balises <script>.

Application d'exemple

Pour mes exemples au sein de cet article, je considérerai que nous souhaitons développer une mini-application dont le but est d'effectuer des multiplications :

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
  <meta http-equiv="content-type" content="text/html; charset=utf-8"/>
  <title>Démo Ajax serveur distant</title>
  <script type="text/javascript" src="lib/prototype.js"></script>
  <script type="text/javascript" src="ajax-distant.js"></script>
</head>
<body>
  <form action="" id="multiplier" onsubmit="return false;">
    <div>
      <input type="text" maxlength="6" size="8" id="a" name="a" />
      *
      <input type="text" maxlength="6" size="8" id="b" name="b" />
      <input type="button" name="do" id="do" value="=" onclick="multiply();" />
      <input type="text" size="8" id="result" name="result" readonly="readonly" />
    </div>
  </form>
  <div id="msg">&nbsp;</div>

  <script type="text/javascript">
    // Appui sur "ENTER" dans le formulaire => lance la multiplication
    Event.observe($('multiplier'), 'keydown', function (evt) {
        if (evt.keyCode == 13) {
            multiply();
        }
    });
  </script>

</body>
</html>

Deux champs de saisie, un bouton pour lancer la multiplication, et un champ pour afficher le résultat :

Application multiplier

Dans le principe, nul besoin d'effectuer une requête Ajax pour effectuer une multiplication : le code Javascript suivant ferait parfaitement l'affaire :

var multiply_noAjax = function () {
    var a = parseFloat($F('a'));
    var b = parseFloat($F('b'));
    $('result').value = a*b;
}; // multiply_noAjax

Pour faciliter les écritures, et ne pas avoir à changer la partie HTML de notre application, nous passerons par un alias : pour chaque nouvelle version, nous affecterons une nouvelle fonction à la variable multiply, par laquelle l'application passera toujours.

En travaillant avec la version "sans Ajax", donc :

var multiply = multiply_noAjax;

Autrement dit, appeler la fonction multiply revient en fait à appeler la fonction multiply_noAjax.


Un appel Ajax "normal"

Passons maintenant à une version Ajaxifiée de notre application de calcul de multiplications : les deux opérandes sont passées, via un appel Ajax, à une application côté serveur, qui, elle, effectue la multiplication, et retourne le résultat.

Le code Javascript n'a alors plus qu'à afficher ledit résultat dans le champ correspondant, sans avoir à effectuer le moindre calcul.

Pour simplifier les choses (en particulier, la compatibilité cross-navigateur), j'utiliserai le Framework javascript prototype, que j'ai déjà présenté au cours de plusieurs articles.
Les données de retour transiteront au format JSON.
Je vous conseille donc la lecture des articles suivants :

Voici le code de la fonction Javascript effectuant l'appel Ajax :

var multiply_ajaxBase = function (url) {
    var a = parseFloat($F('a'));
    var b = parseFloat($F('b'));
    var myAjax = new Ajax.Request(
    url,
    {
      method: 'get',
      parameters: {
        a: a,
        b: b
      },
      onSuccess: function (xhr, jsonValue) {
          if (jsonValue) {
            $('result').value = jsonValue;
          }
        }, // onSuccess
      onException: function (xhr, e) {
          $('msg').innerHTML = e;
        } // onException
    });
}; // multiply_ajaxBase

Et voici comment elle est appelée, avec l'URL du service de calcul passée en paramètre.

var multiply_ajaxLocal = function () {
    multiply_ajaxBase('./multiplier-server.php');
}; // multiply_ajaxLocal

Naturellement, comme indiqué précédemment, nous aliasons cette fonction sous le nom multiply :

var multiply = multiply_ajaxLocal;

Et côté serveur, rien de très compliqué : on récupère les deux valeurs passées en GET, et on renvoit le résultat, au sein d'une en-tête X-JSON, tel qu'attendu par prototype :

$a = floatval($_GET['a']);
$b = floatval($_GET['b']);
$result = $a * $b;

// prototype.js attend des données dans
// une en-tête nommée X-JSON :
header('X-JSON:' . json_encode($result));

Note : A vous de gérer les éventuels cas d'erreurs (du genre si l'utilisateur saisit n'importe quoi)

Note 2 : Nous pourrions aussi renvoyer les données sur la sortie standard, et utiliser la propriété responseText de l'instance d'objet XMLHttpRequest côté JavaScript... Ce que nous ferions certainement si nous n'utilisions pas le framework Prototype.


Echec en cross-domain

A présent, que se passe-t'il si nous essayons d'appeler un service serveur localisé sur un autre nom de domaine que celui où se trouve notre page d'application cliente ?

var multiply_ajaxDistantErreur = function () {
    multiply_ajaxBase('http://distant.example.com/ajax-distant/multiplier-server.php');
} // multiply_ajaxDistantErreur

(Libre à vous d'adapter l'URL pour pointer vers un autre site ; le tout étant que l'URL comporte un nom de domaine autre que celui où est déployée l'application HTML+Javascript ; Au besoin, ajouter un alias dans le fichiers hosts de votre système, et un second VirtualHost Apache fonctionne parfaitement - c'est ce que j'ai fait pour écrire cet article, d'ailleurs ^^)

Et, encore une fois, avec l'alias qui va bien :

var multiply = multiply_ajaxDistantErreur;

Lorsque nous cliquons sur le bouton censé lancer le calcul, nous obtenons affichage d'un message d'erreur :

Error: Permission denied to call method XMLHttpRequest.open

C'est ce à quoi nous nous attendions : la contrainte de "Same Origin Policy" nous empéche de réaliser des appels Ajax vers un nom de domaine autre que celui où est déployée notre application.


Solution à base de balise <script>

La contrainte de SOP nous empêche, nous venons de le constater, d'effectuer des appels Ajax vers un autre nom de domaine... Du moins, en passant par l'objet XMLHttpRequest.

Heureusement (du moins, dans le cadre de ce que nous voulons faire), cette contrainte n'est pas appliquée à tous les chargements de données distants.

En particulier, elle ne s'applique pas aux chargements de scripts Javascript : rien ne vous empêche d'écrire des balises <script> incluant un fichier Javascript depuis un autre nom de domaine que celui où est déployée votre application.


Code Javascript générant une balise <script> dynamique

Maintenant, plutôt que d'effectuer un "vrai" appel Ajax, nous pourrions créer dynamiquement une balise <script> et l'injecter dans le DOM de notre page d'application... En ajouotant les paramètres qui nous intéressent dans l'URL indiquée en attribut "src" de cette balise.

Au moment où la balise <script> sera injectée dans le DOM, le navigateur effectuera un appel vers le serveur distant pour charger le code Javascript correspondant ; mais on peut tout à fait utiliser un script (PHP, pour nos exemples) pour générer du Javascript !

Donc, pour notre cas :

var multiply_scriptTag = function () {
    var a = parseFloat($F('a'));
    var b = parseFloat($F('b'));

    if ($('requestMultiplier')) {
        // Suppression de la balise si elle existait déjà
        // (pour faire un brin de ménage dans le DOM)
        $('requestMultiplier').remove();
    }

    // Création de la nouvelle balise :
    var script = document.createElement('script');
    script.src = 'http://distant.example.com/ajax-distant/multiplier-server-tag.php?';
    script.src += 'func=resultFunction';
    script.src += '&a=' + a;
    script.src += '&b=' + b;

    script.id = 'requestMultiplier';
    script.type = 'text/javascript';

    // Et injection dans le DOM :
    document.body.appendChild(script);
}; // multiply_scriptTag

Que fait cette fonction ?

  • S'il existe déjà une balise <script> utilisée pour effectuer cet appel distant, elle sera supprimée (inutile d'en avoir 36, avec seulement la dernière qui serve encore à quelque chose)
  • Création de la nouvelle balise :
    • Renseignement de l'URL du code Javascript à charger, en lui passant les paramètres qui nous intéressent, dont un nom de fonction à appeler une fois le calcul effectué,
    • Renseignement de l'id et du type de la balise,
  • Et ajout de la balise à la fin de la page HTML.

Pourquoi indiquer à la page distante quelle sera la fonction à appeler une fois le calcul effectué ?

Parce que le chargement du script distant se fait en dehors de notre contrôle : une fois la balise ajoutée au document, nous n'avons plus aucune maîtrise ; et, en particulier, nous ne savons pas quand le script aura fini de charger - et nous n'avons donc aucun moyen de récupérer le résultat du calcul !

Alors que si le programme serveur génère, en fin du code Javascript, un appel à cette fonction, en lui passant en paramètre le résultat du calcul, nous saurons que celui-ci s'est terminé, et quelle valeur a été retournée.


Fonction appelée une fois le script distant chargé

Voici donc la fonction qui devra être appelée une fois le calcul terminé - une fois le script distant chargé :

/**
 * Appelée au retour de l'appel "Ajax"
 */
var resultFunction = function (jsonValue) {
    if (jsonValue) {
        $('result').value = jsonValue;
    }
    else {
        $('msg').innerHTML = 'Erreur...';
    }
}; // resultFunction

Dans le principe, on récupère la valeur passée en paramètre, en on l'affiche dans le champ de résultat.


Page distante effectuant le calcul

Et pour la page appelée via l'attribut src de la balise <script> :

<?php
// Le calcul à effectuer
$a = floatval($_GET['a']);
$b = floatval($_GET['b']);
$result = $a * $b;

// Et la fonction JS qui devrait être appelée au retour
$funcResult = $_GET['func'];

$jsonResult = json_encode($result);
echo "$funcResult($jsonResult);";

Le principe est composé de trois étapes :

  • Récupération des paramètres,
  • Calcul,
  • Et écriture du code Javascript qui sera interprété par le navigateur
    • En l'occurrence, appel à la fonction chargée de gérer la valeur retournée.


Échange HTTP

Lorsque la balise <script> est dynamiquement ajoutée à la page, une requête HTTP est effectuée vers l'URL précisée en attribut src de celle-ci.

Cette requête HTTP, dans notre cas, est la suivante :

GET /ajax-distant/multiplier-server-tag.php?func=resultFunction&a=2&b=10 HTTP/1.1
Host: distant.example.com
User-Agent: Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9b5) Gecko/2008040603 Firefox/3.0b5
Accept: */*
Accept-Language: fr-fr,en-us;q=0.7,en;q=0.3
Accept-Encoding: gzip,deflate
Accept-Charset: UTF-8,*
Keep-Alive: 300
Connection: keep-alive
Referer: http://tests/ajax-distant/demo.html

Et une fois les calculs terminés, la réponse suivante est émise par le serveur :

HTTP/1.1 200 OK
Date: Mon, 14 Apr 2008 08:40:13 GMT
Server: Apache/2.2.4 (Ubuntu) DAV/2 SVN/1.4.4 mod_fastcgi/2.4.2 PHP/5.2.3-1ubuntu6.3 proxy_html/2.5 mod_perl/2.0.2 Perl/v5.8.8
X-Powered-By: PHP/5.2.3-1ubuntu6.3
Content-Length: 19

Keep-Alive: timeout=15, max=99
Connection: Keep-Alive
Content-Type: text/html; charset=UTF-8

resultFunction(20);

Du côté de l'application cliente, tout ce qui est vu par le navigateur est une portion de code Javascript à exécuter :

resultFunction(20);

Et, considérant la définition de la fonction resultFunction, cela provoquera l'affichage du résultat de la multiplication !


Format de données pour les échanges

Pour une donnée simple comme un résultat de multiplication, la question du format de données à utiliser pour les échanges est peu importante...

... Mais lorsque vous travaillerez avec des cas concrets d'appels distants, "quel format utiliser ?" est une question que vous vous poserez certainement.

J'ai tendance, dans ce genre de situations, à privilégier le format JSON :

  • Facile à générer côté serveur : il existe des bibliothèques pour la plupart des langages orientés Web
  • Facile à interpréter côté navigateur (c'est du code Javascript valide)
  • C'est du code Javascript valide[1] : considérant que votre appel distant se fait via une balise <script type="text/javascript">, il semble logique que le serveur génère... du Javascript !


Quelques points à noter

Avant de terminer, j'aimerais attirer votre attention sur un point : avec ce mécanisme, vous offrez au serveur distant la possibilité d'injecter n'importe quelle portion de code Javascript dans votre page !

Autrement dit, vous offrez au serveur distant la possibilité de faire à peu près ce qu'il veut sur la page de votre site, via une injection XSS... Que vous avez volontairement créé. En vrac :

  • Vol de cookies des utilisateurs,
  • Ajout de publicités lui rapportant de l'argent - et non à vous,
  • Affichage de n'importe quoi sur votre page - vous décrédibilisant éventuellement,
  • ...

Un exemple simple pour illustrer mes propos ?
Que se passera-t'il si le code du serveur distant contient la portion de code PHP suivante :

echo "document.getElementsByTagName('body')[0].innerHTML = 'MECHANT';";

Qui entraînera la génération de cette portion de code Javascript :

document.getElementsByTagName('body')[0].innerHTML = 'MECHANT';

Effectivement, en une ligne, le serveur distant a démoli votre site ^^

N'effectuez donc ce genre d'appel que vers des serveurs en lesquels vous avez entièrement confiance !


Note

[1] Oui, ça fait deux fois que je le dis ; mais c'est important