Modèle d’authentification et de sécurité
Présentation
qwen serve est un démon local par défaut et une surface exposée dans une mauvaise configuration. Son modèle de sécurité est en couches afin qu’une mauvaise configuration échoue de manière sécurisée (fail closed) :
- Bind — une liaison non-loopback sans jeton bearer refuse de démarrer.
- Authentification Bearer — middleware
bearerAuthavec comparaison SHA-256 à temps constant protège chaque route sauf/healthsur loopback (require_authétend cela à loopback et/healthaussi). - Liste blanche d’en-têtes Host — sur loopback, seuls
localhost,127.0.0.1,[::1],host.docker.internal(plus le port) sont acceptés ; défense contre le DNS rebinding. L’écouteur LAN Local Control est l’exception qui applique toujours sa vérification Host d’autorité annoncée, quelle que soit la liaison principale. - Contrôle d’origine — l’application runtime installe toujours
allowOriginCorssur une liste blanche mutable (MutableOriginAllowlist) : les entrées--allow-origin <pattern>l’initialisent, et Local Control ajoute l’origine LAN lorsqu’il est activé. Les origines non correspondantes reçoivent l’enveloppe de refus 403. Le mur de refus inconditionnel (denyBrowserOriginCors) ne survit que dans l’application bootstrap qui répond avant le démarrage du runtime. - Porte de mutation par route — les routes mutantes de Wave 4 peuvent opter pour des réponses
401même sur loopback quand aucun jeton n’est configuré, en utilisant une erreur distinctecode: 'token_required'. - Authentification par flux d’appareil — surface OAuth séparée pour les fournisseurs (
POST /workspace/auth/device-flow+ GET/DELETE sur/:id).
Ce document décrit chaque couche et les invariants explicites que le chemin de démarrage applique.
Responsabilités
- Refuser de démarrer dans des configurations dangereuses.
- Filtrer chaque requête HTTP via bearer (lorsqu’il est configuré) + host (loopback) + origines.
- Fournir une porte de mutation par route que les routes Wave 4 peuvent adopter.
- Héberger le registre de flux d’appareil qui pilote les flux OAuth des fournisseurs visibles via les événements SSE.
Architecture
Règles de refus au démarrage
Dans run-qwen-serve.ts :
if (!isLoopbackBind(opts.hostname) && !token) {
throw new Error('Refusing to bind <host>:<port> without a bearer token. ...');
}
if (opts.requireAuth && !token) {
throw new Error(
'Refusing to start with --require-auth set but no bearer token configured. ...',
);
}Le wildcard allow-origin a sa propre règle de refus :
const parsed = parseAllowOriginPatterns(opts.allowOrigins);
if (parsed.allowAny && !token) {
throw new Error(
"Refusing to start with --allow-origin '*' but no bearer token configured. ...",
);
}Ces trois refus sont des échecs de démarrage explicites (visibles dans stderr / envoyés à l’intégrateur), jamais silencieux. Le modèle de menace de #3803 interdit explicitement de laisser silencieusement un démon se lier au-delà de loopback.
Chaîne de middleware (ordre des requêtes HTTP)
mutationGate est une fabrique de middleware par route (createMutationGate retourne mutate()) ; les routes appellent mutate() ou mutate({strict: true}) au moment de l’enregistrement. Ce n’est pas un middleware global app.use(). La journalisation d’accès est enregistrée avant bearerAuth pour que les rejets 401 soient tout de même journalisés. La limitation de débit s’exécute après bearerAuth et avant express.json(), ainsi seules les requêtes authentifiées comptent et les gros corps sont rejetés avant analyse quand une limite est dépassée.
bearerAuth
- Aucun jeton configuré → le middleware est un no-op (loopback, développement par défaut). Exception : l’écouteur LAN Local Control est limité à l’écouteur et exige toujours son identifiant de paire (
CredentialStore.isOpenn’est jamais vrai pourlocal-control), donc il n’est jamais ouvert même sur un démon sans jeton. - Jeton configuré → SHA-256 du jeton configuré une fois à la construction ; sur chaque requête, hacher le candidat et comparer avec
timingSafeEqual. Pas de court-circuit par égalité de chaîne ; pas de fuite temporelle. - Analyse du schéma :
Bearerinsensible à la casse selon RFC 7235 §2.1 ; tolérantSP\tHTABentre schéma et identifiants selon RFC 7230 §3.2.6 BWS ; rejette le pure-HTAB comme séparateur. - Renforcement CodeQL : analyse manuelle avec
indexOfplutôt qu’une regex avec\s+/.+en concurrence (pas de risque polynomial de regex).
hostAllowlist
Uniquement loopback. Maintient un Set<string> indexé par port. Hôtes autorisés :
localhost:<port>,127.0.0.1:<port>,[::1]:<port>,host.docker.internal:<port>.- Plus les formes sans port (
localhost,127.0.0.1,[::1],host.docker.internal) uniquement quand lié au port 80 (selon RFC 7230 §5.4 omission du port par défaut).
La comparaison d’hôte est insensible à la casse — Express normalise les noms d’en-tête mais pas les valeurs, donc les proxys Docker qui mettent en majuscule les Hosts (Localhost:4170, HOST.docker.internal) obtiendraient un 403 avec une comparaison de chaîne exacte.
Les liaisons non-loopback contournent la porte principale (l’opérateur a choisi la surface d’attaque ; le jeton bearer protège plutôt contre l’usurpation Host). L’écouteur LAN Local Control est l’exception : il applique toujours sa vérification Host d’autorité annoncée, quelle que soit la liaison principale.
denyBrowserOriginCors (application bootstrap uniquement)
Rejette toute requête avec un en-tête Origin. Les CLI/SDK ne définissent jamais Origin ; seuls les navigateurs le font. Retourne un 403 { error: 'Request denied by CORS policy' } déterministe plutôt que le 500 HTML que produirait le callback d’erreur du paquet cors. L’application runtime n’installe plus ce mur — elle exécute allowOriginCors sur la liste blanche mutable (ci-dessous) ; le comportement de refus y survit comme la branche des origines sans correspondance. Le mur reste dans l’application bootstrap (run-qwen-serve.ts) qui sert les requêtes avant le démarrage du runtime.
Exception : les XHR de même origine du Web Shell sur une liaison loopback sont gérées par un middleware séparé (dans server/self-origin.ts) qui supprime Origin lorsqu’il correspond à l’une des auto-origines loopback (127.0.0.1, localhost, [::1], host.docker.internal). Sur les liaisons non-loopback, les XHR du shell portent un Origin sans correspondance et nécessitent --allow-origin pour l’origine du démon.
allowOriginCors (application runtime, toujours installé)
L’application runtime installe allowOriginCors(originAllowlist) de manière
inconditionnelle ; la liste blanche est une MutableOriginAllowlist initialisée
à partir des entrées --allow-origin <pattern> (possiblement aucune) et étendue
au runtime lorsque Local Control est activé (l’origine LAN est
ajoutée/supprimée avec l’écouteur) :
- Les valeurs
Origincorrespondantes reçoiventAccess-Control-Allow-Origin,Access-Control-Allow-Headers, etAccess-Control-Allow-Methods; le pré-volOPTIONSretourne204. - Les valeurs
Originnon correspondantes reçoivent le même403 { error: 'Request denied by CORS policy' }déterministe qu’en mode refus. --allow-origin '*'nécessite--token; sinon le démarrage refuse.parseAllowOriginPatterns()valide la syntaxe du motif au démarrage.- La balise de capacité
allow_originn’est annoncée que lorsque ce mode est configuré.
createMutationGate
Porte opt-in par route. Matrice de comportement :
| configuration du démon | options de route | résultat |
|---|---|---|
requireAuth=true | quelconque | transmission¹ |
token configuré | quelconque | transmission² |
| pas de jeton (loopback dev) | strict: false | transmission |
| pas de jeton (loopback dev) | strict: true, non authentifié | 401 { code: 'token_required' } |
| pas de jeton (loopback dev) | strict: true, authentifié³ | transmission |
¹ --require-auth démarre uniquement avec un jeton, donc le bearerAuth global a déjà renvoyé 401 aux appelants non authentifiés.
² Toute configuration de jeton fait que le bearerAuth global impose bearer-required-partout ; la porte est redondante mais inoffensive.
³ Authentifié via un identifiant limité à l’écouteur : l’écouteur LAN Local Control vérifie son identifiant de paire même sur un démon sans jeton et marque la requête comme authentifiée, donc les routes strictes passent pour le client LAN apparié.
La forme code: 'token_required' est distincte du simple Unauthorized de bearerAuth pour que les clients SDK puissent afficher une astuce “configurer —token / —require-auth” plutôt qu’un 401 générique.
Routes strictes Wave 4+ : /workspace/memory, /workspace/agents/*,
/workspace/agents/generate, /file/write, /file/edit,
/workspace/tools/:name/enable, /workspace/mcp/:server/restart,
/workspace/mcp/:server/{enable,disable,authenticate,clear-auth},
/workspace/mcp/servers (POST/DELETE), /workspace/auth/device-flow,
/workspace/init, /session/:id/approval-mode, /session/:id/rewind, et
/session/:id/shell.
Le rewind reste REST-only dans le SDK TypeScript même lorsqu’un transport ACP est
configuré. Cela préserve la porte de mutation stricte et les en-têtes bearer/identité client ;
la table de routes ACP n’a intentionnellement pas de mapping de rewind. Le routage propriétaire
revérifie aussi la confiance du workspace avant que le rewind ou le shell n’atteigne un bridge
de runtime secondaire. Les IDs de session live dupliqués échouent en fail closed avec
ambiguous_session_owner au lieu de revenir au runtime primaire.
Exemption /health
Sur les liaisons loopback, /health est enregistrée avant le middleware bearer de sorte que les sondes de santé dans le pod n’aient pas besoin de porter le jeton. Les liaisons non-loopback protègent /health derrière bearer comme toutes les autres routes. --require-auth supprime l’exemption : /health nécessite Authorization: Bearer <token> même sur loopback.
L’identité client v1 (X-Qwen-Client-Id) est auto-déclarée
Le démon valide uniquement le format de X-Qwen-Client-Id
([A-Za-z0-9._:-]{1,128}) et suit les identifiants clients attachés par session. Il n’effectue
actuellement pas de preuve de possession. Un client qui observe
originatorClientId sur SSE peut ré-enregistrer le même identifiant et usurper cet
initiateur dans des requêtes ultérieures.
Impact :
designated— un appelant distant peut usurper l’initiateur et voter sur une requête destinée uniquement à l’initiateur de l’invite.consensus— si l’identifiant usurpé était déjà dans l’instantanévotersAtIssue, il peut voter.local-onlyn’est pas affecté car il se base surfromLoopback, que le démon appose à partir de l’adresse distante de la connexion.first-respondern’est pas affecté car il est indépendant de l’identité.
Un futur mécanisme de paire de jetons délivrera un secret par session depuis
POST /session ; les votes designated / consensus devront le présenter. En attendant,
les déploiements qui ont besoin d’une politique désignée renforcée devraient se lier à loopback
ou fonctionner derrière un proxy inverse authentifié. Voir
04-permission-mediation.md pour les détails au niveau des politiques.
Authentification par flux d’appareil
Surface OAuth séparée pour l’authentification des fournisseurs. L’identifiant de fournisseur v1 est
qwen-oauth, mais le niveau gratuit de Qwen OAuth a été interrompu le 2026-04-15 ; les nouvelles
configurations devraient utiliser un fournisseur d’authentification actuellement pris en charge quand il est disponible.
POST /workspace/auth/device-flow— démarrer un flux ; retourne{deviceFlowId, providerId, expiresAt, verificationUrl, userCode}.GET /workspace/auth/device-flow/:id— interroger l’état.DELETE /workspace/auth/device-flow/:id— annuler.GET /workspace/auth/status— instantané du compte / fournisseur actuel.
Les événements SSE auth_device_flow_{started, throttled, authorized, failed, cancelled} diffusent l’état du flux à tous les abonnés afin que les interfaces multi-clients restent synchronisées. Voir 09-event-schema.md.
Implémentation : packages/cli/src/serve/auth/device-flow.ts + qwen-device-flow-provider.ts.
Défense contre l’injection de logs / Trojan Source : sanitizeForStderr(value)
(device-flow.ts) remplace les caractères de contrôle ASCII et les caractères de contrôle
Unicode par ?. Un IdP malveillant pourrait autrement falsifier des lignes de log ou cacher
des charges utiles :
| Plage | Pourquoi elle est supprimée |
|---|---|
\x00–\x1f, \x7f, \x80–\x9f | Contrôles ASCII C0 / DEL / C1, séquences d’échappement terminal, et falsification de lignes de log. |
| U+200B-U+200F | Caractères de largeur nulle plus LRM / RLM ; invisibles mais peuvent modifier le rendu du terminal. |
| U+2028-U+2029 | SÉPARATEUR DE LIGNE / PARAGRAPHE ; de nombreux terminaux compatibles Unicode les traitent comme des sauts de ligne. |
| U+202A-U+202E | Contrôles d’incorporation / d’override bidirectionnels. |
| U+2066-U+2069 | Contrôles d’isolation bidirectionnelle (LRI / RLI / FSI / PDI), le principal vecteur CVE-2021-42574 “Trojan Source” . Un IdP utilisant U+2066 (LRI) au lieu de U+202D (LRO) peut contourner les filtres uniquement EMBEDDING/OVERRIDE avec un réordonnancement visuel similaire. |
| U+FEFF | BOM / espace insécable de largeur nulle. |
La longueur est préservée en remplaçant chaque point de code supprimé par ? plutôt
que de le supprimer, afin que les opérateurs puissent toujours voir que quelque chose était présent à cet
index. Les deux couches utilisent le sanitizer : qwenDeviceFlowProvider nettoie l’IdP
oauthError, et l’observateur de sondage tardif du registre nettoie les valeurs
contrôlées par le fournisseur interpolées dans les indices d’audit (latePollResult.kind / lateErr.name).
La balise de capacité auth_device_flow est annoncée inconditionnellement ; les routes elles-mêmes retournent 400 unsupported_provider si le démon ne peut satisfaire un fournisseur spécifique. La liste des fournisseurs pris en charge se trouve sur /workspace/auth/status plutôt que sur /capabilities pour garder une forme uniforme du descripteur.
Workflow
Requête réussie avec authentification Bearer
Modes d’échec de l’authentification Bearer
Tous retournent 401 { error: 'Unauthorized' } (uniforme entre en-tête manquant / mauvais schéma / mauvais jeton afin que le sondage ne puisse pas distinguer).
Ombrage --require-auth
Après authentification, caps.features.includes('require_auth') confirme que le déploiement est renforcé.
Porte de mutation Wave 4 sur loopback sans jeton
État & Cycle de vie
- Le jeton Bearer est lu au démarrage et tronqué (les nouvelles lignes de
cat token.txtcasseraient autrement la comparaison silencieusement). - Le mode CLI
--open-with-auths’exécute avant le démarrage : après les vérifications déterministes loopback/Web Shell, il applique la même sélection option-sur-environnement et remplitServeOptions.tokenavec 32 octets aléatoires encodés en base64url uniquement lorsqu’aucun jeton sélectionné non vide n’existe. L’identifiant généré a une durée de vie processus, n’est pas écrit dansprocess.envni persisté par le démon, et atteint le navigateur via le fragment URL existant. Le Web Shell conserve sa copie navigateur dans lesessionStoragepar onglet. Le--opennu et les appelants directs derunQwenServe()ne le génèrent jamais. - L’ensemble des hôtes autorisés est mis en cache par port ; reconstruit en cas de changement de port (
0éphémère → port réel aprèslisten). - La porte de mutation construit
passthroughetstrictDenierune fois par construction d’application ; l’appel par route retourne la fermeture mise en cache (pas d’allocation par requête). - Le registre de flux d’appareil est supprimé lors de
shutdown()Phase 1 afin que les flux en attente se résolvent encancelledavant le démontage HTTP.
Dépendances
node:crypto—createHash,timingSafeEqual.packages/cli/src/serve/loopback-binds.ts—isLoopbackBind.packages/cli/src/serve/auth/device-flow.ts— machine à états du flux d’appareil.@qwen-code/acp-bridge— expose les événements de flux d’appareil sur le bus SSE par session.
Configuration
| Source | Bouton | Effet |
|---|---|---|
| Env | QWEN_SERVER_TOKEN | Jeton Bearer (tronqué). |
| Drapeau | --token | Jeton Bearer (remplace l’environnement). |
| Flags CLI | --open-with-auth | Réutiliser ou générer un bearer Web Shell loopback avant le démarrage du démon. |
| Drapeau | --require-auth | Étend bearer à loopback + /health. Démarre uniquement avec un jeton. |
| Drapeau | --hostname | Liaison non-loopback nécessite --token (ou env). |
| Drapeau | --allow-origin <pattern> | Passer en mode liste blanche CORS. '*' nécessite un jeton. |
| Balises de capacité | require_auth (conditionnel), auth_device_flow (toujours), allow_origin (conditionnel) | Voir 11-capabilities-versioning.md. |
Mises en garde & Limites connues
- L’ombrage
--require-authempêche la pré-découverte des fonctionnalités. Les clients non authentifiés ne peuvent pas découvrir la baliserequire_auth; leur surface de découverte est le corps 401 lui-même. - Ordre analyseur de corps / porte de mutation : les réponses 401 de
mutationGate({strict: true})sont déclenchées après queexpress.json()a analysé le corps. Dans le pire cas sur un écouteur loopback saturé :--max-connections × express.json({limit: '10mb'})≈ 2,5 Go transitoires. Surface d’attaque loopback uniquement, intentionnellement acceptée. - Suppression de l’en-tête Origin de même origine dans
server.tsa lieu avantallowOriginCors. Si un changement futur déplace la suppression ailleurs, le Web Shell se casse. - La comparaison du jeton se fait sur le digest SHA-256, pas le jeton brut. Réduit les fuites temporelles en réduisant la comparaison de jetons de longueur variable à une comparaison de digest de taille fixe.
- Le démon ne porte pas mTLS, signature de requête, ou preuve de possession par paire de jetons aujourd’hui.
--rate-limitfournit une limitation de débit HTTP par clé client-id / IP ; ce n’est pas une authentification d’identité client.
Références
packages/cli/src/serve/auth.ts(fichier entier)packages/cli/src/serve/run-qwen-serve.ts(règles de refus)packages/cli/src/serve/loopback-binds.tspackages/cli/src/serve/auth/device-flow.tspackages/cli/src/serve/auth/qwen-device-flow-provider.ts- Modèle de menace pour l’utilisateur :
../../users/qwen-serve.md. - Référence filaire :
../qwen-serve-protocol.md.