# Bugs d’encodage d’URL : testez les entrées API de l’agent avant les appels

Une URL n’est pas une chaîne inoffensive. C’est une instruction structurée dont les délimiteurs déterminent l’hôte, la route, les noms de paramètres et leurs valeurs. Si un agent combine incorrectement un texte fourni par l’utilisateur avec une URL, il peut appeler un autre point de terminaison que celui examiné par le développeur.

J’ai vu des équipes approuver une requête qui ressemblait à `GET /records/alice` dans le journal d’un outil, avant de passer l’après-midi à découvrir que le serveur avait reçu une route avec une barre oblique supplémentaire, un paramètre de requête en double ou une séquence de traversée décodée. L’agent n’avait pas besoin d’un exploit exotique. Un texte ordinaire comme `a+b`, `%2F`, `&admin=true` ou un nom dans un alphabet non latin suffisait.

Les bugs d’encodage d’URL sont faciles à minimiser, car un client HTTP « gère souvent l’encodage ». Il gère une partie de la sérialisation, selon sa propre API et ses valeurs par défaut. Il ne peut pas décider si une valeur utilisateur appartient à un segment de chemin, à une valeur de requête, au corps d’un formulaire ou à aucun de ces endroits. Cette décision doit figurer dans la définition de l’action et dans ses tests.

## C’est la cible de la requête, pas la chaîne visible, qui détermine l’appel

Une requête peut changer de sens chaque fois qu’un logiciel analyse ou reconstruit son URL. La séquence de caractères produite par l’agent n’est que le début. Votre bibliothèque cliente peut la normaliser, un proxy inverse peut la réécrire et le framework de l’application peut la décoder avant la correspondance de route ou la liaison des paramètres.

La RFC 3986 divise un URI en composants : schéma, autorité, chemin, requête et fragment. Dans un chemin, `/` est un délimiteur. Dans une requête, `&` et `=` prennent un sens selon les conventions courantes, même si la RFC 3986 ne définit pas de grammaire universelle pour les requêtes. Cette distinction explique la plupart des problèmes que l’on désigne vaguement comme des « problèmes d’encodage ».

Prenons une action destinée à récupérer un projet :

```text
GET https://api.example.test/projects/{project_id}
```

Si `project_id` vaut `north/ops`, ces deux cibles sont différentes :

```text
/projects/north/ops
/projects/north%2Fops
```

La première contient deux segments après `projects`. La seconde tente de transporter une barre oblique littérale dans un seul segment. La conservation de cette forme dépend de toute la chaîne entre le client et l’application. Certains environnements décodent `%2F` avant la correspondance et la transforment en première forme. D’autres la refusent. Un test client qui vérifie seulement que « l’URL a été encodée » ne prouve presque rien.

Le même piège existe dans les requêtes. Une action qui doit rechercher une expression littérale ne devrait pas construire ceci :

```text
/search?q=USER_TEXT
```

en remplaçant `USER_TEXT` par du texte brut. Avec `red&limit=500`, la cible finale peut devenir :

```text
/search?q=red&limit=500
```

L’application voit alors deux paramètres. Si le code crée un objet de requête et fournit `red&limit=500` comme valeur de `q`, il devrait produire :

```text
/search?q=red%26limit%3D500
```

C’est cette limite concrète qu’il faut tester : un champ sémantique en entrée, une cible exacte en sortie, puis le même champ sémantique analysé à destination.

Ne testez pas seulement avec des lettres et des chiffres. Ces valeurs masquent précisément les bugs que les agents révèlent. Un agent reçoit des tickets d’assistance, des titres d’incidents, des noms de branches, des chemins de fichiers, des URL copiées et du texte libre. Les entrées réelles contiennent des délimiteurs.

## Un paramètre de chemin est un segment, pas une URL incomplète

Traitez un paramètre de chemin comme un seul segment, sauf si le contrat de l’API indique explicitement qu’il accepte un chemin. Cette règle élimine une quantité surprenante d’ambiguïtés.

