Skip to Content
Guide développeurDaemonModèle d'authentification et de sécurité

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) :

  1. Bind — une liaison non-loopback sans jeton bearer refuse de démarrer.
  2. Authentification Bearer — middleware bearerAuth avec comparaison SHA-256 à temps constant protège chaque route sauf /health sur loopback (require_auth étend cela à loopback et /health aussi).
  3. 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.
  4. Contrôle d’origine — l’application runtime installe toujours allowOriginCors sur 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.
  5. Porte de mutation par route — les routes mutantes de Wave 4 peuvent opter pour des réponses 401 même sur loopback quand aucun jeton n’est configuré, en utilisant une erreur distincte code: 'token_required'.
  6. 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.isOpen n’est jamais vrai pour local-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 : Bearer insensible à la casse selon RFC 7235 §2.1 ; tolérant SP\tHTAB entre schéma et identifiants selon RFC 7230 §3.2.6 BWS ; rejette le pure-HTAB comme séparateur.
  • Renforcement CodeQL : analyse manuelle avec indexOf plutô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 Origin correspondantes reçoivent Access-Control-Allow-Origin, Access-Control-Allow-Headers, et Access-Control-Allow-Methods ; le pré-vol OPTIONS retourne 204.
  • Les valeurs Origin non correspondantes reçoivent le même 403 { 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_origin n’est annoncée que lorsque ce mode est configuré.

createMutationGate

Porte opt-in par route. Matrice de comportement :

configuration du démonoptions de routerésultat
requireAuth=truequelconquetransmission¹
token configuréquelconquetransmission²
pas de jeton (loopback dev)strict: falsetransmission
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-only n’est pas affecté car il se base sur fromLoopback, que le démon appose à partir de l’adresse distante de la connexion.
  • first-responder n’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 :

PlagePourquoi elle est supprimée
\x00–\x1f, \x7f, \x80–\x9fContrôles ASCII C0 / DEL / C1, séquences d’échappement terminal, et falsification de lignes de log.
U+200B-U+200FCaractères de largeur nulle plus LRM / RLM ; invisibles mais peuvent modifier le rendu du terminal.
U+2028-U+2029SÉPARATEUR DE LIGNE / PARAGRAPHE ; de nombreux terminaux compatibles Unicode les traitent comme des sauts de ligne.
U+202A-U+202EContrôles d’incorporation / d’override bidirectionnels.
U+2066-U+2069Contrô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+FEFFBOM / 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.txt casseraient autrement la comparaison silencieusement).
  • Le mode CLI --open-with-auth s’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 remplit ServeOptions.token avec 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 dans process.env ni persisté par le démon, et atteint le navigateur via le fragment URL existant. Le Web Shell conserve sa copie navigateur dans le sessionStorage par onglet. Le --open nu et les appelants directs de runQwenServe() 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ès listen).
  • La porte de mutation construit passthrough et strictDenier une 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 en cancelled avant le démontage HTTP.

Dépendances

  • node:cryptocreateHash, timingSafeEqual.
  • packages/cli/src/serve/loopback-binds.tsisLoopbackBind.
  • 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

SourceBoutonEffet
EnvQWEN_SERVER_TOKENJeton Bearer (tronqué).
Drapeau--tokenJeton Bearer (remplace l’environnement).
Flags CLI--open-with-authRé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--hostnameLiaison 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-auth empêche la pré-découverte des fonctionnalités. Les clients non authentifiés ne peuvent pas découvrir la balise require_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 que express.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.ts a lieu avant allowOriginCors. 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-limit fournit 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.ts
  • packages/cli/src/serve/auth/device-flow.ts
  • packages/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.
Last updated on