Limits put to the testLes limites à l’épreuve

A hook that counts bytecode is not enough to limit every Lua script. Here are runnable reproductions, side by side.Un hook qui compte le bytecode ne suffit pas à limiter tous les scripts Lua. Voici des reproductions exécutables, côte à côte.

The upstream column uses Fengari 0.1.5, as published on npm, with a host hook of 20,000 instructions. Pyroeis uses its limits API with the same budget, 200 ms, a 1 MiB ceiling per allocation and 4 MiB accumulated per call.La colonne amont utilise Fengari 0.1.5, tel que publié sur npm, avec un hook hôte de 20 000 instructions. Pyroeis utilise son API limits avec le même budget, 200 ms, un plafond de 1 Mio par allocation et 4 Mio cumulés par appel.

Both engines run in separate Web Workers. A worker busy for more than 1.5 seconds is stopped by the page; the timer starts after the engine loads. No script gets a JavaScript bridge, files or access to another application.Les deux moteurs tournent dans des Web Workers séparés. Un worker occupé plus de 1,5 seconde est arrêté par la page ; le chronomètre commence après le chargement du moteur. Aucun script ne reçoit de pont JavaScript, de fichier ou d’accès à une autre application.

Pick a case, then run the comparison.Choisissez un cas, puis lancez la comparaison.

Upstream Fengari 0.1.5Fengari 0.1.5 amont

Not run yetPas encore exécuté

Pyroeis with limitsPyroeis avec limits

Not run yetPas encore exécuté

This session’s resultsRésultats de cette session

CaseCasFengari 0.1.5Pyroeis

What these cases showCe que ces cas démontrent

Pattern searches and table.move can monopolise a native function without yielding to the bytecode hook. A hook error can also be caught by pcall, or trigger an xpcall handler unchecked. This becomes a denial-of-service risk when the host runs untrusted scripts relying on that hook alone.Les recherches de motifs et table.move peuvent monopoliser une fonction native sans rendre la main au hook de bytecode. Une erreur de hook peut aussi être attrapée par pcall, ou déclencher un gestionnaire xpcall sans contrôle. Cela devient un risque de déni de service lorsque l’hôte exécute des scripts non fiables en se reposant sur ce seul hook.

Upstream Fengari does not provide Pyroeis’s sandbox or limits API. The adapter used here removes sensitive libraries and adds a hook; it does not claim to be an official Fengari sandbox. The allocation case shows protection added by Pyroeis, not an upstream security commitment.Fengari amont ne fournit pas l’API sandbox ou limits de Pyroeis. L’adaptateur utilisé ici retire les bibliothèques sensibles et ajoute un hook ; il ne prétend pas être un sandbox officiel Fengari. Le cas d’allocation illustre une protection ajoutée par Pyroeis, plutôt qu’un engagement de sécurité de l’amont.