Les développeurs concatènent souvent les chemins parce que le résultat semble lisible :

```javascript
const target = base + "/projects/" + projectId + "/builds";
```

Ce code n’attribue aucune signification propre au composant `projectId`. Si la valeur contient `/`, `?`, `#` ou `%`, le résultat dépend de ce que le code suivant fait avec `target`. Cela invite aussi à une deuxième erreur : quelqu’un voit une valeur encodée dans un journal, applique de nouveau `encodeURIComponent` « par sécurité » et produit un identifiant différent.

Construisez les segments comme des données, encodez chaque segment une seule fois et ne joignez que les séparateurs appartenant à la route. En JavaScript, ce petit utilitaire rend le contrat visible :

```javascript
function pathSegment(value) {
  if (typeof value !== "string" || value.length === 0) {
    throw new Error("project id must be a nonempty string");
  }
  return encodeURIComponent(value);
}

const path = "/projects/" + pathSegment(projectId) + "/builds";
```

`encodeURIComponent` convient ici, car il encode `/`, `?`, `#`, `&` et `=` qui pourraient autrement modifier le chemin, commencer une requête ou un fragment. Il laisse toutefois quelques caractères RFC 3986 non échappés, dont les apostrophes et les parenthèses. Cela ne change généralement pas la structure du chemin, mais un contrat d’API strict peut exiger un encodeur plus précis. Décidez-en à partir de la spécification de l’API, pas par habitude.

N’utilisez pas `encodeURI` pour un segment unique. Cette fonction conserve les délimiteurs URI, car elle attend un URI complet. Si vous lui transmettez `north/ops`, la barre oblique reste présente et la route change. Le nom de la fonction semble approprié, mais son périmètre ne l’est pas.

Un chemin peut légitimement contenir plusieurs segments, par exemple si une API définit `/{owner}/{repository}`. Modélisez alors deux champs, et non une chaîne libre `resourcePath`. Si votre point de terminaison a réellement besoin d’un identifiant opaque pouvant contenir des barres obliques, envisagez de le placer dans un paramètre de requête ou dans un corps JSON. Les API qui forcent du texte opaque à passer par les couches de routage obligent chacun à deviner le comportement des barres encodées.

L’encodage côté client ne peut pas non plus corriger certaines décisions de routage. De nombreux proxies et frameworks normalisent les segments `.` et `..`, regroupent les barres obliques répétées ou refusent les séparateurs encodés. Posez au responsable du point de terminaison une question directe : la correspondance de route a-t-elle lieu avant ou après le décodage percent ? Testez ensuite la route déployée, proxy compris. La documentation d’un framework exécuté seul sur le poste d’un développeur ne répond pas à cette question.

## Les chaînes de requête ont besoin d’une grammaire déclarée

Une chaîne de requête n’est pas un bloc unique à échapper. C’est un ensemble de champs dont la grammaire appartient à l’API. Il faut décider à l’avance du comportement des noms répétés, des valeurs vides, des tableaux, des booléens, des espaces et des doublons.

Les API orientées navigateur et la norme WHATWG utilisent une sérialisation de type formulaire pour `URLSearchParams`. Dans cette convention, un espace devient souvent `+` et un plus littéral devient `%2B`. De nombreux analyseurs serveur utilisent la même convention. La RFC 3986 ne dit toutefois pas que `+` signifie un espace dans un URI générique. Ces deux faits comptent lorsqu’un composant utilise un analyseur générique et un autre un analyseur de formulaire.

Utilisez un générateur de requête plutôt que des modèles de chaînes :

```javascript
const query = new URLSearchParams();
query.set("q", userText);
query.set("include_archived", "false");
for (const label of labels) query.append("label", label);

const url = "https://api.example.test/search?" + query.toString();
```

