Le Granuloshop tourne sur un PC Beelink sous Windows 10 Pro, IIS 10 et SQL Server 2016. Le site est en Classic ASP — une technologie de 1996, toujours en service, toujours capable de faire des erreurs 500 sans rien dire. Ce billet raconte la mise en place d'une page d'erreur personnalisée qui fait deux choses simples : ne rien exposer au visiteur, et tout dire au développeur.
Le point de départ : des champs vides
La page d'erreur existante (errors/error500-100.asp) s'affichait bien, mais les champs strNumber, strPage et strDesc étaient tous vides. En apparence un problème de code, en réalité un problème de configuration IIS.
La cause : Server.GetLastError() ne retourne un objet valide que si la page d'erreur est déclarée via l'attribut defaultPath de la section <httpErrors> dans le web.config. Pas via <customErrors> (ASP.NET, ignoré pour Classic ASP), pas via un <error statusCode> isolé : uniquement via defaultPath.
<httpErrors errorMode="DetailedLocalOnly"
defaultPath="/errors/error500-100.asp"
defaultResponseMode="ExecuteURL">
<remove statusCode="500" subStatusCode="100" />
<error statusCode="500" subStatusCode="100"
path="/errors/error500-100.asp"
responseMode="ExecuteURL" />
</httpErrors>
httpErrors est verrouillée par défaut au niveau serveur. Tenter de la définir dans un web.config de site provoque une « violation de verrouillage » (erreur ligne 29). Solution : déverrouiller depuis une invite administrateur, ou — ce qui s'est révélé être déjà fait — configurer directement dans applicationHost.config via appcmd.
Architecture de la page : deux visages
Le principe est simple : une seule page, deux rendus selon l'IP du visiteur. Pour un visiteur externe, un message neutre sans aucune information interne. Pour le développeur (détecté par IP), le détail complet de l'erreur, le contexte HTTP, l'extrait de code source autour de la ligne fautive, et le statut du logging et du mail.
Const ADMIN_IP = "127.0.0.1,192.168.0.*"
Dim bIsAdmin : bIsAdmin = False
Dim arrAdminIPs : arrAdminIPs = Split(ADMIN_IP, ",")
For i = 0 To UBound(arrAdminIPs)
If IPMatch(Trim(arrAdminIPs(i)), strRemoteIP) Then
bIsAdmin = True : Exit For
End If
Next
La fonction IPMatch supporte les wildcards par octet (192.168.0.*, 192.168.*.*), ce qui évite de lister une à une toutes les machines du réseau local.
Logging dans un fichier texte avec purge automatique
Chaque erreur est inscrite dans un fichier journalier err500_YYYYMMDD.log dans un dossier dédié. Le log contient l'ID d'incident, l'IP, l'URL originale reconstituée, les champs de l'objet ASPError, et un extrait du fichier source fautif (±10 lignes autour de la ligne d'erreur).
Un point d'attention sur les permissions : le compte qui exécute le pool d'applications n'est pas IIS_IUSRS si le pool a une identité dédiée. Sur ce serveur le pool tourne sous IIS APPPOOL\granuloshop, visible dans C:\Users\. La commande correcte est donc :
icacls "C:\...\errors" /grant "IIS APPPOOL\granuloshop:(OI)(CI)M" /T
Un script de purge passe en revue les fichiers .log du dossier et supprime ceux de plus de 30 jours. Il s'exécute à chaque appel de la page d'erreur, ce qui est suffisant en pratique.
Alerte mail avec anti-flood
Le mail est envoyé via CDO.Message (natif Windows, sans composant tiers) avec authentification SSL sur smtp.free.fr:465. Un compteur journalier écrit dans un fichier texte limite les envois à 5 mails par jour — au-delà les erreurs sont toujours loggées mais aucun mail ne part.
oMail.Configuration.Fields.Item( _
"http://schemas.microsoft.com/cdo/configuration/smtpserver") = "smtp.free.fr"
oMail.Configuration.Fields.Item( _
"http://schemas.microsoft.com/cdo/configuration/smtpserverport") = 465
oMail.Configuration.Fields.Item( _
"http://schemas.microsoft.com/cdo/configuration/smtpusessl") = 1
oMail.Configuration.Fields.Item( _
"http://schemas.microsoft.com/cdo/configuration/smtpauthenticate") = 1
Extraction de l'URL originale depuis la query string IIS
Quand IIS appelle la page d'erreur via ExecuteURL, il passe l'URL fautive en query string sous la forme 500;https://granuloshop.com:443/vc30/default.asp?ln=fr. La page d'erreur doit donc décomposer cette chaîne pour en extraire le chemin original et ses paramètres :
Dim nSemicolon : nSemicolon = InStr(strQueryString, ";")
If nSemicolon > 0 Then
Dim strAfterSemi : strAfterSemi = Mid(strQueryString, nSemicolon + 1)
Dim nQmark : nQmark = InStr(strAfterSemi, "?")
If nQmark > 0 Then
' Extraire le chemin depuis le dernier "/" avant "?"
' puis isoler la query string originale
End If
End If
Sans ce traitement, le log et le mail contiennent l'URL brute d'IIS plutôt que l'URL de la page fautive, ce qui rend le diagnostic difficile.
Extrait du code source autour de la ligne fautive
Server.GetLastError() retourne le chemin virtuel du fichier fautif et le numéro de ligne. Il faut convertir ce chemin virtuel en chemin physique avec Server.MapPath(), lire le fichier avec FileSystemObject, et afficher les 10 lignes avant et après la ligne incriminée. La ligne fautive est marquée >>>.
Cela nécessite que le compte du pool IIS ait accès en lecture au répertoire du site — ce qui est le cas puisque c'est lui qui exécute les pages ASP.
Le vrai problème : sendErrors n'existe pas sur IIS 10
La propriété sendErrors de la section system.webServer/asp, documentée dans certains articles pour permettre à Server.GetLastError() de recevoir l'objet erreur, n'existe tout simplement pas dans cette version d'IIS. La commande appcmd set config -section:system.webServer/asp /sendErrors:true retourne une erreur « Attribut inconnu ».
En pratique, Server.GetLastError() fonctionne malgré tout via defaultPath + ExecuteURL, mais uniquement lorsque l'erreur se produit pendant l'exécution ASP. Si l'erreur survient avant (erreur de parse, #include manquant, composant COM absent), l'objet retourné est vide. C'est le cas le plus fréquent sur ce serveur.
Diagnostic : trouver la vraie cause des 500
Puisque Server.GetLastError() restait vide, le diagnostic a été fait dans les logs IIS natifs (%SystemDrive%\inetpub\logs\LogFiles\W3SVC2\). Le sc-substatus 500 0 0 avec le message ASP_0145 | echec_de_la_nouvelle_application indique qu'un Server.CreateObject() échoue — autrement dit un composant COM n'est pas enregistré sur le serveur.
Une recherche globale de tous les CreateObject dans le code du site a permis d'identifier les suspects :
findstr /i /s "createobject" "C:\...\*.asp" > createobjects.txt
Résultat : SMTPsvg.Mailer utilisé dans contactexpress_en.asp était absent du registre Windows. Ce composant SMTP tiers, datant de l'ère IIS 5, n'est plus compatible. Il a été remplacé par CDO.Message, natif depuis Windows 2000.
reponse.asp, est lui toujours enregistré dans le registre. Il n'est pas la cause des erreurs sur les pages publiques.
Ce que la page 500 ne peut pas toujours capturer
Une page d'erreur personnalisée n'est pas un débogueur universel. Elle intervient seulement lorsque IIS a déjà réussi à transférer l'exécution vers cette page et que le moteur ASP peut encore fournir un objet ASPError. Dans les cas favorables, Server.GetLastError() donne le numéro d'erreur, le fichier, la ligne et la description. Dans d'autres cas, il ne renvoie pratiquement rien : numéro 0, fichier vide, ligne 0.
La distinction importante est la suivante : une erreur produite pendant l'exécution ASP est souvent capturable, y compris si le code vient d'un fichier inclus. Par exemple, une erreur SQL, une division par zéro ou un objet non initialisé peuvent remonter correctement. En revanche, une erreur qui se produit avant l'exécution réelle de la page est beaucoup plus difficile à capturer : erreur de syntaxe dans un #include, include manquant, composant COM absent, problème de compilation, ou erreur très précoce dans l'initialisation de l'application.
' Erreur souvent capturable : exécution ASP déjà commencée
Set rs = conn.Execute("SELECT * FROM table_absente")
' Erreur souvent non capturable proprement : include non compilable
<!--#include file="include/shop.asp"-->
' ... si shop.asp contient une erreur de syntaxe ou un composant absent
ErrNum = 0, Fichier vide et Ligne = 0, il ne faut pas conclure que l'erreur n'existe pas. Cela signifie plutôt que Server.GetLastError() n'a pas reçu d'information exploitable. Dans ce cas, le log IIS natif devient la source principale du diagnostic.
Amélioration : croiser l'erreur ASP avec le log IIS natif
La page d'erreur a donc été enrichie pour ne plus dépendre uniquement de Server.GetLastError(). Elle ajoute désormais au log et au mail un extrait du journal IIS du jour, recherché à partir de l'heure, de l'IP visiteur, du chemin de la page et de la query string. Cette approche est particulièrement utile lorsque l'objet ASPError est vide.
Const IIS_LOG_FOLDER = "C:\inetpub\logs\LogFiles\W3SVC2\"
' Exemple de fichier IIS du 12 juin 2026 :
' C:\inetpub\logs\LogFiles\W3SVC2\u_ex260612.log
' Colonnes utiles dans le format W3C :
' date time s-ip cs-method cs-uri-stem cs-uri-query s-port ...
' c-ip cs(User-Agent) cs(Referer) sc-status sc-substatus sc-win32-status time-taken
Il faut faire attention à conserver le chemin réel retourné par IIS. Par exemple, /vc30/default.asp ne doit pas être réduit à /default.asp, sinon la recherche dans le journal IIS peut manquer la ligne fautive.
2026-06-12 02:55:08 192.168.0.4 GET /vc30/default.asp ln=fr 443 - 66.150.196.179 ... 500 100 0 125
Pour que la page d'erreur puisse lire ce journal, le compte du pool d'applications doit avoir un droit de lecture sur le dossier des logs IIS :
icacls "C:\inetpub\logs\LogFiles\W3SVC2" /grant "IIS APPPOOL\granuloshop:(RX)" /T
Amélioration : enrichir le contexte HTTP
Le mail d'alerte contient maintenant plus que l'erreur ASP : URL originale, URL brute reçue par la page d'erreur, méthode HTTP, IP, user-agent, referrer, query string et variables serveur significatives. Deux variables sont particulièrement pratiques pour les cas ambigus : ALL_HTTP et ALL_RAW.
strMailBody = strMailBody & "--- ALL_HTTP ---" & vbCrLf
strMailBody = strMailBody & Request.ServerVariables("ALL_HTTP") & vbCrLf & vbCrLf
strMailBody = strMailBody & "--- ALL_RAW ---" & vbCrLf
strMailBody = strMailBody & Request.ServerVariables("ALL_RAW") & vbCrLf & vbCrLf
Le but n'est pas d'exposer ces informations au visiteur. Le visiteur reçoit toujours un message neutre avec un identifiant d'incident. Le détail technique reste réservé au fichier log, au mail d'alerte et à la vue administrateur locale.
Amélioration : protéger les logs secondaires
Un piège classique est de provoquer une seconde erreur pendant le traitement de la première. Dans un vieux site ASP, cela arrive facilement avec un fichier de log applicatif : dossier absent, droit d'écriture insuffisant, fichier vide lu avec ReadAll, fichier verrouillé, chemin mal reconstruit. Le code de logging doit donc être tolérant : il ne doit jamais faire tomber la page.
Sub SafeAppendTextFile(path, text)
On Error Resume Next
Dim fso, folder, ts
Set fso = Server.CreateObject("Scripting.FileSystemObject")
folder = fso.GetParentFolderName(path)
If Not fso.FolderExists(folder) Then
fso.CreateFolder folder
End If
Set ts = fso.OpenTextFile(path, 8, True)
If Err.Number = 0 Then
ts.Write text
ts.Close
End If
Err.Clear
On Error GoTo 0
End Sub
De même, avant de lire un fichier existant avec ReadAll, il est prudent de vérifier sa taille. Un fichier de zéro octet peut suffire à produire une erreur du type « Entrée dépasse la fin du fichier ».
If objFSO.FileExists(Log_FileName) Then
If objFSO.GetFile(Log_FileName).Size > 0 Then
Set ObjTStream = objFSO.OpenTextFile(Log_FileName, 1, False)
StrLog = ObjTStream.ReadAll
ObjTStream.Close
Else
StrLog = ""
End If
Else
StrLog = ""
End If
Cette règle est importante : une routine de surveillance ne doit jamais devenir une nouvelle source d'erreur 500. Si elle échoue, elle doit abandonner silencieusement ou inscrire un message minimal dans un autre canal, mais ne pas bloquer la réponse HTTP.
Méthode de diagnostic quand tout est vide
Quand Server.GetLastError() ne donne rien, la méthode pratique consiste à croiser trois sources : le mail d'alerte pour l'heure, l'IP et l'URL ; le log applicatif de la page d'erreur pour l'identifiant d'incident ; puis le log IIS natif pour le statut réel sc-status, sc-substatus et sc-win32-status.
findstr /i "66.150.196.179 /vc30/default.asp ln=fr 500" C:\inetpub\logs\LogFiles\W3SVC2\u_ex260612.log
Si cela ne suffit pas, l'ancienne méthode des marqueurs reste efficace en Classic ASP : placer temporairement des Response.Write et Response.Flush dans l'include suspect, puis rechercher le dernier marqueur affiché avant la 500. Ce n'est pas élégant, mais sur un site historique avec beaucoup d'includes et de composants COM, c'est souvent le chemin le plus court vers la ligne fautive.
Bilan
La page d'erreur est en service. Pour un visiteur externe : un message neutre avec une référence d'incident. Pour le développeur connecté depuis le réseau local : le détail complet avec extrait source, statut du mail et du log. Chaque erreur est loggée et une alerte mail part (dans la limite de 5 par jour).
Ce qui ne fonctionne pas encore : Server.GetLastError() reste vide quand l'erreur se produit avant l'exécution ASP. Pour ces cas, les logs IIS natifs restent la seule source d'information. Le travail de remplacement des composants COM obsolètes (SMTPsvg.Mailer, et d'autres) permettra progressivement de réduire le nombre de ces erreurs.