@markii/lua 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/capabilities.d.ts +124 -0
- package/dist/capabilities.js +281 -0
- package/dist/errors.d.ts +63 -0
- package/dist/errors.js +43 -0
- package/dist/executor.d.ts +39 -0
- package/dist/executor.js +26 -0
- package/dist/globals.d.ts +32 -0
- package/dist/globals.js +284 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +13 -0
- package/dist/limits.d.ts +146 -0
- package/dist/limits.js +172 -0
- package/dist/marshal.d.ts +126 -0
- package/dist/marshal.js +260 -0
- package/dist/require.d.ts +43 -0
- package/dist/require.js +49 -0
- package/dist/sandbox.d.ts +71 -0
- package/dist/sandbox.js +336 -0
- package/package.json +51 -0
package/dist/globals.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { LuaFactory, LuaLibraries } from 'wasmoon';
|
|
2
|
+
/**
|
|
3
|
+
* Builds the *empty* Lua environment (spec §10: "Scripts run in an empty
|
|
4
|
+
* Lua environment: no `os`, no `io`, no `require`, no globals except the
|
|
5
|
+
* capability functions the host injects").
|
|
6
|
+
*
|
|
7
|
+
* Strategy: `openStandardLibs: false` means wasmoon never calls
|
|
8
|
+
* `luaL_openlibs` at all — `os`, `io`, `package`, `debug`, and `coroutine`
|
|
9
|
+
* are never linked into this Lua state's globals table in the first place.
|
|
10
|
+
* This is strictly stronger than "load everything, then delete the bad
|
|
11
|
+
* parts": there is nothing to delete because the C library that would have
|
|
12
|
+
* installed them was never invoked, so there is no back door (metatable,
|
|
13
|
+
* `_ENV`, or otherwise) that recovers them — recovering a name that was
|
|
14
|
+
* never assigned into the (single, shared) globals table is not possible in
|
|
15
|
+
* Lua regardless of what handle a script obtains on that table.
|
|
16
|
+
*
|
|
17
|
+
* We then hand-load exactly four libraries via `Global.loadLibrary` (the
|
|
18
|
+
* per-library `luaopen_*` wasmoon exposes) and immediately run a trusted
|
|
19
|
+
* "scrub" prelude that nils out the specific names, within those four
|
|
20
|
+
* libraries, that are still dangerous. Every inclusion/exclusion below is
|
|
21
|
+
* commented with why.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Libraries linked into the sandbox, and why:
|
|
25
|
+
* - Base (`_G`) — required: this is where `pcall`/`xpcall`/`error`/
|
|
26
|
+
* `assert`/`type`/`tostring`/`tonumber`/`pairs`/`ipairs`/`next`/`select`
|
|
27
|
+
* live in stock Lua 5.4. There's no way to get these without opening
|
|
28
|
+
* Base, so Base is opened and then aggressively pruned (see below) —
|
|
29
|
+
* everything Base brings that ISN'T on the whitelist gets nil'd out by
|
|
30
|
+
* the scrub prelude.
|
|
31
|
+
* - String — string manipulation is core to "fetch JSON, format a label"
|
|
32
|
+
* scripts; pruned of `string.dump` only (see SCRUB_PRELUDE).
|
|
33
|
+
* - Table — `table.insert/remove/concat/sort/pack/unpack/move`: no
|
|
34
|
+
* member of this library reads/writes anything outside the table passed
|
|
35
|
+
* to it, so the whole library is safe as-is; nothing pruned.
|
|
36
|
+
* - Math — pure numeric functions (including `math.random`/
|
|
37
|
+
* `math.randomseed` — a script influencing its own PRNG seed cannot
|
|
38
|
+
* reach anything outside its own arithmetic); nothing pruned.
|
|
39
|
+
*
|
|
40
|
+
* NOT linked, and why (spec §10 threat model: the script is hostile):
|
|
41
|
+
* - Coroutine — `coroutine.*` would let a script create additional Lua
|
|
42
|
+
* threads it controls directly. We already run the whole script on a
|
|
43
|
+
* dedicated child thread and drive `resume`/`yield` ourselves (for the
|
|
44
|
+
* capability await bridge, `./capabilities`, and the instruction-count
|
|
45
|
+
* hook, `./limits`); handing the script its own `coroutine.create` is
|
|
46
|
+
* unnecessary for the documented host API and only adds surface area
|
|
47
|
+
* (e.g. spawning coroutines to dodge the hook's per-thread installation
|
|
48
|
+
* — see the `./limits` doc comment on why hooks are per-thread).
|
|
49
|
+
* - IO, OS — filesystem/process/env/clock access. The bundle-scoped
|
|
50
|
+
* filesystem (§11) is the ONLY filesystem a script gets, via the
|
|
51
|
+
* injected `bundle.*` capability table (`./capabilities`), never real
|
|
52
|
+
* `io`.
|
|
53
|
+
* - Package — `package.loadlib`/`package.cpath` is exactly "load a native
|
|
54
|
+
* C module", meaningless (and dangerous, if it somehow resolved) in a
|
|
55
|
+
* WASM sandbox with no real filesystem; `require` itself is intentionally
|
|
56
|
+
* not wired to it either (see `./require`).
|
|
57
|
+
* - Debug — `debug.getmetatable`/`debug.getupvalue`/`debug.sethook`
|
|
58
|
+
* are a complete metatable/upvalue/hook bypass of every other
|
|
59
|
+
* restriction in this file. Never loaded.
|
|
60
|
+
* - UTF8 — not on the documented host API surface; omitted by default
|
|
61
|
+
* deny (a script working with byte strings and `string.*` covers the
|
|
62
|
+
* documented use cases). Add it if a real need shows up — it carries no
|
|
63
|
+
* special risk (pure string library), it just wasn't asked for.
|
|
64
|
+
*/
|
|
65
|
+
const LIBRARIES = [
|
|
66
|
+
LuaLibraries.Base,
|
|
67
|
+
LuaLibraries.String,
|
|
68
|
+
LuaLibraries.Table,
|
|
69
|
+
LuaLibraries.Math,
|
|
70
|
+
];
|
|
71
|
+
/**
|
|
72
|
+
* Runs once per fresh engine, before any capability table or user code is
|
|
73
|
+
* injected. Mutates the real `string` table object in place (`string.dump
|
|
74
|
+
* = nil`) rather than replacing the `string` global — the metatable that
|
|
75
|
+
* makes `("x"):upper()` work points at this same table object, so deleting
|
|
76
|
+
* the field this way removes both `string.dump(...)` AND `("x"):dump()` in
|
|
77
|
+
* one move, and can't be un-done by anything a script can reach (no
|
|
78
|
+
* `getmetatable` is exposed — see below — so a script cannot even inspect,
|
|
79
|
+
* let alone rewrite, that metatable).
|
|
80
|
+
*
|
|
81
|
+
* Base-library names removed, each independently a documented sandbox
|
|
82
|
+
* escape or ambient-authority primitive:
|
|
83
|
+
* - `load`, `loadstring`, `loadfile`, `dofile` — compile/run arbitrary
|
|
84
|
+
* source or bytecode text at runtime; the entire point of §10 is that
|
|
85
|
+
* the ONLY code that ever runs is the one chunk the host handed in.
|
|
86
|
+
* `lua_load` also accepts precompiled bytecode with no source-text
|
|
87
|
+
* validation — bytecode is not sandboxed the way source is (it can
|
|
88
|
+
* encode out-of-range opcodes that crash or exploit the VM), which is
|
|
89
|
+
* also why `string.dump` (bytecode *production*) is removed below.
|
|
90
|
+
* - `collectgarbage` — its `"count"` argument is a harmless memory query
|
|
91
|
+
* but other arguments (`"stop"`, `"generational"`, `"incremental"`) let
|
|
92
|
+
* a script retune the collector as a denial-of-service knob against the
|
|
93
|
+
* memory cap installed in `./sandbox`; removed wholesale rather than
|
|
94
|
+
* allow-listing a sub-mode we don't need.
|
|
95
|
+
* - `rawget`, `rawset`, `rawequal`, `rawlen` — bypass `__index`/
|
|
96
|
+
* `__newindex`/`__eq`/`__len` metamethods. Nothing in this sandbox
|
|
97
|
+
* currently relies on metamethod interception for security (we don't
|
|
98
|
+
* proxy the capability tables — see `./capabilities`), but keeping raw
|
|
99
|
+
* accessors off by default costs the sandbox nothing and closes off
|
|
100
|
+
* that category of future bug entirely: default-deny.
|
|
101
|
+
* - `getmetatable`, `setmetatable` — `getmetatable("")` is the standard
|
|
102
|
+
* way to reach the shared string metatable and, with `setmetatable`,
|
|
103
|
+
* rewrite it — potentially restoring a removed method or corrupting
|
|
104
|
+
* `__index` for every string literal for the rest of the run. Removing
|
|
105
|
+
* both closes this off completely (there is then no Lua-reachable way
|
|
106
|
+
* to obtain any metatable at all, since `debug.getmetatable` is also
|
|
107
|
+
* unavailable — `debug` is never loaded).
|
|
108
|
+
* - `print`, `warn` — write to the process's real stdout/stderr. Not a
|
|
109
|
+
* sandbox-escape by itself, but not on the documented host API surface
|
|
110
|
+
* (scripts communicate by *returning a value*, per spec §8 — "Scripts
|
|
111
|
+
* return values; they never write into the document body") and a
|
|
112
|
+
* console/stderr channel is an unnecessary side channel; default-deny.
|
|
113
|
+
* - `_G`, `_VERSION` — `_G` is merely the *name* Base binds to the real
|
|
114
|
+
* globals table for convenience; removing the name does not remove the
|
|
115
|
+
* table (every chunk's `_ENV` upvalue still refers to it — that's
|
|
116
|
+
* unavoidable in Lua and is exactly why every prune in this file is done
|
|
117
|
+
* by MUTATING the real table/globals rather than rebinding a name to
|
|
118
|
+
* something else). What matters is that the *table itself* never had
|
|
119
|
+
* `os`/`io`/`debug`/`package`/`load`/etc. as members to begin with, so
|
|
120
|
+
* reaching it via `_ENV` recovers nothing. `_VERSION` is a harmless
|
|
121
|
+
* info leak with no use on the documented API; removed for
|
|
122
|
+
* default-deny consistency.
|
|
123
|
+
*
|
|
124
|
+
* Explicitly KEPT from Base (each is on the DoD-mandated whitelist):
|
|
125
|
+
* `tonumber`, `tostring`, `type`, `ipairs`, `pairs`, `next`, `select`,
|
|
126
|
+
* `error`, `assert`, `pcall`, `xpcall`. NOTE: `xpcall` is kept in name only —
|
|
127
|
+
* the C-level `xpcall` Base actually provides is REPLACED by a pure-Lua
|
|
128
|
+
* reimplementation (`XPCALL_REIMPLEMENTATION` below, run as part of this
|
|
129
|
+
* prelude) that closes a host-deadlock the C version has under this
|
|
130
|
+
* sandbox's limits hook; see that constant's doc comment for the full
|
|
131
|
+
* mechanism and empirical evidence.
|
|
132
|
+
*
|
|
133
|
+
* `unpack`/`table.unpack`: Lua 5.4 (this build, no 5.1-compat flag) never
|
|
134
|
+
* defines a *global* `unpack` — only `table.unpack` exists in stock 5.4,
|
|
135
|
+
* confirmed empirically (a fresh engine with Base+Table loaded has no
|
|
136
|
+
* global `unpack`). We do not synthesize one; `table.unpack` is exposed as
|
|
137
|
+
* part of the (fully kept) Table library and is the documented spelling.
|
|
138
|
+
*/
|
|
139
|
+
/**
|
|
140
|
+
* Replaces the C-level \`xpcall\` with a pure-Lua reimplementation built on
|
|
141
|
+
* \`pcall\`.
|
|
142
|
+
*
|
|
143
|
+
* ## Why: the C \`xpcall\` deadlocks the host under the limits hook
|
|
144
|
+
*
|
|
145
|
+
* \`./limits\`' instruction/wall-clock interrupt works by having the JS
|
|
146
|
+
* count-hook call \`thread.lua.lua_error(...)\` — a longjmp — from inside the
|
|
147
|
+
* hook. That is an ordinary Lua error, so \`pcall\` and \`xpcall\` both catch
|
|
148
|
+
* it (this is exactly what makes the "script's own pcall can't swallow the
|
|
149
|
+
* interrupt" guarantee in \`./limits\` need the out-of-band JS flag in the
|
|
150
|
+
* first place). With \`pcall\` that is harmless: no user code runs in
|
|
151
|
+
* response, the call just returns \`false, err\` and the JS-side breach flag
|
|
152
|
+
* still forces the run to a hard failure. But the C \`xpcall\` INVOKES THE
|
|
153
|
+
* USER'S MESSAGE HANDLER as part of unwinding, while the VM is still inside
|
|
154
|
+
* the C-level xpcall error-handling frame. If that handler also runs long
|
|
155
|
+
* enough for the hook to fire again (trivially true once the hook has
|
|
156
|
+
* already tightened to \`count = 1\` after the first breach — see
|
|
157
|
+
* \`./limits\`), the second \`lua_error\` longjmps AGAIN, nested inside the
|
|
158
|
+
* xpcall error-handler's setjmp/Asyncify state, and wasmoon deadlocks:
|
|
159
|
+
* \`thread.run()\` never resolves or rejects, the JS event loop is fully
|
|
160
|
+
* blocked (not spinning — an idle wait), and nothing external, including an
|
|
161
|
+
* unrelated \`setTimeout\`, ever fires again. Verified empirically against
|
|
162
|
+
* wasmoon 1.16.0 in a disposable child-process harness (own OS-level SIGKILL
|
|
163
|
+
* watchdog, run outside this repo's test suite so a still-hanging case could
|
|
164
|
+
* never block CI): \`return xpcall(f, f)\` and three structural variants
|
|
165
|
+
* (looping in a \`while\` around the xpcall call, wrapping the xpcall call in
|
|
166
|
+
* an outer \`pcall\`, and a looping message handler with a non-looping body)
|
|
167
|
+
* all hang the host process indefinitely under the stock C \`xpcall\`, while
|
|
168
|
+
* \`pcall\`-only equivalents (including the pcall-wrapped-retry-loop "worst
|
|
169
|
+
* case" documented in \`./limits\`) correctly terminate as limit failures.
|
|
170
|
+
*
|
|
171
|
+
* ## The fix
|
|
172
|
+
*
|
|
173
|
+
* Reimplementing \`xpcall\` in Lua on top of \`pcall\` means the message
|
|
174
|
+
* handler runs at ORDINARY Lua call depth (an ordinary function call from
|
|
175
|
+
* inside this prelude's own \`xpcall\`, itself invoked through \`pcall\`),
|
|
176
|
+
* never inside the C xpcall error-unwind frame — so a hook-triggered
|
|
177
|
+
* longjmp during the handler propagates exactly like it does for the
|
|
178
|
+
* already-safe \`pcall\` cases, instead of deadlocking. Verified empirically
|
|
179
|
+
* in the same disposable harness: with this reimplementation installed, all
|
|
180
|
+
* four former-hang cases now terminate (well under a second) as
|
|
181
|
+
* \`{ok:false, error:{kind:'limit', limit:'instructions'}}\`, and legitimate
|
|
182
|
+
* \`xpcall(f, handler)\` usage (handler receiving the error object on
|
|
183
|
+
* failure; all return values passed through unchanged on success) is
|
|
184
|
+
* unaffected — see \`limits.deadlock.test.ts\`.
|
|
185
|
+
*
|
|
186
|
+
* ## Accepted semantics change (safe in this sandbox)
|
|
187
|
+
*
|
|
188
|
+
* The handler now runs AFTER the stack has unwound (an ordinary \`pcall\`
|
|
189
|
+
* return), not WHILE it is still live, so it cannot walk a live traceback
|
|
190
|
+
* the way \`debug.traceback\` would from inside a real C \`xpcall\` handler.
|
|
191
|
+
* This loses nothing real here: \`debug\` is never loaded in this sandbox
|
|
192
|
+
* (see the library table below), so no script could have used that
|
|
193
|
+
* capability anyway. \`table.pack\`/\`table.unpack\` preserve variadic
|
|
194
|
+
* arguments to \`f\` and every return value on the success path, matching
|
|
195
|
+
* stock \`xpcall\`'s multi-return contract.
|
|
196
|
+
*/
|
|
197
|
+
const XPCALL_REIMPLEMENTATION = `
|
|
198
|
+
do
|
|
199
|
+
local _pcall = pcall
|
|
200
|
+
xpcall = function(f, msgh, ...)
|
|
201
|
+
local r = table.pack(_pcall(f, ...))
|
|
202
|
+
if r[1] then return table.unpack(r, 1, r.n) end
|
|
203
|
+
return false, msgh(r[2])
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
`;
|
|
207
|
+
const SCRUB_PRELUDE = `
|
|
208
|
+
load = nil
|
|
209
|
+
loadstring = nil
|
|
210
|
+
loadfile = nil
|
|
211
|
+
dofile = nil
|
|
212
|
+
collectgarbage = nil
|
|
213
|
+
rawget = nil
|
|
214
|
+
rawset = nil
|
|
215
|
+
rawequal = nil
|
|
216
|
+
rawlen = nil
|
|
217
|
+
getmetatable = nil
|
|
218
|
+
setmetatable = nil
|
|
219
|
+
print = nil
|
|
220
|
+
warn = nil
|
|
221
|
+
_G = nil
|
|
222
|
+
_VERSION = nil
|
|
223
|
+
string.dump = nil
|
|
224
|
+
${XPCALL_REIMPLEMENTATION}
|
|
225
|
+
`;
|
|
226
|
+
/** The Base-library names this sandbox intentionally keeps reachable. */
|
|
227
|
+
export const ALLOWED_GLOBALS = [
|
|
228
|
+
'tonumber',
|
|
229
|
+
'tostring',
|
|
230
|
+
'type',
|
|
231
|
+
'ipairs',
|
|
232
|
+
'pairs',
|
|
233
|
+
'next',
|
|
234
|
+
'select',
|
|
235
|
+
'error',
|
|
236
|
+
'assert',
|
|
237
|
+
'pcall',
|
|
238
|
+
'xpcall',
|
|
239
|
+
'string',
|
|
240
|
+
'table',
|
|
241
|
+
'math',
|
|
242
|
+
];
|
|
243
|
+
/** Names this sandbox verifies are absent after scrubbing (see the adversarial test suite). */
|
|
244
|
+
export const DENIED_GLOBALS = [
|
|
245
|
+
'os',
|
|
246
|
+
'io',
|
|
247
|
+
'require',
|
|
248
|
+
'dofile',
|
|
249
|
+
'loadfile',
|
|
250
|
+
'load',
|
|
251
|
+
'loadstring',
|
|
252
|
+
'debug',
|
|
253
|
+
'package',
|
|
254
|
+
'coroutine',
|
|
255
|
+
'collectgarbage',
|
|
256
|
+
'rawget',
|
|
257
|
+
'rawset',
|
|
258
|
+
'rawequal',
|
|
259
|
+
'rawlen',
|
|
260
|
+
'getmetatable',
|
|
261
|
+
'setmetatable',
|
|
262
|
+
'print',
|
|
263
|
+
'warn',
|
|
264
|
+
'_G',
|
|
265
|
+
'_VERSION',
|
|
266
|
+
];
|
|
267
|
+
/**
|
|
268
|
+
* Creates a fresh wasmoon engine with the curated, empty environment
|
|
269
|
+
* described above. `traceAllocations: true` is required for the memory cap
|
|
270
|
+
* (`./limits` / `./sandbox` call `engine.global.setMemoryMax`) — without it
|
|
271
|
+
* wasmoon uses the plain, uncapped allocator.
|
|
272
|
+
*/
|
|
273
|
+
export async function createEmptyLuaEngine(options) {
|
|
274
|
+
const factory = new LuaFactory(options?.wasmUri);
|
|
275
|
+
const engine = await factory.createEngine({
|
|
276
|
+
openStandardLibs: false,
|
|
277
|
+
traceAllocations: true,
|
|
278
|
+
});
|
|
279
|
+
for (const library of LIBRARIES) {
|
|
280
|
+
engine.global.loadLibrary(library);
|
|
281
|
+
}
|
|
282
|
+
await engine.doString(SCRUB_PRELUDE);
|
|
283
|
+
return engine;
|
|
284
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type { ScriptFailure, ScriptLimitKind, ScriptMarshalReason, } from './errors.js';
|
|
2
|
+
export { CAPABILITY_ERROR_TAG, MARSHAL_ERROR_TAG, ScriptLimitError, } from './errors.js';
|
|
3
|
+
export type { CreateEmptyLuaEngineOptions } from './globals.js';
|
|
4
|
+
export { ALLOWED_GLOBALS, DENIED_GLOBALS, createEmptyLuaEngine, } from './globals.js';
|
|
5
|
+
export type { LimitHandle, ScriptLimits } from './limits.js';
|
|
6
|
+
export { DEFAULT_LIMITS, installLimits } from './limits.js';
|
|
7
|
+
export type { CacheEntry, CacheProvider, CapabilityConfig, CapabilityTier, NetGrants, NetProvider, NetResponse, } from './capabilities.js';
|
|
8
|
+
export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, luaStringToBytes, } from './capabilities.js';
|
|
9
|
+
export type { MarshalLimits } from './marshal.js';
|
|
10
|
+
export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
|
|
11
|
+
export { NOT_YET_SUPPORTED_MESSAGE, buildRequireStub } from './require.js';
|
|
12
|
+
export type { RunScriptOptions, RunScriptResult } from './sandbox.js';
|
|
13
|
+
export { runScript } from './sandbox.js';
|
|
14
|
+
export type { LuaExecutorConfig } from './executor.js';
|
|
15
|
+
export { createLuaExecutor } from './executor.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// @markii/lua: the sandboxed Lua 5.4 (wasmoon) execution primitive backing
|
|
2
|
+
// spec §8 (scripting), §10 (capability security), and §11 (bundle-scoped
|
|
3
|
+
// filesystem). No React, no @markii/core, no @markii/react — see CLAUDE.md's
|
|
4
|
+
// import rule and the ESLint guard in the root config. May depend on
|
|
5
|
+
// @markii/bundle for the `ScriptView` capability type only.
|
|
6
|
+
export { CAPABILITY_ERROR_TAG, MARSHAL_ERROR_TAG, ScriptLimitError, } from './errors.js';
|
|
7
|
+
export { ALLOWED_GLOBALS, DENIED_GLOBALS, createEmptyLuaEngine, } from './globals.js';
|
|
8
|
+
export { DEFAULT_LIMITS, installLimits } from './limits.js';
|
|
9
|
+
export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, luaStringToBytes, } from './capabilities.js';
|
|
10
|
+
export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
|
|
11
|
+
export { NOT_YET_SUPPORTED_MESSAGE, buildRequireStub } from './require.js';
|
|
12
|
+
export { runScript } from './sandbox.js';
|
|
13
|
+
export { createLuaExecutor } from './executor.js';
|
package/dist/limits.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { type LuaThread } from 'wasmoon';
|
|
2
|
+
import type { ScriptLimitKind } from './errors.js';
|
|
3
|
+
/** Resource limits for one `runScript` call. All configurable; defaults are conservative. */
|
|
4
|
+
export interface ScriptLimits {
|
|
5
|
+
/** Lua VM instructions before the run is killed. Default 100,000,000. */
|
|
6
|
+
maxInstructions: number;
|
|
7
|
+
/** Wall-clock milliseconds before the run is killed. Default 5,000. */
|
|
8
|
+
wallClockMs: number;
|
|
9
|
+
/** Bytes the Lua allocator may hand out before allocations start failing. Default 32 MiB. */
|
|
10
|
+
maxMemoryBytes: number;
|
|
11
|
+
/**
|
|
12
|
+
* VM instructions between hook firings (the `count` argument to
|
|
13
|
+
* `lua_sethook`'s `LUA_MASKCOUNT`). Smaller = finer-grained wall-clock
|
|
14
|
+
* checks and lower overshoot past `maxInstructions`, at the cost of more
|
|
15
|
+
* hook-call overhead per instruction executed. Default 10,000.
|
|
16
|
+
*/
|
|
17
|
+
hookIntervalInstructions: number;
|
|
18
|
+
}
|
|
19
|
+
export declare const DEFAULT_LIMITS: ScriptLimits;
|
|
20
|
+
export interface LimitHandle {
|
|
21
|
+
/** True once the instruction/wall-clock hook has fired at least once. Authoritative: check this after every run, regardless of the run's apparent outcome (see the module doc comment). */
|
|
22
|
+
isBreached(): boolean;
|
|
23
|
+
breachKind(): ScriptLimitKind | undefined;
|
|
24
|
+
/** Removes the WASM function pointer and clears the hook. Must be called exactly once, in the run's `finally`. */
|
|
25
|
+
dispose(): void;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Installs the instruction-count + wall-clock interrupt on `thread` (a
|
|
29
|
+
* dedicated child thread from `engine.global.newThread()` — see the "hooks
|
|
30
|
+
* are per-thread" note below) and returns a handle to read/tear it down.
|
|
31
|
+
*
|
|
32
|
+
* ## Why this exists instead of wasmoon's own `Thread.setTimeout`/`run({
|
|
33
|
+
* timeout })`
|
|
34
|
+
*
|
|
35
|
+
* wasmoon ships a built-in mechanism (`Thread.setTimeout`, used internally
|
|
36
|
+
* by `run({ timeout })`): it installs a `lua_sethook` count hook that, once
|
|
37
|
+
* `Date.now() > deadline`, pushes a `LuaTimeoutError` and calls
|
|
38
|
+
* `lua_error`. That is a completely ordinary Lua error from the VM's point
|
|
39
|
+
* of view — indistinguishable from a script calling `error("x")` itself —
|
|
40
|
+
* which means it is fully catchable by `pcall`:
|
|
41
|
+
*
|
|
42
|
+
* ```lua
|
|
43
|
+
* pcall(function() while true do end end) -- catches the timeout, keeps going
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* This is exactly the case the task calls out as "the crucial one": the
|
|
47
|
+
* interrupt must abort the WHOLE RUN, not be swallowed by the script's own
|
|
48
|
+
* `pcall`. Verified empirically against wasmoon 1.16.0 — a script that
|
|
49
|
+
* `pcall`s an infinite loop and then keeps running afterward completes
|
|
50
|
+
* "successfully" under wasmoon's own `setTimeout`/`run({timeout})`
|
|
51
|
+
* mechanism; the timeout is silently absorbed.
|
|
52
|
+
*
|
|
53
|
+
* ## The fix: an out-of-band JS flag, plus shrinking the hook interval to 1
|
|
54
|
+
*
|
|
55
|
+
* This hook does two things wasmoon's built-in one doesn't:
|
|
56
|
+
*
|
|
57
|
+
* 1. It sets a plain JS closure variable (`breached`) the instant the limit
|
|
58
|
+
* is first exceeded. This flag lives entirely on the JS side — no Lua
|
|
59
|
+
* value, no metatable, no `pcall` can ever see or clear it. `sandbox.ts`
|
|
60
|
+
* checks this flag UNCONDITIONALLY after every run and overrides the
|
|
61
|
+
* result to a hard `ScriptLimitError` if it's set, regardless of what
|
|
62
|
+
* the Lua-level call returned or whether a `pcall` inside the script
|
|
63
|
+
* reported "success". This is the actual enforcement point — not the
|
|
64
|
+
* Lua-level error the hook also raises (see next point).
|
|
65
|
+
* 2. On the FIRST breach, it re-installs the hook with `count = 1` (fires
|
|
66
|
+
* on literally every subsequent VM instruction, not just every
|
|
67
|
+
* `hookIntervalInstructions`). Combined with the hook continuing to
|
|
68
|
+
* call `lua_error` every time it fires, this makes forward progress
|
|
69
|
+
* after a breach exceedingly hard: even a script that wraps the
|
|
70
|
+
* offending loop in its own `pcall` and immediately retries in an outer
|
|
71
|
+
* loop gets re-interrupted after (usually) a single VM instruction, so
|
|
72
|
+
* the error re-escalates and reaches the top of the call stack (a
|
|
73
|
+
* frame with no enclosing `pcall`) almost immediately. Verified
|
|
74
|
+
* empirically: `local n=0; while true do pcall(function() while true do
|
|
75
|
+
* end end); n=n+1 end` — the worst case in the adversarial suite,
|
|
76
|
+
* combining an inner `pcall`-wrapped infinite loop with an outer
|
|
77
|
+
* infinite retry loop — still terminates (as a hard failure, `n` never
|
|
78
|
+
* returned) in under 100ms with `maxInstructions = 5,000,000`.
|
|
79
|
+
*
|
|
80
|
+
* Point 2 is a strong practical deterrent but is NOT a formal proof of
|
|
81
|
+
* "zero possible forward progress after breach" — a script could in
|
|
82
|
+
* principle structure code so that literally every single VM instruction
|
|
83
|
+
* after the breach happens inside its own fresh `pcall` frame (each
|
|
84
|
+
* `pcall` call itself is several instructions, some of which execute
|
|
85
|
+
* outside any protection while the closure/call frame is being set up).
|
|
86
|
+
* Point 1 is what actually closes the loophole: even in that
|
|
87
|
+
* (implausible, and in practice never observed) worst case, the run's
|
|
88
|
+
* *reported outcome* is still forced to a limit failure by the JS-side
|
|
89
|
+
* flag, which the script has no way to reach or clear. **Solid** claim:
|
|
90
|
+
* the reported result of a breached run is always a limit failure, never
|
|
91
|
+
* a script-controlled "success" — but this was verified against `pcall`
|
|
92
|
+
* specifically, and does NOT generalize to every Lua construct that can
|
|
93
|
+
* catch an error. It was FALSE, for one such construct, until a separate
|
|
94
|
+
* fix landed: the stock C `xpcall` invokes its message handler WHILE still
|
|
95
|
+
* inside the C-level xpcall error-unwind frame, and a hook-triggered
|
|
96
|
+
* longjmp firing again during that handler (trivial once the hook has
|
|
97
|
+
* tightened to `count = 1` after the first breach) re-enters that same
|
|
98
|
+
* setjmp/Asyncify state and deadlocks wasmoon's `thread.run()` outright —
|
|
99
|
+
* not a script-controlled "success", but a hung HOST, which is worse. That
|
|
100
|
+
* specific hole is closed by replacing `xpcall` with a pure-Lua
|
|
101
|
+
* reimplementation built on `pcall` (see `./globals`'s
|
|
102
|
+
* `XPCALL_REIMPLEMENTATION`), so the message handler runs at ordinary Lua
|
|
103
|
+
* call depth instead of inside the C xpcall frame, where a re-firing hook
|
|
104
|
+
* behaves exactly like the already-safe `pcall` case. **Best-effort, not
|
|
105
|
+
* airtight** claim, even with that fix: the breached script's own further
|
|
106
|
+
* CPU consumption is curtailed almost immediately, not provably instantly,
|
|
107
|
+
* and this in-VM hook is fundamentally a cooperative, best-effort
|
|
108
|
+
* mechanism — it only runs BETWEEN Lua VM instructions and cannot preempt
|
|
109
|
+
* a single WASM-synchronous hang (whether from an as-yet-undiscovered catch
|
|
110
|
+
* construct with the same nested-frame shape as the old `xpcall`, or from
|
|
111
|
+
* something outside the VM's own instruction stream entirely). The
|
|
112
|
+
* AUTHORITATIVE kill for that class of failure is not this hook at all —
|
|
113
|
+
* it's the host's EXTERNAL, terminatable-isolate watchdog (dedicated Web
|
|
114
|
+
* Worker/`worker_thread` + an outside wall-clock timer calling
|
|
115
|
+
* `terminate()`), now normative in DESIGN.md §10 ("In-process limits are
|
|
116
|
+
* best-effort; the terminatable isolate is the real guarantee"). This
|
|
117
|
+
* module's hook reduces how often that external kill is needed and gives
|
|
118
|
+
* fast, precise, in-band error classification for the common compute-bound
|
|
119
|
+
* case; it is not, and cannot be, a substitute for the external watchdog.
|
|
120
|
+
*
|
|
121
|
+
* ## Why hooks are per-thread, and why that matters here
|
|
122
|
+
*
|
|
123
|
+
* `lua_sethook` sets the hook on the specific `lua_State` passed to it.
|
|
124
|
+
* `engine.doString()` internally creates a NEW child thread via
|
|
125
|
+
* `engine.global.newThread()` and runs the script there — a hook installed
|
|
126
|
+
* on `engine.global.address` would never fire for code run through
|
|
127
|
+
* `doString`. `sandbox.ts` therefore does NOT use `engine.doString()` for
|
|
128
|
+
* the untrusted script; it creates the child thread itself (mirroring what
|
|
129
|
+
* `doString` does internally) so it can install this hook on that exact
|
|
130
|
+
* thread before loading/running the script.
|
|
131
|
+
*
|
|
132
|
+
* ## Wall-clock accuracy
|
|
133
|
+
*
|
|
134
|
+
* The wall-clock check only runs when the hook fires — i.e. every
|
|
135
|
+
* `hookIntervalInstructions` VM instructions. This is not a true
|
|
136
|
+
* OS-level preemption (nothing in wasmoon offers that inside a single JS
|
|
137
|
+
* thread); it bounds overshoot to "however long `hookIntervalInstructions`
|
|
138
|
+
* takes to execute", which for the default of 10,000 is sub-millisecond on
|
|
139
|
+
* ordinary hardware. It does NOT protect against a single hung *host*
|
|
140
|
+
* operation (e.g. a capability call that never resolves its promise) —
|
|
141
|
+
* that's a separate concern handled by the `Promise.race` wall-clock guard
|
|
142
|
+
* in `sandbox.ts`, since Lua isn't executing any instructions while
|
|
143
|
+
* suspended on an `await`, so this hook simply never fires during that
|
|
144
|
+
* window.
|
|
145
|
+
*/
|
|
146
|
+
export declare function installLimits(thread: LuaThread, limits: Pick<ScriptLimits, 'maxInstructions' | 'wallClockMs' | 'hookIntervalInstructions'>): LimitHandle;
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { LuaEventMasks } from 'wasmoon';
|
|
2
|
+
export const DEFAULT_LIMITS = {
|
|
3
|
+
maxInstructions: 100_000_000,
|
|
4
|
+
wallClockMs: 5_000,
|
|
5
|
+
maxMemoryBytes: 32 * 1024 * 1024,
|
|
6
|
+
hookIntervalInstructions: 10_000,
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Installs the instruction-count + wall-clock interrupt on `thread` (a
|
|
10
|
+
* dedicated child thread from `engine.global.newThread()` — see the "hooks
|
|
11
|
+
* are per-thread" note below) and returns a handle to read/tear it down.
|
|
12
|
+
*
|
|
13
|
+
* ## Why this exists instead of wasmoon's own `Thread.setTimeout`/`run({
|
|
14
|
+
* timeout })`
|
|
15
|
+
*
|
|
16
|
+
* wasmoon ships a built-in mechanism (`Thread.setTimeout`, used internally
|
|
17
|
+
* by `run({ timeout })`): it installs a `lua_sethook` count hook that, once
|
|
18
|
+
* `Date.now() > deadline`, pushes a `LuaTimeoutError` and calls
|
|
19
|
+
* `lua_error`. That is a completely ordinary Lua error from the VM's point
|
|
20
|
+
* of view — indistinguishable from a script calling `error("x")` itself —
|
|
21
|
+
* which means it is fully catchable by `pcall`:
|
|
22
|
+
*
|
|
23
|
+
* ```lua
|
|
24
|
+
* pcall(function() while true do end end) -- catches the timeout, keeps going
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* This is exactly the case the task calls out as "the crucial one": the
|
|
28
|
+
* interrupt must abort the WHOLE RUN, not be swallowed by the script's own
|
|
29
|
+
* `pcall`. Verified empirically against wasmoon 1.16.0 — a script that
|
|
30
|
+
* `pcall`s an infinite loop and then keeps running afterward completes
|
|
31
|
+
* "successfully" under wasmoon's own `setTimeout`/`run({timeout})`
|
|
32
|
+
* mechanism; the timeout is silently absorbed.
|
|
33
|
+
*
|
|
34
|
+
* ## The fix: an out-of-band JS flag, plus shrinking the hook interval to 1
|
|
35
|
+
*
|
|
36
|
+
* This hook does two things wasmoon's built-in one doesn't:
|
|
37
|
+
*
|
|
38
|
+
* 1. It sets a plain JS closure variable (`breached`) the instant the limit
|
|
39
|
+
* is first exceeded. This flag lives entirely on the JS side — no Lua
|
|
40
|
+
* value, no metatable, no `pcall` can ever see or clear it. `sandbox.ts`
|
|
41
|
+
* checks this flag UNCONDITIONALLY after every run and overrides the
|
|
42
|
+
* result to a hard `ScriptLimitError` if it's set, regardless of what
|
|
43
|
+
* the Lua-level call returned or whether a `pcall` inside the script
|
|
44
|
+
* reported "success". This is the actual enforcement point — not the
|
|
45
|
+
* Lua-level error the hook also raises (see next point).
|
|
46
|
+
* 2. On the FIRST breach, it re-installs the hook with `count = 1` (fires
|
|
47
|
+
* on literally every subsequent VM instruction, not just every
|
|
48
|
+
* `hookIntervalInstructions`). Combined with the hook continuing to
|
|
49
|
+
* call `lua_error` every time it fires, this makes forward progress
|
|
50
|
+
* after a breach exceedingly hard: even a script that wraps the
|
|
51
|
+
* offending loop in its own `pcall` and immediately retries in an outer
|
|
52
|
+
* loop gets re-interrupted after (usually) a single VM instruction, so
|
|
53
|
+
* the error re-escalates and reaches the top of the call stack (a
|
|
54
|
+
* frame with no enclosing `pcall`) almost immediately. Verified
|
|
55
|
+
* empirically: `local n=0; while true do pcall(function() while true do
|
|
56
|
+
* end end); n=n+1 end` — the worst case in the adversarial suite,
|
|
57
|
+
* combining an inner `pcall`-wrapped infinite loop with an outer
|
|
58
|
+
* infinite retry loop — still terminates (as a hard failure, `n` never
|
|
59
|
+
* returned) in under 100ms with `maxInstructions = 5,000,000`.
|
|
60
|
+
*
|
|
61
|
+
* Point 2 is a strong practical deterrent but is NOT a formal proof of
|
|
62
|
+
* "zero possible forward progress after breach" — a script could in
|
|
63
|
+
* principle structure code so that literally every single VM instruction
|
|
64
|
+
* after the breach happens inside its own fresh `pcall` frame (each
|
|
65
|
+
* `pcall` call itself is several instructions, some of which execute
|
|
66
|
+
* outside any protection while the closure/call frame is being set up).
|
|
67
|
+
* Point 1 is what actually closes the loophole: even in that
|
|
68
|
+
* (implausible, and in practice never observed) worst case, the run's
|
|
69
|
+
* *reported outcome* is still forced to a limit failure by the JS-side
|
|
70
|
+
* flag, which the script has no way to reach or clear. **Solid** claim:
|
|
71
|
+
* the reported result of a breached run is always a limit failure, never
|
|
72
|
+
* a script-controlled "success" — but this was verified against `pcall`
|
|
73
|
+
* specifically, and does NOT generalize to every Lua construct that can
|
|
74
|
+
* catch an error. It was FALSE, for one such construct, until a separate
|
|
75
|
+
* fix landed: the stock C `xpcall` invokes its message handler WHILE still
|
|
76
|
+
* inside the C-level xpcall error-unwind frame, and a hook-triggered
|
|
77
|
+
* longjmp firing again during that handler (trivial once the hook has
|
|
78
|
+
* tightened to `count = 1` after the first breach) re-enters that same
|
|
79
|
+
* setjmp/Asyncify state and deadlocks wasmoon's `thread.run()` outright —
|
|
80
|
+
* not a script-controlled "success", but a hung HOST, which is worse. That
|
|
81
|
+
* specific hole is closed by replacing `xpcall` with a pure-Lua
|
|
82
|
+
* reimplementation built on `pcall` (see `./globals`'s
|
|
83
|
+
* `XPCALL_REIMPLEMENTATION`), so the message handler runs at ordinary Lua
|
|
84
|
+
* call depth instead of inside the C xpcall frame, where a re-firing hook
|
|
85
|
+
* behaves exactly like the already-safe `pcall` case. **Best-effort, not
|
|
86
|
+
* airtight** claim, even with that fix: the breached script's own further
|
|
87
|
+
* CPU consumption is curtailed almost immediately, not provably instantly,
|
|
88
|
+
* and this in-VM hook is fundamentally a cooperative, best-effort
|
|
89
|
+
* mechanism — it only runs BETWEEN Lua VM instructions and cannot preempt
|
|
90
|
+
* a single WASM-synchronous hang (whether from an as-yet-undiscovered catch
|
|
91
|
+
* construct with the same nested-frame shape as the old `xpcall`, or from
|
|
92
|
+
* something outside the VM's own instruction stream entirely). The
|
|
93
|
+
* AUTHORITATIVE kill for that class of failure is not this hook at all —
|
|
94
|
+
* it's the host's EXTERNAL, terminatable-isolate watchdog (dedicated Web
|
|
95
|
+
* Worker/`worker_thread` + an outside wall-clock timer calling
|
|
96
|
+
* `terminate()`), now normative in DESIGN.md §10 ("In-process limits are
|
|
97
|
+
* best-effort; the terminatable isolate is the real guarantee"). This
|
|
98
|
+
* module's hook reduces how often that external kill is needed and gives
|
|
99
|
+
* fast, precise, in-band error classification for the common compute-bound
|
|
100
|
+
* case; it is not, and cannot be, a substitute for the external watchdog.
|
|
101
|
+
*
|
|
102
|
+
* ## Why hooks are per-thread, and why that matters here
|
|
103
|
+
*
|
|
104
|
+
* `lua_sethook` sets the hook on the specific `lua_State` passed to it.
|
|
105
|
+
* `engine.doString()` internally creates a NEW child thread via
|
|
106
|
+
* `engine.global.newThread()` and runs the script there — a hook installed
|
|
107
|
+
* on `engine.global.address` would never fire for code run through
|
|
108
|
+
* `doString`. `sandbox.ts` therefore does NOT use `engine.doString()` for
|
|
109
|
+
* the untrusted script; it creates the child thread itself (mirroring what
|
|
110
|
+
* `doString` does internally) so it can install this hook on that exact
|
|
111
|
+
* thread before loading/running the script.
|
|
112
|
+
*
|
|
113
|
+
* ## Wall-clock accuracy
|
|
114
|
+
*
|
|
115
|
+
* The wall-clock check only runs when the hook fires — i.e. every
|
|
116
|
+
* `hookIntervalInstructions` VM instructions. This is not a true
|
|
117
|
+
* OS-level preemption (nothing in wasmoon offers that inside a single JS
|
|
118
|
+
* thread); it bounds overshoot to "however long `hookIntervalInstructions`
|
|
119
|
+
* takes to execute", which for the default of 10,000 is sub-millisecond on
|
|
120
|
+
* ordinary hardware. It does NOT protect against a single hung *host*
|
|
121
|
+
* operation (e.g. a capability call that never resolves its promise) —
|
|
122
|
+
* that's a separate concern handled by the `Promise.race` wall-clock guard
|
|
123
|
+
* in `sandbox.ts`, since Lua isn't executing any instructions while
|
|
124
|
+
* suspended on an `await`, so this hook simply never fires during that
|
|
125
|
+
* window.
|
|
126
|
+
*/
|
|
127
|
+
export function installLimits(thread, limits) {
|
|
128
|
+
let breached = false;
|
|
129
|
+
let kind;
|
|
130
|
+
let instructionsSeen = 0;
|
|
131
|
+
let hookInterval = limits.hookIntervalInstructions;
|
|
132
|
+
const deadline = Date.now() + limits.wallClockMs;
|
|
133
|
+
const hookPointer = thread.lua.module.addFunction(() => {
|
|
134
|
+
instructionsSeen += hookInterval;
|
|
135
|
+
if (!breached) {
|
|
136
|
+
if (instructionsSeen >= limits.maxInstructions) {
|
|
137
|
+
breached = true;
|
|
138
|
+
kind = 'instructions';
|
|
139
|
+
}
|
|
140
|
+
else if (Date.now() >= deadline) {
|
|
141
|
+
breached = true;
|
|
142
|
+
kind = 'timeout';
|
|
143
|
+
}
|
|
144
|
+
if (breached) {
|
|
145
|
+
// Tighten the hook so every subsequent instruction re-triggers —
|
|
146
|
+
// see the module doc comment ("point 2").
|
|
147
|
+
hookInterval = 1;
|
|
148
|
+
thread.lua.lua_sethook(thread.address, hookPointer, LuaEventMasks.Count, 1);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (breached) {
|
|
152
|
+
thread.pushValue(`MARK_LIMIT: ${kind ?? 'instructions'} limit exceeded`);
|
|
153
|
+
thread.lua.lua_error(thread.address);
|
|
154
|
+
}
|
|
155
|
+
// Unreachable in practice: lua_error longjmps and never returns here.
|
|
156
|
+
}, 'vii');
|
|
157
|
+
thread.lua.lua_sethook(thread.address, hookPointer, LuaEventMasks.Count, limits.hookIntervalInstructions);
|
|
158
|
+
let disposed = false;
|
|
159
|
+
return {
|
|
160
|
+
isBreached: () => breached,
|
|
161
|
+
breachKind: () => kind,
|
|
162
|
+
dispose: () => {
|
|
163
|
+
if (disposed)
|
|
164
|
+
return;
|
|
165
|
+
disposed = true;
|
|
166
|
+
if (!thread.isClosed()) {
|
|
167
|
+
thread.lua.lua_sethook(thread.address, null, 0, 0);
|
|
168
|
+
}
|
|
169
|
+
thread.lua.module.removeFunction(hookPointer);
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|