Pour `userText = "C++ & systems"`, l’écriture exacte peut être `q=C%2B%2B+%26+systems`. Un serveur qui applique le décodage des formulaires doit retrouver `C++ & systems`. Votre test de régression doit vérifier la valeur sémantique attendue par l’API, et non imposer à toutes les bibliothèques d’utiliser `%20` pour les espaces. `%20` et `+` peuvent tous deux représenter un espace selon les conventions de requête rencontrées ; un plus littéral doit rester un plus après l’analyse.

Les champs répétés exigent une décision explicite. Ces trois contrats sont différents :

```text
?label=bug&label=security
?label=bug,security
?label=["bug","security"]
```

Le premier utilise un nom répété. Le deuxième est une valeur contenant une virgule, sauf indication contraire de l’API. Le troisième ressemble à du JSON, mais n’est pas du JSON tant qu’un serveur ne l’analyse pas volontairement. Ne dites pas à un agent de « passer les labels dans l’URL » sans préciser le format. Donnez à l’action un argument tableau et faites-lui utiliser la seule forme acceptée par le point de terminaison.

Les paramètres scalaires en double constituent un autre échec discret. Une requête `?role=user&role=admin` peut produire la première valeur, la dernière, un tableau ou une erreur selon le framework. Une vérification de sécurité qui lit la première valeur alors qu’un service en aval lit la dernière crée une décision divergente. Refusez les doublons pour les champs qui ne doivent apparaître qu’une fois, dès le premier composant que vous contrôlez.

Les fragments méritent aussi une mention. `#section` ne quitte généralement jamais le client comme partie d’une requête HTTP. Si une donnée utilisateur brute contient `#`, elle peut retirer tout ce qui suit d’un objet URL avant l’envoi. Encodez ce caractère dans une valeur de chemin ou de requête lorsqu’il s’agit d’une donnée. Ne vous fiez pas au journal de la chaîne source pour savoir ce qui a atteint le serveur.

## Les signes percent et l’ordre des décodages créent des identifiants différents

Décodez les séquences percent une seule fois, à une frontière définie. Si deux composants décodent la même entrée, une valeur apparemment inoffensive peut devenir un délimiteur après avoir passé la première vérification.

Prenez le texte `%252F`. Un premier décodage donne `%2F`. Un second donne `/`. Cela compte lorsque la validation intervient entre ces deux opérations. Une passerelle peut refuser `/` dans un identifiant et accepter `%252F`, puis une application en amont peut décoder une deuxième fois et découper la route. Le même phénomène s’applique à `%252e`, qui devient `%2e`, puis `.`.

Distinguez trois valeurs dans les discussions de conception et dans les tests :

1. L’écriture brute sur le réseau, comme `%252F`.
2. La valeur après un décodage percent, comme `%2F`.
3. La valeur de l’application après tous les analyseurs et toutes les réécritures, comme `/`.

Les équipes appellent souvent les trois « l’URL ». Ce vocabulaire flou provoque de mauvaises revues, car on compare des étapes différentes sans s’en rendre compte.

La RFC 3986 conseille aux producteurs d’URI de ne pas encoder ou décoder plusieurs fois la même chaîne. C’est un bon principe, mais ce n’est pas un plan d’implémentation. Définissez la frontière à laquelle votre sérialiseur d’action accepte des chaînes applicatives décodées, puis celle à laquelle le serveur accepte les octets bruts de la requête. Tout ce qui se trouve entre ces frontières doit conserver l’échappement ou refuser les formes qu’il ne peut pas préserver.

N’acceptez pas des entrées déjà encodées par commodité. Une consigne demandant à un agent de « fournir un identifiant de projet encodé en URL » l’oblige à deviner si `%2F` est une donnée ou une instruction. La couche suivante ne peut pas savoir si elle doit conserver le signe percent ou l’encoder en `%25`. Acceptez des champs en texte clair, encodez-les une seule fois dans la couche d’action et ne refusez les séquences percent mal formées que là où les URL brutes sont réellement acceptées.