The old @datamoc/fengari defects (exposed file helpers, limits on global paths and on Coroutine#close) concern our former package and are described in its history. They are not attributed to Fengari 0.1.5. These reproductions are not an exhaustive audit.Les anciens défauts de @datamoc/fengari (helpers de fichiers exposés, limites sur les chemins globaux et sur Coroutine#close) concernent notre ancien paquet et sont décrits dans son historique. Ils ne sont pas attribués à Fengari 0.1.5. Ces reproductions ne constituent pas un audit exhaustif.

Untrusted Lua in games: why the bridge is differentLua non fiable dans les jeux : pourquoi le pont est différent

Many games and tools let players write Lua: strategies, bots, mods, quest scripts. That code comes from people the host does not know. It still has to talk to the game, so the real question is what the script is allowed to touch.Beaucoup de jeux et d’outils laissent les joueurs écrire du Lua : stratégies, bots, mods, scripts de quêtes. Ce code vient de personnes que l’hôte ne connaît pas. Il doit pourtant dialoguer avec le jeu ; la vraie question est donc ce que le script a le droit de toucher.

fengari-interop answers a different need: it gives Lua the JavaScript world. Its js module exposes the global object and lets Lua construct, read, write and call any JavaScript value. That is convenient for trusted scripts, and unacceptable for untrusted ones: a script that reaches js.global reaches everything the page or worker can reach, including eval and Function, network access, storage and the DOM. No limit on instructions or allocations changes that, because the script no longer needs to stay inside the Lua engine. That is why the upstream adapter on this page removes the interop, and why games with untrusted scripts cannot use it.fengari-interop répond à un autre besoin : il donne à Lua le monde JavaScript. Son module js expose l’objet global et permet à Lua de construire, lire, écrire et appeler n’importe quelle valeur JavaScript. C’est pratique pour des scripts de confiance, et inacceptable pour des scripts non fiables : un script qui atteint js.global atteint tout ce que la page ou le worker peut atteindre, y compris eval et Function, le réseau, le stockage et le DOM. Aucune limite d’instructions ou d’allocations n’y change rien, puisque le script n’a plus besoin de rester dans le moteur Lua. C’est pourquoi l’adaptateur amont de cette page retire l’interop, et pourquoi les jeux qui exécutent des scripts non fiables ne peuvent pas l’utiliser.

Pyroeis uses an exchange mode instead. The host decides what exists on the Lua side:Pyroeis adopte donc un mode d’échange. L’hôte décide de ce qui existe côté Lua :

This protects the host, not the host’s own callbacks: a function you register can still do harm if it trusts its arguments. The Lua-to-JavaScript translator is a different tool. Its output is ordinary JavaScript, where Lua globals such as eval or Function keep their JavaScript meaning, and t.__proto__.x = 1 changes Object.prototype. Run translated code only if you wrote or reviewed the Lua. The translator flags these constructs as unsafe, and the Lua ↔ JavaScript page refuses to run such a translation.Cela protège l’hôte, pas ses propres callbacks : une fonction que vous déclarez peut encore nuire si elle fait confiance à ses arguments. Le traducteur Lua vers JavaScript est un autre outil. Sa sortie est du JavaScript ordinaire, où des globaux Lua comme eval ou Function gardent leur sens JavaScript, et où t.__proto__.x = 1 modifie Object.prototype. N’exécutez du code traduit que si vous avez écrit ou relu le Lua. Le traducteur signale ces constructions comme unsafe, et la page Lua ↔ JavaScript refuse d’exécuter une telle traduction.

Data exchange cases that require fengari-interopCas d’échange de données nécessitant fengari-interop

For use cases that require full, bidirectional access between Lua and JavaScript — such as Lua scripts that need to directly manipulate JavaScript objects, call arbitrary JavaScript functions, or access browser APIs like the DOM or fetch — you must use fengari-interop. Pyroeis’s exchange model deliberately does not provide this capability, as it would break the security isolation required for untrusted scripts.Pour les cas d’usage qui nécessitent un accès bidirectionnel complet entre Lua et JavaScript — comme des scripts Lua devant manipuler directement des objets JavaScript, appeler des fonctions JavaScript arbitraires, ou accéder à des API navigateur comme le DOM ou fetch — vous devez utiliser fengari-interop. Le modèle d’échange de Pyroeis ne fournit pas intentionnellement cette capacité, car elle briserait l’isolation de sécurité requise pour les scripts non fiables.

With fengari-interop, Lua code can access js.global, which exposes the entire JavaScript environment including eval, Function, network access, storage and the DOM. This is only safe when the Lua code is fully trusted by the host. For untrusted Lua scripts, Pyroeis’s exchange model — with its explicit data copying and host-controlled function exposure — is the appropriate choice.Avec fengari-interop, le code Lua peut accéder à js.global, qui expose tout l’environnement JavaScript y compris eval, Function, l’accès réseau, le stockage et le DOM. Cela n’est sûr que lorsque le code Lua est entièrement de confiance pour l’hôte. Pour les scripts Lua non fiables, le modèle d’échange de Pyroeis — avec sa copie explicite des données et son exposition contrôlée des fonctions hôte — est le choix approprié.

In summary: if you need Lua to directly interact with the JavaScript runtime, use fengari-interop with trusted scripts only. If you need to safely run untrusted Lua that exchanges data with JavaScript, use Pyroeis’s exchange model as documented on the JS ↔ Lua page.En résumé : si vous avez besoin que Lua interagisse directement avec l’environnement JavaScript, utilisez fengari-interop uniquement avec des scripts de confiance. Si vous devez exécuter du Lua non fiable qui échange des données avec JavaScript, utilisez le modèle d’échange de Pyroeis tel que documenté sur la page JS ↔ Lua.

Corrupted binary chunksChunks binaires corrompus

Lua can load precompiled code with load(chunk, name, "b"). Upstream Fengari 0.1.5 accepts a binary chunk from any script, and the loader and VM assume it is well-formed. This demonstration takes one valid chunk, corrupts one to four bytes at random, and gives it to the same Lua script on both engines. The script loads the chunk and runs it under pcall, so every failure should end as a Lua error.Lua peut charger du code précompilé avec load(chunk, nom, "b"). Fengari 0.1.5 amont accepte un chunk binaire venant de n’importe quel script, et le chargeur comme la VM supposent qu’il est bien formé. Cette démonstration prend un chunk valide, corrompt un à quatre octets au hasard, et le donne au même script Lua sur les deux moteurs. Le script charge le chunk et l’exécute sous pcall : tout échec devrait donc finir en erreur Lua.

With upstream, some corrupted chunks make the engine throw a JavaScript RangeError, Error or TypeError that crosses pcall and reaches the host. We found no code execution; the effect is a host that must catch non-Lua exceptions, and an engine in an undefined state. The Pyroeis sandbox does not parse untrusted binary chunks: load is text-only, so each attempt ends as a Lua error. Outside the sandbox, Pyroeis checks every register, constant, jump and upvalue of a binary chunk when it loads it, which stops a corrupted chunk from stalling the VM inside one instruction, but a binary chunk is still trusted input.Avec l’amont, certains chunks corrompus font lever par le moteur une RangeError, une Error ou une TypeError JavaScript qui traverse pcall et atteint l’hôte. Nous n’avons trouvé aucune exécution de code ; l’effet est un hôte qui doit attraper des exceptions non Lua, et un moteur dans un état indéfini. Le sandbox Pyroeis n’analyse pas les chunks binaires non fiables : load n’accepte que du texte, chaque tentative finit donc en erreur Lua. Hors du sandbox, Pyroeis vérifie à chaque chargement les registres, constantes, sauts et upvalues d’un chunk binaire, ce qui empêche un chunk corrompu d’immobiliser le VM dans une seule instruction, mais un chunk binaire reste une entrée de confiance.

local f, err = load(mutated(), '=mutated', 'b')
if not f then return 'refused: ' .. tostring(err) end
local ok, res = pcall(f)
return ok and 'ran' or 'lua-error: ' .. tostring(res)
Both engines receive the same bytes, from a fixed seed.Les deux moteurs reçoivent les mêmes octets, à partir d’une graine fixe.

Upstream Fengari 0.1.5Fengari 0.1.5 amont

Not run yetPas encore exécuté

Pyroeis sandboxSandbox Pyroeis

Not run yetPas encore exécuté

The proposed test matrix, checked against realityLa matrice de tests proposée, confrontée à la réalité

A first draft compared the engines with nine attacks and pre-written outcomes. The list is kept below, with the behaviour both codes really have and a way to verify each row yourself. Three rows were corrected: the Lua chunk format carries no checksum, t.__proto__ written from Lua is an ordinary key that touches nothing else, and js.global exists only when a host loads fengari-interop. One row was set aside on purpose: rejecting constants that mention eval would break legitimate scripts and would stop nobody, since a Lua string is data and no JavaScript bridge exists to reach. Eight of the ten rows are runnable in the comparison above, and only where the two engines behave differently; each result appears under “This session’s results”.Un premier jet comparait les moteurs avec neuf attaques et des résultats précrits. La liste est conservée ci-dessous, avec le comportement réel des deux codes, et un moyen de vérifier chaque ligne vous-même. Trois lignes ont été corrigées : le format des chunks Lua ne porte pas de checksum, t.__proto__ écrit depuis Lua est une clé ordinaire qui ne touche rien d’autre, et js.global n’existe que si un hôte charge fengari-interop. Une ligne a été écartée à dessein : rejeter les constantes qui mentionnent eval casserait des scripts légitimes et n’arrêterait personne, une chaîne Lua étant de la donnée et aucun pont JavaScript n’existant à atteindre. Huit des dix lignes sont exécutables dans la comparaison ci-dessus, et seulement là où les deux moteurs se comportent différemment ; chaque résultat apparaît sous « Résultats de cette session ».

Case in the proposalCas de la propositionFengari 0.1.5Pyroeis Check it yourselfVérifiez-le vous-même
Corrupted binary chunk (invalid opcode)Chunk binaire corrompu (opcode invalide) Loads any binary chunk as well-formed. A forged operand can stall the VM inside a single instruction; a corrupted chunk can throw a JavaScript exception across pcall.Charge n’importe quel chunk binaire comme s’il était bien formé. Un opérande forgé peut immobiliser le VM dans une seule instruction ; un chunk corrompu peut faire lever une exception JavaScript à travers pcall. The sandbox loads text only, so every attempt ends as a Lua error. Outside it, each loaded function is checked (registers, constants, jumps, upvalues) and refused as malformed (…).Le sandbox ne charge que du texte, chaque tentative finit donc en erreur Lua. Hors du sandbox, chaque fonction chargée est vérifiée (registres, constantes, sauts, upvalues) et refusée en malformed (…). Case chunk: both engines dump the same return 1, the script rewrites one byte of its constant — upstream returns the forged value, the sandbox refuses the binary load · the 1,500-chunk replay on this page · test/lverify.test.js, 60,000 fuzzed chunks.Cas chunk : les deux moteurs sortent le même return 1, le script réécrit un octet de sa constante — l’amont renvoie la valeur forgée, le sandbox refuse le chargement binaire · la relecture de 1 500 chunks de cette page · test/lverify.test.js, 60 000 chunks mutés.
Corrupted binary chunk (invalid checksum)Chunk binaire corrompu (checksum invalide) The chunk format has no checksum: there is nothing to corrupt or verify.Le format des chunks n’a pas de checksum : rien à corrompre ni à vérifier. Not implemented either: a checksum would break luac compatibility and an attacker recomputes it. The real barriers are the text-only load of the sandbox and the structural check.Pas davantage : un checksum casserait la compatibilité luac et un attaquant le recalcule. Les vrais verrous sont le chargement texte du sandbox et la vérification structurelle. The sandbox case in test/lverify.test.js · the replay above.Le cas du sandbox dans test/lverify.test.js · la relecture ci-dessus.
Infinite loopBoucle infinie A host counting hook stops a plain loop, but the script can swallow its error with pcall, loop again in an xpcall handler, or monopolise a native function the hook cannot see.Un hook hôte qui compte arrête une boucle simple, mais le script peut avaler son erreur avec pcall, reparcourir dans un gestionnaire xpcall, ou monopoliser une fonction native que le hook ne voit pas. limits end the host call with a LimitError the script cannot hide, even from a coroutine.Les limits terminent l’appel hôte par un LimitError que le script ne peut pas cacher, même depuis une coroutine. Cases loop, pcall, xpcall, pattern and move: each link selects the case, then run the comparison.Cas loop, pcall, xpcall, pattern et move : chaque lien sélectionne le cas, lancez ensuite la comparaison.
Malicious metatableMétatable malveillante __index is an ordinary metatable. It reaches JavaScript only when the host loads fengari-interop; once loaded, no filter on metatables undoes that.__index est une métatable ordinaire. Elle n’atteint le JavaScript que si l’hôte charge fengari-interop ; une fois chargé, aucun filtre sur les métatables n’y revient. There is no bridge to reach: type(js) is nil in every mode. Blocking __index or __call was rejected — the standard library itself lives on metatables.Il n’y a pas de pont à atteindre : type(js) vaut nil dans tous les modes. Bloquer __index ou __call a été écarté — la bibliothèque standard vit elle-même sur les métatables. Case metatable (Fengari with fengari-interop) · return type(js) in any Pyroeis VM · the bridge section above and the JS ↔ Lua page · test/pyroeis-vm.test.js.Cas metatable (Fengari avec fengari-interop) · return type(js) dans n’importe quel VM Pyroeis · la section sur le pont ci-dessus et la page JS ↔ Lua · test/pyroeis-vm.test.js.
String concatenationConcaténation de chaînes No ceiling: the quadratic loop grows memory until the tab hangs or crashes.Aucun plafond : la boucle quadratique fait croître la mémoire jusqu’à figer ou faire planter l’onglet. limits.allocation and totalAllocation refuse the next allocation: not enough memory, a Lua error the script can catch.limits.allocation et totalAllocation refusent l’allocation suivante : not enough memory, une erreur Lua que le script peut attraper. Cases concat and allocation · test/pyroeis-vm-limits.test.js.Cas concat et allocation · test/pyroeis-vm-limits.test.js.
Giant tableTable géante No memory ceiling at all.Aucun plafond de mémoire. String and buffer allocations are capped; a Lua table is not capped directly — the instruction and time budgets bound the loop that fills it. Coroutines have their own cap, see the next row.Les allocations de chaînes et de buffers sont plafonnées ; une table Lua n’est pas plafonnée directement — les budgets d’instructions et de temps bornent la boucle qui la remplit. Les coroutines ont leur propre plafond, voir la ligne suivante. Cases gianttable and move · the ceilings and their limits are listed below.Cas gianttable et move · les plafonds et leurs limites sont listés ci-dessous.
VM escape (js.global)Évasion de la VM (js.global) Reachable only through fengari-interop; with it the script reaches eval, the DOM and the network. Without it, js is nil.Atteignable seulement via fengari-interop ; avec lui, le script atteint eval, le DOM et le réseau. Sans lui, js vaut nil. No js global exists in any mode. The host helpers (__fs_read, __fs_list, __random) exist only outside the sandbox and are hidden by it.Aucun global js n’existe dans aucun mode. Les helpers hôte (__fs_read, __fs_list, __random) n’existent qu’hors du sandbox, qui les masque. Case jsglobal-interop · the bridge section above and the JS ↔ Lua page · test/pyroeis-vm.test.js, “sandbox leaves no host helper behind”.Cas jsglobal-interop · la section sur le pont ci-dessus et la page JS ↔ Lua · test/pyroeis-vm.test.js, « sandbox leaves no host helper behind ».
Coroutine floodDéluge de coroutines No cap: a generous instruction budget lets a script create a million coroutines, about 1.7 GiB of heap, because the allocation ceilings never see them.Aucun plafond : un budget d’instructions généreux laisse un script créer un million de coroutines, environ 1,7 Gio de tas, car les plafonds d’allocation ne les voient jamais. limits.coroutines caps the coroutines created per call (10,000 here, about 17 MiB); the next one is a LimitError the script cannot catch. It counts creations, not live coroutines. limits.tables and limits.closures work the same way for tables and closures.limits.coroutines plafonne les coroutines créées par appel (10 000 ici, environ 17 Mio) ; la suivante est une LimitError que le script ne peut pas attraper. Il compte les créations, pas les coroutines vivantes. limits.tables et limits.closures font de même pour les tables et les fermetures. Case coroutines · test/pyroeis-vm-limits.test.js.Cas coroutines · test/pyroeis-vm-limits.test.js.
Prototype injectionInjection de prototypes Written from Lua, t.__proto__.x = 1 is an ordinary key on a Lua table; Object.prototype is untouched. The danger appears with an interop that hands real objects to Lua.Écrit depuis Lua, t.__proto__.x = 1 est une clé ordinaire sur une table Lua ; Object.prototype n’est pas touché. Le danger apparaît avec une interop qui remet de vrais objets à Lua. Values cross as copies and a __proto__ key stays an own property. The translator flags __proto__ and constructor as unsafe and emits the computed key ["__proto__"].Les valeurs traversent en copies et une clé __proto__ reste une propriété own. Le traducteur signale __proto__ et constructor comme unsafe et émet la clé calculée ["__proto__"]. Case proto-interop · the Lua ↔ JavaScript page refuses to run an unsafe translation · test/ltranspile.test.js and test/pyroeis-vm.test.js.Cas proto-interop · la page Lua ↔ JavaScript refuse d’exécuter une traduction unsafe · test/ltranspile.test.js et test/pyroeis-vm.test.js.
Constant mentioning evalConstante mentionnant eval A Lua string is data: nothing executes it, and eval is not a Lua global.Une chaîne Lua est de la donnée : rien ne l’exécute, et eval n’est pas un global Lua. Set aside: a content filter would break legitimate scripts and any obfuscation bypasses it. The barrier is that no JavaScript bridge exists.Écarté : un filtre de contenu casserait des scripts légitimes et s’évite par obscurcissement. Le verrou est qu’aucun pont JavaScript n’existe. Not a runnable case: both engines behave the same, so a comparison would teach nothing. load("return eval('1+1')")() ends with attempt to call a nil value (global 'eval') in the sandbox and upstream alike.Pas de cas exécutable : les deux moteurs se comportent pareil, une comparaison n’apprendrait rien. load("return eval('1+1')")() finit en attempt to call a nil value (global 'eval') dans le sandbox comme chez l’amont.
fengari-interop accessAccès via fengari-interop Without fengari-interop loaded, js is nil. With it, js.global exposes the full JavaScript environment including eval, DOM and network.Sans fengari-interop chargé, js vaut nil. Avec lui, js.global expose tout l’environnement JavaScript y compris eval, le DOM et le réseau. No js global exists in any mode. The host helpers exist only outside the sandbox and are hidden by it. fengari-interop is not loaded by Pyroeis.Aucun global js n’existe dans aucun mode. Les helpers hôte n’existent qu’hors du sandbox, qui les masque. fengari-interop n’est pas chargé par Pyroeis. Cases jsglobal-interop and metatable · the interop-required section above and the JS ↔ Lua page.Cas jsglobal-interop et metatable · la section interop-required ci-dessus et la page JS ↔ Lua.
Sources, versions and protection limitsSources, versions et limites de la protection

Exact Lua scriptsScripts Lua exacts · Worker executionExécution dans le worker · Corrupted-chunk workerWorker des chunks corrompus · Upstream adapterAdaptateur amont · npm archive versions and fingerprintsVersions et empreintes des archives npm · Regression testsTests de régression.

The upstream bundle keeps the VM code and only replaces process.env.FENGARICONF with undefined at build time to load the default configuration in the browser. Node modules are excluded from the execution path.Le bundle amont conserve le code de la VM et remplace uniquement process.env.FENGARICONF par undefined au build pour charger la configuration par défaut dans le navigateur. Les modules Node sont exclus du chemin d’exécution.

Allocation ceilings cover the instrumented internal string and buffer allocations, not all JavaScript memory or Lua tables. Other native functions, such as sort and some formats, are not interrupted mid-work. Outside isolation is still needed to bound a whole program and its host callbacks.Les plafonds d’allocation couvrent les allocations internes de chaînes et buffers instrumentées, pas toute la mémoire JavaScript ni les tables Lua. D’autres fonctions natives, comme le tri et certains formats, ne sont pas interrompues au milieu de leur travail. Une isolation extérieure reste nécessaire pour borner l’ensemble d’un programme et ses callbacks hôtes.