L’Unicode ajoute une frontière supplémentaire. Un client URL transforme normalement le texte en octets UTF-8 et encode les octets qui ne peuvent pas apparaître directement dans le composant choisi. Les frameworks serveur peuvent normaliser l’Unicode différemment avant de rechercher un utilisateur ou une ressource. Gardez les identifiants sous une forme canonique au niveau de l’application si votre domaine l’exige. L’échappement transporte des octets ; il ne décide pas si deux chaînes visuellement proches désignent le même compte.

## Testez toute la route avec des entrées adversariales mais ordinaires

Un test d’encodage utile observe deux choses : la cible de requête émise par le client et les valeurs analysées par le destinataire. Si vous ne testez qu’un côté, une réécriture de proxy ou un décodeur de framework peut encore modifier le sens au milieu.

Commencez par un gestionnaire echo contrôlé dans votre environnement de test. Il doit enregistrer la cible brute lorsque le runtime du serveur l’expose, puis renvoyer le chemin et les champs de requête analysés. N’utilisez pas d’identifiants sur ce point de terminaison. Son rôle est d’exposer la sérialisation, pas d’authentifier qui que ce soit.

Cet exemple de gestionnaire Node illustre la forme de la réponse :

```javascript
import http from "node:http";

http.createServer((req, res) => {
  const url = new URL(req.url, "http://local.test");
  const pairs = [...url.searchParams.entries()];
  res.setHeader("content-type", "application/json");
  res.end(JSON.stringify({
    requestTarget: req.url,
    pathname: url.pathname,
    queryPairs: pairs
  }, null, 2));
}).listen(8787);
```

Envoyez-lui des cas connus et conservez le résultat attendu. Par exemple, un générateur recevant `C++ & systems` doit produire une sortie contenant une seule paire `q`, dont la valeur analysée est exactement `C++ & systems`. Une requête construite à la main se révèle souvent en renvoyant deux paires ou en transformant les signes plus en espaces.

```text
{
  "requestTarget": "/search?q=C%2B%2B+%26+systems",
  "pathname": "/search",
  "queryPairs": [["q", "C++ & systems"]]
}
```

Votre suite doit couvrir une matrice réduite, plutôt que des dizaines de chaînes aléatoires :

- espace, plus littéral, signe percent, esperluette, signe égal, point d’interrogation et dièse ;
- barre oblique et barre oblique encodée dans un identifiant censé rester un seul segment ;
- chaînes vides, champs facultatifs absents et noms de requête répétés ;
- valeur Unicode et valeur dont les séquences percent ressemblent à un second passage de décodage ;
- URL complète collée dans un champ qui attend un identifiant.

Utilisez des tests basés sur les propriétés si votre équipe les pratique déjà, mais ne cachez pas les cas nommés derrière des exemples générés. Ils expliquent pourquoi la frontière existe. Lorsqu’une régression survient, `encoded slash stays inside project_id` est beaucoup plus utile qu’un numéro de graine.

Exécutez les mêmes tests d’intégration sur le chemin utilisé par le trafic de production. Un test direct contre le processus de l’application ne dira pas si un proxy refuse `%2F`, réécrit les barres obliques répétées ou choisit une autre valeur en présence d’un paramètre répété. Si le point d’entrée de production ne peut pas être inclus localement, utilisez une route de préproduction avec la même configuration et faites de ce test une vérification de déploiement.

## Donnez aux agents des arguments structurés, pas la liberté de construire des URL

Un agent doit choisir une action et fournir des arguments typés. L’implémentation de l’action doit choisir la méthode HTTP, l’origine autorisée, le modèle de route, la grammaire de requête, les en-têtes et l’encodage. Permettre à l’agent de transmettre une URL complète réunit tous ces contrôles dans une seule chaîne ambiguë.

Un contrat d’action étroit peut ressembler à ceci :

```json
{
  "name": "get_project_builds",
  "input": {
    "project_id": "north/ops",
    "branch": "release+candidate",
    "limit": 25
  }
}
```

Le code de l’action doit valider `project_id` comme identifiant, l’encoder comme un seul segment de chemin, placer `branch` dans un générateur de requête, vérifier que `limit` est un entier dans la plage autorisée par l’API et construire l’URL à partir d’une origine fixe. Le modèle n’a jamais besoin de voir un jeton bearer ni de décider où placer une esperluette.

Cette séparation évite aussi la confusion d’origine. Une chaîne commençant par `https://other.example` n’a rien à faire dans un champ d’identifiant. Si une action doit réellement récupérer une URL fournie par l’utilisateur, faites-en une action distincte avec une liste d’autorisation, des règles DNS et de redirection et une raison clairement documentée. Ne dissimulez pas une capacité de récupération arbitraire derrière un champ nommé `callback` ou `file`.

Soyez prudents avec les API qui acceptent des langages de filtrage dans les paramètres de requête. Un champ comme `filter=status:open AND owner:me` possède deux grammaires : la sérialisation de la requête URL et le langage de filtrage. L’encodage URL conserve le filtre dans une seule valeur de requête, mais ne rend pas le filtre sûr. Analysez ou limitez séparément ce langage interne, ou proposez des champs de filtre typés.

Évitez de placer des secrets dans les URL. Les chaînes de requête peuvent se retrouver dans les journaux d’accès, l’historique du navigateur, la télémétrie et les rapports d’erreur. Les identifiants doivent utiliser le mécanisme d’autorisation attendu par l’API. Encoder un jeton API ne rend pas sa présence dans une requête sûre.

## L’approbation est utile, mais elle ne répare pas une requête mal formée

L’autorisation humaine et la sérialisation correcte répondent à deux problèmes différents. Une approbation peut confirmer qu’un processus d’agent reconnu peut utiliser un identifiant. Elle ne révèle pas si une séquence percent deviendra une barre oblique dans un routeur en aval, ni si un paramètre dupliqué modifiera les privilèges après analyse.

Cette distinction compte lorsque quelqu’un affirme que les invites ou les cartes d’approbation rendent inutile une construction stricte des requêtes. Ce n’est pas le cas. Une personne examinant `https://api.example.test/projects/%252Fadmin` devrait simuler mentalement chaque décodeur de la route pour comprendre le résultat. Ce n’est pas un contrôle de sécurité raisonnable, surtout lorsqu’un agent peut effectuer de nombreux appels pendant une session.

Placez les contrôles déterministes avant l’approbation :

- acceptez des champs structurés plutôt qu’une cible préassemblée ;
- fixez l’origine, la méthode et le modèle de route dans la définition de l’action ;
- sérialisez chaque segment de chemin et chaque valeur de requête une seule fois ;
- refusez les champs en double ou mal formés que le point de terminaison ne définit pas ;
- testez la cible finale sur le chemin de requête déployé.

Sallyport peut garder les identifiants hors d’un agent compatible MCP et demander une autorisation pour un processus d’agent ou pour chaque utilisation d’un identifiant sélectionné. Ce contrôle humain fonctionne mieux lorsque la couche d’action produit déjà une requête dépourvue d’ambiguïté.

Les journaux ont eux aussi besoin de deux niveaux de détail. Enregistrez l’action approuvée et ses arguments sûrs, puis conservez une représentation expurgée de la cible réellement envoyée et du résultat HTTP. Si un service possède un champ de requête sensible, masquez sa valeur tout en gardant son nom et une forme suffisante pour diagnostiquer une duplication accidentelle. Ne consignez jamais les en-têtes d’autorisation simplement parce qu’une requête a échoué.

## La normalisation des routes peut défaire un client correct

Une requête parfaitement sérialisée peut tout de même changer aux frontières de l’infrastructure. Proxies, répartiteurs de charge, pare-feu applicatifs et serveurs d’application ont chacun leur comportement concernant les barres obliques répétées, les segments pointés, les séparateurs encodés et les séquences invalides. Il faut un contrat de route pour toute la chaîne.

Prenons un point de terminaison dont le client envoie ce chemin :

```text
/files/reports%2F2025%2Fnotes
```

Si l’API attend un identifiant de fichier opaque, le paramètre applicatif souhaité est `reports/2025/notes`. Mais un proxy qui décode avant de transmettre peut envoyer `/files/reports/2025/notes`. Un routeur configuré pour `/files/:id` peut le refuser. Un autre peut faire correspondre `/files/:folder/:year/:name` et appeler un autre gestionnaire. Aucun de ces résultats ne prouve que l’encodeur client a échoué.

Prenez une décision pour chaque route sensible. Vous pouvez refuser les barres obliques encodées à la périphérie et documenter que les identifiants ne peuvent pas en contenir. Vous pouvez les préserver jusqu’au gestionnaire et tester cette propriété. Vous pouvez aussi repenser le point de terminaison afin que les données opaques vivent hors du chemin. Ce qu’il ne faut pas faire, c’est laisser le comportement dépendre de valeurs par défaut propres à une version et appeler cela une frontière de sécurité.

Surveillez également les redirections. Les clients HTTP peuvent suivre automatiquement les redirections, et une URL redirigée peut contenir un chemin ou une requête normalisés différemment. Pour les actions utilisant des identifiants, définissez si les redirections sont autorisées, si l’origine de destination doit correspondre et si les en-têtes d’autorisation doivent être supprimés en cas de changement d’origine. Ce sujet est distinct de l’encodage percent, mais les bugs d’URL et les règles de redirection se rencontrent souvent dans le même code.

Si vous exploitez plusieurs services, testez volontairement leurs divergences. Envoyez le même chemin encodé à travers la périphérie puis directement à l’application dans un environnement hors production. Si leurs valeurs de chemin analysées diffèrent, corrigez le problème avant d’autoriser un agent à accéder à la route. Un analyseur divergent est un problème opérationnel, même si aucun attaquant ne l’exploite.

## Une suite de régression doit préserver le sens voulu

Le but des tests d’URL n’est pas d’imposer une seule écriture d’échappement. Il est de préserver la relation entre les arguments de l’action et le sens côté serveur d’une requête, malgré l’évolution des bibliothèques et de l’infrastructure.

Écrivez des assertions à trois niveaux. Les tests unitaires doivent vérifier qu’un encodeur de segment transforme `a/b` en un segment encodé unique et que la sérialisation des requêtes conserve `&` dans une valeur. Les tests d’action doivent capturer la méthode, l’origine, le chemin et les paires de requête exacts envoyés à un gestionnaire echo. Les tests d’intégration doivent traverser une périphérie proche de la production et vérifier que le gestionnaire reçoit la route et les paramètres attendus.

Lorsque le contrat d’une API est vague, notez l’ambiguïté et éliminez-la. « Prend en charge le texte de recherche dans l’URL » n’est pas un contrat. Indiquez si `q` vide est accepté, si `q=a+b` signifie un plus ou un espace, si les valeurs `tag` répétées sont autorisées et si `%2F` est permis dans les identifiants. Ces détails cessent d’être accessoires dès qu’un agent peut générer des appels à partir de texte humain arbitraire.

Ne masquez pas un test défaillant en décodant l’entrée plus tôt. Un décodage précoce donne souvent une apparence normale au cas tout en déplaçant l’ambiguïté vers une couche moins visible. Conservez le texte brut comme donnée jusqu’à ce que le sérialiseur propre au composant le traite. Examinez ensuite ce que le destinataire a réellement analysé.

Le journal Activity de Sallyport facilite la comparaison entre l’appel approuvé par un agent et le résultat renvoyé, tandis que sa chaîne d’audit peut être vérifiée hors ligne avec `sp audit verify`. Utilisez cette trace pour enquêter sur une divergence, pas pour décider à la place de votre point de terminaison ce qu’il accepte.

Le premier test que j’ajouterais est volontairement banal : un identifiant contenant `/`, une valeur de requête contenant `+` et `&`, et un signe percent qui doit rester littéral. Si votre action ne peut pas préciser exactement ce que reçoit le serveur pour ces entrées, elle n’est pas prête à accepter du texte utilisateur provenant d’un agent.
