@markii/lua 0.3.0 → 0.3.1
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/dist/capabilities.d.ts +13 -0
- package/dist/capabilities.js +206 -15
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +16 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/json-decode.d.ts +89 -0
- package/dist/json-decode.js +354 -0
- package/dist/marshal.d.ts +34 -0
- package/dist/marshal.js +65 -2
- package/dist/sandbox.js +1 -0
- package/package.json +3 -3
package/dist/capabilities.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ScriptView } from '@markii/bundle';
|
|
2
|
+
import { type MarshalLimits } from './marshal.js';
|
|
2
3
|
/** A GET/POST/PATCH result handed back to Lua as `{status=..., body=...}`. */
|
|
3
4
|
export interface NetResponse {
|
|
4
5
|
status: number;
|
|
@@ -51,6 +52,18 @@ export interface CapabilityConfig {
|
|
|
51
52
|
/** Bundle-scoped filesystem view (spec §11) — already capability-restricted by `@markii/bundle`'s `createScriptView`; this module delegates to it, never re-implements the path-jail or write policy. */
|
|
52
53
|
bundle?: ScriptView;
|
|
53
54
|
maxFetchBytes?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Depth/node budget for a `net.fetch_json` response, checked (via
|
|
57
|
+
* `./marshal`'s `checkJsonWithinLimits`) against the parsed JSON BEFORE
|
|
58
|
+
* the raw body text is ever handed to Lua's `__smd_json_decode`
|
|
59
|
+
* (`./json-decode`) — see that module's doc comment for the full
|
|
60
|
+
* rationale (GitHub issue #6). Defaults to `./marshal`'s
|
|
61
|
+
* `DEFAULT_MARSHAL_LIMITS`, the same defaults `runScript` already uses
|
|
62
|
+
* for the return-value marshal walk, so a fetched response and a
|
|
63
|
+
* script's own return value are held to one shared limit by default,
|
|
64
|
+
* not two independently-tuned ones.
|
|
65
|
+
*/
|
|
66
|
+
marshalLimits?: MarshalLimits;
|
|
54
67
|
}
|
|
55
68
|
export declare const DEFAULT_MAX_FETCH_BYTES = 2000000;
|
|
56
69
|
/** One genuine capability denial, as recorded by `buildCapabilities`' `denials` handle — see its doc comment. */
|
package/dist/capabilities.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { LuaMultiReturn } from 'wasmoon';
|
|
2
|
+
import { CAPABILITY_ERROR_TAG, MARSHAL_ERROR_TAG } from './errors.js';
|
|
3
|
+
import { buildJsonDecodePrelude } from './json-decode.js';
|
|
4
|
+
import { buildMarshalPrelude, checkJsonWithinLimits, DEFAULT_MARSHAL_LIMITS, finalizeMarshaledValue, } from './marshal.js';
|
|
2
5
|
export const DEFAULT_MAX_FETCH_BYTES = 2_000_000;
|
|
3
6
|
function capabilityError(message) {
|
|
4
7
|
return new Error(`${CAPABILITY_ERROR_TAG}: ${message}`);
|
|
@@ -94,8 +97,38 @@ export function luaStringToBytes(s) {
|
|
|
94
97
|
*/
|
|
95
98
|
export function buildCapabilities(config) {
|
|
96
99
|
const maxFetchBytes = config.maxFetchBytes ?? DEFAULT_MAX_FETCH_BYTES;
|
|
100
|
+
const marshalLimits = config.marshalLimits ?? DEFAULT_MARSHAL_LIMITS;
|
|
97
101
|
const rawGlobals = {};
|
|
98
102
|
const preludeParts = [];
|
|
103
|
+
// `__smd_json_decode` (`./json-decode`) is needed by BOTH `net.fetch_json`
|
|
104
|
+
// and `cache.get` (a cache hit re-enters Lua the same way a fetch result
|
|
105
|
+
// does — see the `cache` section below) — injected at most once
|
|
106
|
+
// regardless of how many callers need it. Re-defining the same Lua
|
|
107
|
+
// global twice would be harmless (Lua allows redefining a function), but
|
|
108
|
+
// there is no reason to emit the prelude source twice.
|
|
109
|
+
let jsonDecodePreludeInjected = false;
|
|
110
|
+
function ensureJsonDecodePrelude() {
|
|
111
|
+
if (jsonDecodePreludeInjected)
|
|
112
|
+
return;
|
|
113
|
+
jsonDecodePreludeInjected = true;
|
|
114
|
+
preludeParts.push(buildJsonDecodePrelude(marshalLimits));
|
|
115
|
+
}
|
|
116
|
+
// `__smd_marshal_root` (`./marshal`'s `buildMarshalPrelude`) is normally
|
|
117
|
+
// injected once, unconditionally, by `sandbox.ts` — AFTER this module's
|
|
118
|
+
// own prelude. `cache.get`'s internal refresh path (below) needs it
|
|
119
|
+
// available too, and needs it self-contained here rather than assuming
|
|
120
|
+
// that injection order: this module is exercised standalone (see
|
|
121
|
+
// `capabilities.test.ts`, which never touches `sandbox.ts`), so it must
|
|
122
|
+
// not depend on a caller injecting it. Re-running `sandbox.ts`'s own
|
|
123
|
+
// injection afterward just redefines the same idempotent Lua functions —
|
|
124
|
+
// harmless.
|
|
125
|
+
let marshalPreludeInjected = false;
|
|
126
|
+
function ensureMarshalPrelude() {
|
|
127
|
+
if (marshalPreludeInjected)
|
|
128
|
+
return;
|
|
129
|
+
marshalPreludeInjected = true;
|
|
130
|
+
preludeParts.push(buildMarshalPrelude(marshalLimits));
|
|
131
|
+
}
|
|
99
132
|
// Out-of-band denial record — see `CapabilityDenials`'s doc comment. Every
|
|
100
133
|
// site below that throws a `capabilityError` records here FIRST, so
|
|
101
134
|
// `sandbox.ts` can classify the failure by this JS-only signal instead of
|
|
@@ -145,12 +178,42 @@ export function buildCapabilities(config) {
|
|
|
145
178
|
recordDenial('denied', message);
|
|
146
179
|
throw capabilityError(message);
|
|
147
180
|
}
|
|
148
|
-
|
|
181
|
+
// Depth/node budget, checked HERE on the plain parsed JS value and
|
|
182
|
+
// BEFORE the raw text is ever handed to Lua — see `./json-decode`'s
|
|
183
|
+
// doc comment (GitHub issue #6) for why decoding happens entirely in
|
|
184
|
+
// Lua, and `MarshalLimits`' doc comment above for why this reuses the
|
|
185
|
+
// same budget the return-value marshal walk already enforces.
|
|
186
|
+
const budgetCheck = checkJsonWithinLimits(parsed, marshalLimits);
|
|
187
|
+
if (!budgetCheck.ok) {
|
|
188
|
+
const message = `fetch response for "${url}" ${budgetCheck.message}`;
|
|
189
|
+
recordDenial('denied', message);
|
|
190
|
+
throw capabilityError(message);
|
|
191
|
+
}
|
|
192
|
+
// Hand back the RAW JSON TEXT, not the parsed JS value: any object or
|
|
193
|
+
// array crossing this JS->Lua boundary as-is would arrive in Lua as a
|
|
194
|
+
// wasmoon `js_proxy` userdata, not a genuine table (see
|
|
195
|
+
// `./json-decode`'s doc comment for the full mechanism and why that
|
|
196
|
+
// breaks `type()`/`#`/marshaling a nested return value, and silently
|
|
197
|
+
// raises on a `null` field read). Strings, unlike objects/arrays, are
|
|
198
|
+
// scalars and cross the boundary cleanly with no proxy involved; the
|
|
199
|
+
// prelude below decodes this text into a genuine Lua table entirely
|
|
200
|
+
// in Lua (`__smd_json_decode`, `./json-decode`).
|
|
201
|
+
return res.body;
|
|
149
202
|
});
|
|
203
|
+
ensureJsonDecodePrelude();
|
|
150
204
|
preludeParts.push(`
|
|
151
205
|
local __smd_net_get = __smd_net_get_raw
|
|
206
|
+
-- Captured into a local HERE, at prelude-definition time (this whole
|
|
207
|
+
-- prelude runs once, before any untrusted script code) -- NOT resolved as
|
|
208
|
+
-- a dynamic global lookup inside \`net.fetch_json\`'s own body. Otherwise a
|
|
209
|
+
-- script could do \`__smd_json_decode = function(t) return t end\` before a
|
|
210
|
+
-- LATER \`net.fetch_json\` call and neuter the decoder's own depth guard and
|
|
211
|
+
-- array-marker stripping entirely (adversarial finding A2) -- the same
|
|
212
|
+
-- rebinding risk \`./json-decode\`'s own doc comment (finding A1) already
|
|
213
|
+
-- closes for the primitives the decoder uses internally.
|
|
214
|
+
local __smd_net_get_json_decode = __smd_json_decode
|
|
152
215
|
__smd_net_get_raw = nil
|
|
153
|
-
net.fetch_json = function(url) return __smd_net_get(url):await() end
|
|
216
|
+
net.fetch_json = function(url) return __smd_net_get_json_decode(__smd_net_get(url):await()) end
|
|
154
217
|
`);
|
|
155
218
|
}
|
|
156
219
|
// POST/PATCH are effectful. Under the 'manual' tier, wired to the real
|
|
@@ -172,12 +235,25 @@ net.fetch_json = function(url) return __smd_net_get(url):await() end
|
|
|
172
235
|
recordDenial('denied', message);
|
|
173
236
|
throw capabilityError(message);
|
|
174
237
|
}
|
|
175
|
-
|
|
238
|
+
const res = await config.net.post(url, body);
|
|
239
|
+
// As with `net.fetch_json` above (GitHub issue #6): a plain JS object
|
|
240
|
+
// (even one this shallow) crosses into Lua as a `js_proxy` userdata,
|
|
241
|
+
// not a genuine table. `status`/`body` are both scalars, so instead
|
|
242
|
+
// of proxying the whole response object, resolve with a
|
|
243
|
+
// `LuaMultiReturn` — `:await()` recognizes that and expands it into
|
|
244
|
+
// TWO separate Lua return values (see `wasmoon`'s promise
|
|
245
|
+
// `await`/`MultiReturn` handling) — and let the trusted prelude below
|
|
246
|
+
// rebuild a real `{status=..., body=...}` table out of ordinary Lua
|
|
247
|
+
// table-constructor syntax.
|
|
248
|
+
return LuaMultiReturn.of(res.status, res.body);
|
|
176
249
|
});
|
|
177
250
|
preludeParts.push(`
|
|
178
251
|
local __smd_net_post = __smd_net_post_raw
|
|
179
252
|
__smd_net_post_raw = nil
|
|
180
|
-
net.post = function(url, body)
|
|
253
|
+
net.post = function(url, body)
|
|
254
|
+
local status, respBody = __smd_net_post(url, body):await()
|
|
255
|
+
return { status = status, body = respBody }
|
|
256
|
+
end
|
|
181
257
|
`);
|
|
182
258
|
}
|
|
183
259
|
else if (
|
|
@@ -213,12 +289,18 @@ net.post = function(url, body) return __smd_net_post_blocked(url, body):await()
|
|
|
213
289
|
recordDenial('denied', message);
|
|
214
290
|
throw capabilityError(message);
|
|
215
291
|
}
|
|
216
|
-
|
|
292
|
+
const res = await config.net.patch(url, body);
|
|
293
|
+
// Same fix as `net.post` above (GitHub issue #6) — see that block's
|
|
294
|
+
// comment for the full mechanism.
|
|
295
|
+
return LuaMultiReturn.of(res.status, res.body);
|
|
217
296
|
});
|
|
218
297
|
preludeParts.push(`
|
|
219
298
|
local __smd_net_patch = __smd_net_patch_raw
|
|
220
299
|
__smd_net_patch_raw = nil
|
|
221
|
-
net.patch = function(url, body)
|
|
300
|
+
net.patch = function(url, body)
|
|
301
|
+
local status, respBody = __smd_net_patch(url, body):await()
|
|
302
|
+
return { status = status, body = respBody }
|
|
303
|
+
end
|
|
222
304
|
`);
|
|
223
305
|
}
|
|
224
306
|
else if (
|
|
@@ -247,10 +329,103 @@ net.patch = function(url, body) return __smd_net_patch_blocked(url, body):await(
|
|
|
247
329
|
// ever exposes plain read/write primitives (`__smd_cache_get_raw`,
|
|
248
330
|
// `__smd_cache_set_raw`); the read-if-fresh-else-run-fn CONTROL FLOW is
|
|
249
331
|
// Lua calling Lua, never JS calling Lua.
|
|
332
|
+
//
|
|
333
|
+
// ## The same issue #6 proxy problem, on a cache HIT
|
|
334
|
+
//
|
|
335
|
+
// A stored value that came from `net.fetch_json` (the canonical idiom
|
|
336
|
+
// documented in `docs/scripting.md`: `cache.get(key, ttl, function()
|
|
337
|
+
// return net.fetch_json(url) end)`) is exactly the JSON-shaped data
|
|
338
|
+
// `./json-decode`'s doc comment already covers. Handing a cache HIT's
|
|
339
|
+
// stored value back to Lua as-is has the identical fix requirement as
|
|
340
|
+
// `net.fetch_json`'s own result: it must not cross the boundary as a raw
|
|
341
|
+
// JS object (a `js_proxy` userdata), or `type()`/`#`/marshaling a nested
|
|
342
|
+
// hit result breaks exactly like an un-fixed `fetch_json` would.
|
|
343
|
+
//
|
|
344
|
+
// The fix mirrors `net.fetch_json` exactly and reuses BOTH of its pieces:
|
|
345
|
+
// - On `cache.get`'s READ side, the raw JS function JSON-encodes the
|
|
346
|
+
// stored value (`JSON.stringify`, on a value that's already
|
|
347
|
+
// plain/JSON-safe — see the WRITE side below) and hands back that
|
|
348
|
+
// TEXT, a scalar with no proxy involved, alongside the plain-number
|
|
349
|
+
// `storedAtMs`, via one `LuaMultiReturn` (same technique
|
|
350
|
+
// `net.post`/`net.patch` use for their two-field response). Lua then
|
|
351
|
+
// decodes it with the SAME `__smd_json_decode` fetch_json already
|
|
352
|
+
// uses (`ensureJsonDecodePrelude`).
|
|
353
|
+
// - On the WRITE side (an internal refresh, never a public
|
|
354
|
+
// `cache.set` — see `docs/scripting.md`: `cache.get` is the only
|
|
355
|
+
// public cache API), the value `fn()` computed is run through the
|
|
356
|
+
// SAME capped, cycle-safe Lua walk (`__smd_marshal_root`,
|
|
357
|
+
// `./marshal`'s `buildMarshalPrelude`) already used to bound a
|
|
358
|
+
// script's own top-level return value, BEFORE it ever crosses to JS
|
|
359
|
+
// as a function argument. This closes a real, separate gap: passing
|
|
360
|
+
// an uncapped Lua table as a host-function argument uses wasmoon's
|
|
361
|
+
// own eager, unbounded table->JS conversion (see `./marshal`'s doc
|
|
362
|
+
// comment on why the return-value path never trusts that
|
|
363
|
+
// conversion) — without this walk, `cache.get(key, ttl, function()
|
|
364
|
+
// return hugeOrCyclicTable end)` would hit that same unbounded cost,
|
|
365
|
+
// and a cyclic value would later crash the JS-side `JSON.stringify`
|
|
366
|
+
// outright. `finalizeMarshaledValue` (the same JS-side pass the
|
|
367
|
+
// top-level return path already runs) then strips the walk's array
|
|
368
|
+
// marker and rejects a non-finite number, before the plain,
|
|
369
|
+
// JSON-safe result is handed to `config.cache!.set` — so
|
|
370
|
+
// `CacheEntry.value`'s STORAGE shape is unchanged (still whatever
|
|
371
|
+
// plain value the host's `CacheProvider` already expects; e.g. a
|
|
372
|
+
// bundle's `cache/*.json` file), and a script's own scalar values
|
|
373
|
+
// (numbers, strings, booleans) round-trip exactly as before.
|
|
374
|
+
// - Either enforcement failing raises the existing, already-classified
|
|
375
|
+
// `MARSHAL_ERROR_TAG` error (`sandbox.ts` already recognizes it as
|
|
376
|
+
// `kind: 'marshal'`) — no new error taxonomy for this path.
|
|
250
377
|
if (config.cache) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
378
|
+
ensureJsonDecodePrelude();
|
|
379
|
+
ensureMarshalPrelude();
|
|
380
|
+
rawGlobals.__smd_cache_get_raw = (async (key) => {
|
|
381
|
+
const entry = await config.cache.get(key);
|
|
382
|
+
if (entry === undefined)
|
|
383
|
+
return undefined;
|
|
384
|
+
// Depth/node budget, checked BEFORE the stored value is ever encoded
|
|
385
|
+
// to text and handed to Lua — mirrors `net.fetch_json`'s own
|
|
386
|
+
// pre-check exactly (adversarial finding B2). A host-stored value is
|
|
387
|
+
// exactly as untrusted as a remote fetch body — a bundle's
|
|
388
|
+
// `cache/*.json` file, for instance, can be edited by anything with
|
|
389
|
+
// write access to the bundle, not just this sandbox's own WRITE side
|
|
390
|
+
// below — so without this check, a 300k-element cached array reached
|
|
391
|
+
// the script completely uncapped even though the FETCH path was
|
|
392
|
+
// already capped.
|
|
393
|
+
const budgetCheck = checkJsonWithinLimits(entry.value, marshalLimits);
|
|
394
|
+
if (!budgetCheck.ok) {
|
|
395
|
+
const message = `cached value for "${key}" ${budgetCheck.message}`;
|
|
396
|
+
recordDenial('denied', message);
|
|
397
|
+
throw capabilityError(message);
|
|
398
|
+
}
|
|
399
|
+
let text;
|
|
400
|
+
try {
|
|
401
|
+
// A cyclic or BigInt-bearing stored value throws here. This
|
|
402
|
+
// module's own WRITE side (`__smd_cache_set_raw` below) already
|
|
403
|
+
// rejects both before ever storing anything, but a `CacheProvider`
|
|
404
|
+
// is host-controlled storage that something other than this
|
|
405
|
+
// sandbox could have written — turn that into the same clean,
|
|
406
|
+
// catchable denial instead of an unclassified throw out of the
|
|
407
|
+
// capability layer.
|
|
408
|
+
const encoded = JSON.stringify(entry.value);
|
|
409
|
+
text = encoded === undefined ? 'null' : encoded;
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
const message = `cached value for "${key}" could not be encoded: ${describeThrown(err)}`;
|
|
413
|
+
recordDenial('denied', message);
|
|
414
|
+
throw capabilityError(message);
|
|
415
|
+
}
|
|
416
|
+
return LuaMultiReturn.of(text, entry.storedAtMs);
|
|
417
|
+
});
|
|
418
|
+
rawGlobals.__smd_cache_set_raw = (async (key,
|
|
419
|
+
// Already the output of `__smd_marshal_root` (`./marshal`'s
|
|
420
|
+
// `buildMarshalPrelude`) by the time it reaches here — see the
|
|
421
|
+
// prelude below — so this is a bounded, cycle-free, marker-tagged
|
|
422
|
+
// plain value (or a scalar), never a raw uncapped Lua table.
|
|
423
|
+
marshaledValue, storedAtMs) => {
|
|
424
|
+
const finalized = finalizeMarshaledValue(marshaledValue);
|
|
425
|
+
if (!finalized.ok) {
|
|
426
|
+
throw new Error(`${MARSHAL_ERROR_TAG}:${finalized.reason}`);
|
|
427
|
+
}
|
|
428
|
+
await config.cache.set(key, { value: finalized.value, storedAtMs });
|
|
254
429
|
return true;
|
|
255
430
|
});
|
|
256
431
|
// `now` (for TTL freshness) is computed in JS, once per cache.get
|
|
@@ -263,20 +438,36 @@ net.patch = function(url, body) return __smd_net_patch_blocked(url, body):await(
|
|
|
263
438
|
local __smd_cache_get = __smd_cache_get_raw
|
|
264
439
|
local __smd_cache_set = __smd_cache_set_raw
|
|
265
440
|
local __smd_now_ms = __smd_now_ms_raw
|
|
441
|
+
-- Captured into locals HERE, at prelude-definition time -- NOT resolved as
|
|
442
|
+
-- dynamic globals inside cache.get's own body (adversarial findings A2 and
|
|
443
|
+
-- D1). Without this, a script could do \`__smd_json_decode = function(t)
|
|
444
|
+
-- return t end\` (A2) or, more seriously, \`__smd_marshal_root = function(v)
|
|
445
|
+
-- return v end\` (D1) before a LATER cache.get call: the marshal-root
|
|
446
|
+
-- rebind would send an UNBOUNDED, uncapped table straight through
|
|
447
|
+
-- wasmoon's own eager table->JS conversion into the store the moment the
|
|
448
|
+
-- rebound "identity" function handed it back, since the write side
|
|
449
|
+
-- (\`__smd_cache_set_raw\` below) would then be receiving whatever the
|
|
450
|
+
-- script substituted with no cap ever having run -- the return-value path
|
|
451
|
+
-- was never vulnerable to this trick only because of an unrelated
|
|
452
|
+
-- evaluation-order accident (its own \`__smd_marshal_root\` call happens
|
|
453
|
+
-- inside \`wrapUserCode\`'s generated code, evaluated before the script's
|
|
454
|
+
-- own top-level statements finish), not because the global was pinned.
|
|
455
|
+
local __smd_cache_json_decode = __smd_json_decode
|
|
456
|
+
local __smd_cache_marshal_root = __smd_marshal_root
|
|
266
457
|
__smd_cache_get_raw = nil
|
|
267
458
|
__smd_cache_set_raw = nil
|
|
268
459
|
__smd_now_ms_raw = nil
|
|
269
460
|
cache = cache or {}
|
|
270
461
|
cache.get = function(key, ttl, fn)
|
|
271
|
-
local
|
|
272
|
-
if
|
|
462
|
+
local text, storedAtMs = __smd_cache_get(key):await()
|
|
463
|
+
if text ~= nil then
|
|
273
464
|
local now = __smd_now_ms():await()
|
|
274
|
-
if (now -
|
|
275
|
-
return
|
|
465
|
+
if (now - storedAtMs) < (ttl * 1000) then
|
|
466
|
+
return __smd_cache_json_decode(text)
|
|
276
467
|
end
|
|
277
468
|
end
|
|
278
469
|
local value = fn()
|
|
279
|
-
__smd_cache_set(key, value, __smd_now_ms():await()):await()
|
|
470
|
+
__smd_cache_set(key, __smd_cache_marshal_root(value), __smd_now_ms():await()):await()
|
|
280
471
|
return value
|
|
281
472
|
end
|
|
282
473
|
`);
|
package/dist/errors.d.ts
CHANGED
|
@@ -49,6 +49,22 @@
|
|
|
49
49
|
export declare const CAPABILITY_ERROR_TAG = "MARK_CAPABILITY";
|
|
50
50
|
/** Prefix tag for a marshal-time rejection raised from the in-Lua marshal walk (see `./marshal`). */
|
|
51
51
|
export declare const MARSHAL_ERROR_TAG = "MARK_MARSHAL";
|
|
52
|
+
/**
|
|
53
|
+
* Prefix tag for a rejection raised from the in-Lua JSON decoder
|
|
54
|
+
* (`./json-decode`'s `__smd_json_decode`, used by `net.fetch_json` — see
|
|
55
|
+
* `./capabilities`). Under normal operation this never fires: the
|
|
56
|
+
* depth/node budget is enforced BEFORE the fetched body ever reaches Lua
|
|
57
|
+
* (`./capabilities`' `checkJsonWithinLimits` call, using the same
|
|
58
|
+
* `MarshalLimits` as `./marshal`, recorded as an ordinary capability denial
|
|
59
|
+
* — see `CAPABILITY_ERROR_TAG` above). The Lua-side `maxDepth` check this
|
|
60
|
+
* tag backs is a pure recursion-depth (C-stack) safety net for the case
|
|
61
|
+
* where that pre-check and the decoder's own walk of the exact same text
|
|
62
|
+
* would ever disagree — not a second, independently-tuned limit. Like
|
|
63
|
+
* `MARSHAL_ERROR_TAG`, this is NOT a classification signal `sandbox.ts`
|
|
64
|
+
* inspects: a script forging this text produces an ordinary `'runtime'`
|
|
65
|
+
* failure, same as any other `error()` call.
|
|
66
|
+
*/
|
|
67
|
+
export declare const FETCH_DECODE_ERROR_TAG = "MARK_FETCH_DECODE";
|
|
52
68
|
/** The limits a run can breach; see `./limits`. */
|
|
53
69
|
export type ScriptLimitKind = 'instructions' | 'timeout' | 'memory';
|
|
54
70
|
/** Why a return value was rejected by the marshaller; see `./marshal`. */
|
package/dist/errors.js
CHANGED
|
@@ -49,6 +49,22 @@
|
|
|
49
49
|
export const CAPABILITY_ERROR_TAG = 'MARK_CAPABILITY';
|
|
50
50
|
/** Prefix tag for a marshal-time rejection raised from the in-Lua marshal walk (see `./marshal`). */
|
|
51
51
|
export const MARSHAL_ERROR_TAG = 'MARK_MARSHAL';
|
|
52
|
+
/**
|
|
53
|
+
* Prefix tag for a rejection raised from the in-Lua JSON decoder
|
|
54
|
+
* (`./json-decode`'s `__smd_json_decode`, used by `net.fetch_json` — see
|
|
55
|
+
* `./capabilities`). Under normal operation this never fires: the
|
|
56
|
+
* depth/node budget is enforced BEFORE the fetched body ever reaches Lua
|
|
57
|
+
* (`./capabilities`' `checkJsonWithinLimits` call, using the same
|
|
58
|
+
* `MarshalLimits` as `./marshal`, recorded as an ordinary capability denial
|
|
59
|
+
* — see `CAPABILITY_ERROR_TAG` above). The Lua-side `maxDepth` check this
|
|
60
|
+
* tag backs is a pure recursion-depth (C-stack) safety net for the case
|
|
61
|
+
* where that pre-check and the decoder's own walk of the exact same text
|
|
62
|
+
* would ever disagree — not a second, independently-tuned limit. Like
|
|
63
|
+
* `MARSHAL_ERROR_TAG`, this is NOT a classification signal `sandbox.ts`
|
|
64
|
+
* inspects: a script forging this text produces an ordinary `'runtime'`
|
|
65
|
+
* failure, same as any other `error()` call.
|
|
66
|
+
*/
|
|
67
|
+
export const FETCH_DECODE_ERROR_TAG = 'MARK_FETCH_DECODE';
|
|
52
68
|
/**
|
|
53
69
|
* Thrown by the instruction-count/wall-clock hook installed in `./limits`
|
|
54
70
|
* when this package needs to surface a limit breach as a JS-level
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { ScriptFailure, ScriptLimitKind, ScriptMarshalReason, } from './errors.js';
|
|
2
|
-
export { CAPABILITY_ERROR_TAG, MARSHAL_ERROR_TAG, ScriptLimitError, } from './errors.js';
|
|
2
|
+
export { CAPABILITY_ERROR_TAG, FETCH_DECODE_ERROR_TAG, MARSHAL_ERROR_TAG, ScriptLimitError, } from './errors.js';
|
|
3
3
|
export type { CreateEmptyLuaEngineOptions } from './globals.js';
|
|
4
4
|
export { ALLOWED_GLOBALS, DENIED_GLOBALS, createEmptyLuaEngine, } from './globals.js';
|
|
5
5
|
export type { LimitHandle, ScriptLimits } from './limits.js';
|
|
@@ -7,7 +7,7 @@ export { DEFAULT_LIMITS, installLimits } from './limits.js';
|
|
|
7
7
|
export type { CacheEntry, CacheProvider, CapabilityConfig, CapabilityDenial, CapabilityDenials, CapabilityTier, NetGrants, NetProvider, NetResponse, } from './capabilities.js';
|
|
8
8
|
export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, luaStringToBytes, } from './capabilities.js';
|
|
9
9
|
export type { MarshalLimits } from './marshal.js';
|
|
10
|
-
export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
|
|
10
|
+
export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, checkJsonWithinLimits, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
|
|
11
11
|
export { NOT_YET_SUPPORTED_MESSAGE, buildRequireStub } from './require.js';
|
|
12
12
|
export type { RunScriptOptions, RunScriptResult } from './sandbox.js';
|
|
13
13
|
export { runScript } from './sandbox.js';
|
package/dist/index.js
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// filesystem). No React, no @markii/core, no @markii/react — see AGENTS.md's
|
|
4
4
|
// import rule and the ESLint guard in the root config. May depend on
|
|
5
5
|
// @markii/bundle for the `ScriptView` capability type only.
|
|
6
|
-
export { CAPABILITY_ERROR_TAG, MARSHAL_ERROR_TAG, ScriptLimitError, } from './errors.js';
|
|
6
|
+
export { CAPABILITY_ERROR_TAG, FETCH_DECODE_ERROR_TAG, MARSHAL_ERROR_TAG, ScriptLimitError, } from './errors.js';
|
|
7
7
|
export { ALLOWED_GLOBALS, DENIED_GLOBALS, createEmptyLuaEngine, } from './globals.js';
|
|
8
8
|
export { DEFAULT_LIMITS, installLimits } from './limits.js';
|
|
9
9
|
export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, luaStringToBytes, } from './capabilities.js';
|
|
10
|
-
export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
|
|
10
|
+
export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, checkJsonWithinLimits, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
|
|
11
11
|
export { NOT_YET_SUPPORTED_MESSAGE, buildRequireStub } from './require.js';
|
|
12
12
|
export { runScript } from './sandbox.js';
|
|
13
13
|
export { createLuaExecutor } from './executor.js';
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type MarshalLimits } from './marshal.js';
|
|
2
|
+
/**
|
|
3
|
+
* Builds the trusted Lua prelude defining `__smd_json_decode(text)`, the
|
|
4
|
+
* fix for GitHub issue #6: `net.fetch_json` used to hand the script
|
|
5
|
+
* wasmoon's own JS→Lua conversion of the parsed JSON object, which for any
|
|
6
|
+
* non-scalar JS value is a live PROXY (userdata wrapping the JS object via
|
|
7
|
+
* wasmoon's `js_proxy` metatable), never a genuine Lua table. That is the
|
|
8
|
+
* single root cause of all three traps the issue describes:
|
|
9
|
+
*
|
|
10
|
+
* 1. Returning any nested piece of the result fails marshaling with
|
|
11
|
+
* `MARK_MARSHAL:type:userdata` — `./marshal`'s in-Lua walk sees
|
|
12
|
+
* `type(value) == "userdata"` for the proxy and rejects it outright
|
|
13
|
+
* (functions/userdata/threads are never marshalable, by design).
|
|
14
|
+
* 2. `type()` on the proxy genuinely returns `"userdata"` (it IS a
|
|
15
|
+
* wasmoon `js_proxy` userdata, metatable-dressed to answer to
|
|
16
|
+
* indexing like a table) and `#`/`pairs()` only work through that
|
|
17
|
+
* metatable's `__len`/`__pairs`, which use 0-based JS semantics
|
|
18
|
+
* inconsistent with the proxy's own 1-based `__index` — forcing a
|
|
19
|
+
* script to guard every operation on the result with `pcall`.
|
|
20
|
+
* 3. Reading a field whose JSON value is `null` raises instead of
|
|
21
|
+
* returning `nil`: the proxy's `__index` returns the raw JS `null`,
|
|
22
|
+
* and wasmoon has no type extension that can push a bare JS `null`
|
|
23
|
+
* onto the Lua stack (verified against `wasmoon`'s `NullTypeExtension`,
|
|
24
|
+
* which is only registered when the engine option `injectObjects` is
|
|
25
|
+
* on — it never is in this sandbox, see `./globals`), so the push
|
|
26
|
+
* throws.
|
|
27
|
+
*
|
|
28
|
+
* Rather than fighting wasmoon's proxy machinery from the JS side (there is
|
|
29
|
+
* no supported way to make a plain JS object push as a genuine, non-proxied
|
|
30
|
+
* Lua table — only Lua's own table-constructor syntax produces one), this
|
|
31
|
+
* decodes the fetched body ENTIRELY IN LUA, the same philosophy `./marshal`
|
|
32
|
+
* already uses for the opposite direction (see that module's doc comment on
|
|
33
|
+
* why wasmoon's automatic conversions aren't trusted for anything
|
|
34
|
+
* resource-shaped): `./capabilities`' `net.fetch_json` hands this function
|
|
35
|
+
* the raw JSON text (a plain Lua string — strings are scalars, so they
|
|
36
|
+
* cross the JS↔Lua boundary cleanly with no proxy involved) and gets back a
|
|
37
|
+
* genuine Lua table built entirely out of ordinary `{}`/`t[k]=v` operations,
|
|
38
|
+
* indistinguishable from a table the script constructed itself: `type()`
|
|
39
|
+
* says `"table"`, `#`/`ipairs`/`pairs` behave normally, and a JSON `null`
|
|
40
|
+
* (as an OBJECT value) simply never gets assigned a key — reading it back
|
|
41
|
+
* is an ordinary, un-raising Lua `nil`. `cache.get`'s hit path (`./capabilities`)
|
|
42
|
+
* reuses this exact function for the identical reason: a cache hit re-enters
|
|
43
|
+
* Lua the same way a fetch result does.
|
|
44
|
+
*
|
|
45
|
+
* ## Depth/size caps: enforced BEFORE this ever runs, not by this function
|
|
46
|
+
*
|
|
47
|
+
* `./capabilities`' `net.fetch_json` and `cache.get` both call `./marshal`'s
|
|
48
|
+
* `checkJsonWithinLimits` on the already-`JSON.parse`d body/stored value and
|
|
49
|
+
* reject an oversized/too-deep one as an ordinary capability denial
|
|
50
|
+
* (recorded on the same `CapabilityDenials` handle the byte-size cap already
|
|
51
|
+
* uses) BEFORE the raw text is ever handed to this decoder. This function's
|
|
52
|
+
* own `maxDepth` check exists only as a recursion-depth (Lua C-stack) safety
|
|
53
|
+
* net for the case where that pre-check and this decoder's walk of the
|
|
54
|
+
* EXACT SAME text would ever disagree — see `FETCH_DECODE_ERROR_TAG`'s doc
|
|
55
|
+
* comment. There is no matching node-count check here: an instruction
|
|
56
|
+
* count over the sandbox's existing hook (`./limits`) already bounds the
|
|
57
|
+
* cost of a pathological decode the same way it bounds any other
|
|
58
|
+
* expensive Lua loop, so a second, independently-tuned node counter would
|
|
59
|
+
* only duplicate that enforcement.
|
|
60
|
+
*
|
|
61
|
+
* ## Why re-parse instead of reusing `JSON.parse`'s result
|
|
62
|
+
*
|
|
63
|
+
* `./capabilities` already calls `JSON.parse` once, to produce a clear
|
|
64
|
+
* "not valid JSON" error message and to run the depth/size pre-check
|
|
65
|
+
* above. Re-parsing the SAME (already-validated) text here in Lua is
|
|
66
|
+
* intentionally redundant: it is the only way to build genuine Lua tables
|
|
67
|
+
* at all (see above), and the cost is bounded by the same `maxFetchBytes`
|
|
68
|
+
* cap that already bounds the JS-side parse.
|
|
69
|
+
*
|
|
70
|
+
* ## Every Lua primitive this decoder uses is pinned at PRELUDE-DEFINITION
|
|
71
|
+
* ## time, not resolved as a dynamic global at call time (adversarial
|
|
72
|
+
* ## finding A1)
|
|
73
|
+
*
|
|
74
|
+
* `string.byte`/`sub`/`find`/`char`, `table.concat`, `tonumber`,
|
|
75
|
+
* `math.floor`, and `error` are all captured into locals ONCE, at the top
|
|
76
|
+
* of this chunk — which runs immediately when this prelude is injected,
|
|
77
|
+
* before any untrusted script code ever runs — and every nested function
|
|
78
|
+
* below closes over those locals as upvalues. Without this (the ORIGINAL
|
|
79
|
+
* shape of this file resolved them as plain global lookups INSIDE
|
|
80
|
+
* `__smd_json_decode`, i.e. at every CALL, not just once), a script could
|
|
81
|
+
* do `error = function() end` (or `string = {}`) before a LATER
|
|
82
|
+
* `net.fetch_json`/`cache.get` call and silently disable this decoder's own
|
|
83
|
+
* safety checks (verified empirically: rebinding `error` this way disables
|
|
84
|
+
* the depth guard entirely, since `fail()` would then no longer actually
|
|
85
|
+
* raise). This mirrors `./marshal`'s own `buildMarshalPrelude`, which
|
|
86
|
+
* already does exactly this for the same reason (see that module's doc
|
|
87
|
+
* comment referencing sandbox-audit finding F-1).
|
|
88
|
+
*/
|
|
89
|
+
export declare function buildJsonDecodePrelude(limits: MarshalLimits): string;
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { FETCH_DECODE_ERROR_TAG } from './errors.js';
|
|
2
|
+
import { ARRAY_MARKER } from './marshal.js';
|
|
3
|
+
/**
|
|
4
|
+
* Builds the trusted Lua prelude defining `__smd_json_decode(text)`, the
|
|
5
|
+
* fix for GitHub issue #6: `net.fetch_json` used to hand the script
|
|
6
|
+
* wasmoon's own JS→Lua conversion of the parsed JSON object, which for any
|
|
7
|
+
* non-scalar JS value is a live PROXY (userdata wrapping the JS object via
|
|
8
|
+
* wasmoon's `js_proxy` metatable), never a genuine Lua table. That is the
|
|
9
|
+
* single root cause of all three traps the issue describes:
|
|
10
|
+
*
|
|
11
|
+
* 1. Returning any nested piece of the result fails marshaling with
|
|
12
|
+
* `MARK_MARSHAL:type:userdata` — `./marshal`'s in-Lua walk sees
|
|
13
|
+
* `type(value) == "userdata"` for the proxy and rejects it outright
|
|
14
|
+
* (functions/userdata/threads are never marshalable, by design).
|
|
15
|
+
* 2. `type()` on the proxy genuinely returns `"userdata"` (it IS a
|
|
16
|
+
* wasmoon `js_proxy` userdata, metatable-dressed to answer to
|
|
17
|
+
* indexing like a table) and `#`/`pairs()` only work through that
|
|
18
|
+
* metatable's `__len`/`__pairs`, which use 0-based JS semantics
|
|
19
|
+
* inconsistent with the proxy's own 1-based `__index` — forcing a
|
|
20
|
+
* script to guard every operation on the result with `pcall`.
|
|
21
|
+
* 3. Reading a field whose JSON value is `null` raises instead of
|
|
22
|
+
* returning `nil`: the proxy's `__index` returns the raw JS `null`,
|
|
23
|
+
* and wasmoon has no type extension that can push a bare JS `null`
|
|
24
|
+
* onto the Lua stack (verified against `wasmoon`'s `NullTypeExtension`,
|
|
25
|
+
* which is only registered when the engine option `injectObjects` is
|
|
26
|
+
* on — it never is in this sandbox, see `./globals`), so the push
|
|
27
|
+
* throws.
|
|
28
|
+
*
|
|
29
|
+
* Rather than fighting wasmoon's proxy machinery from the JS side (there is
|
|
30
|
+
* no supported way to make a plain JS object push as a genuine, non-proxied
|
|
31
|
+
* Lua table — only Lua's own table-constructor syntax produces one), this
|
|
32
|
+
* decodes the fetched body ENTIRELY IN LUA, the same philosophy `./marshal`
|
|
33
|
+
* already uses for the opposite direction (see that module's doc comment on
|
|
34
|
+
* why wasmoon's automatic conversions aren't trusted for anything
|
|
35
|
+
* resource-shaped): `./capabilities`' `net.fetch_json` hands this function
|
|
36
|
+
* the raw JSON text (a plain Lua string — strings are scalars, so they
|
|
37
|
+
* cross the JS↔Lua boundary cleanly with no proxy involved) and gets back a
|
|
38
|
+
* genuine Lua table built entirely out of ordinary `{}`/`t[k]=v` operations,
|
|
39
|
+
* indistinguishable from a table the script constructed itself: `type()`
|
|
40
|
+
* says `"table"`, `#`/`ipairs`/`pairs` behave normally, and a JSON `null`
|
|
41
|
+
* (as an OBJECT value) simply never gets assigned a key — reading it back
|
|
42
|
+
* is an ordinary, un-raising Lua `nil`. `cache.get`'s hit path (`./capabilities`)
|
|
43
|
+
* reuses this exact function for the identical reason: a cache hit re-enters
|
|
44
|
+
* Lua the same way a fetch result does.
|
|
45
|
+
*
|
|
46
|
+
* ## Depth/size caps: enforced BEFORE this ever runs, not by this function
|
|
47
|
+
*
|
|
48
|
+
* `./capabilities`' `net.fetch_json` and `cache.get` both call `./marshal`'s
|
|
49
|
+
* `checkJsonWithinLimits` on the already-`JSON.parse`d body/stored value and
|
|
50
|
+
* reject an oversized/too-deep one as an ordinary capability denial
|
|
51
|
+
* (recorded on the same `CapabilityDenials` handle the byte-size cap already
|
|
52
|
+
* uses) BEFORE the raw text is ever handed to this decoder. This function's
|
|
53
|
+
* own `maxDepth` check exists only as a recursion-depth (Lua C-stack) safety
|
|
54
|
+
* net for the case where that pre-check and this decoder's walk of the
|
|
55
|
+
* EXACT SAME text would ever disagree — see `FETCH_DECODE_ERROR_TAG`'s doc
|
|
56
|
+
* comment. There is no matching node-count check here: an instruction
|
|
57
|
+
* count over the sandbox's existing hook (`./limits`) already bounds the
|
|
58
|
+
* cost of a pathological decode the same way it bounds any other
|
|
59
|
+
* expensive Lua loop, so a second, independently-tuned node counter would
|
|
60
|
+
* only duplicate that enforcement.
|
|
61
|
+
*
|
|
62
|
+
* ## Why re-parse instead of reusing `JSON.parse`'s result
|
|
63
|
+
*
|
|
64
|
+
* `./capabilities` already calls `JSON.parse` once, to produce a clear
|
|
65
|
+
* "not valid JSON" error message and to run the depth/size pre-check
|
|
66
|
+
* above. Re-parsing the SAME (already-validated) text here in Lua is
|
|
67
|
+
* intentionally redundant: it is the only way to build genuine Lua tables
|
|
68
|
+
* at all (see above), and the cost is bounded by the same `maxFetchBytes`
|
|
69
|
+
* cap that already bounds the JS-side parse.
|
|
70
|
+
*
|
|
71
|
+
* ## Every Lua primitive this decoder uses is pinned at PRELUDE-DEFINITION
|
|
72
|
+
* ## time, not resolved as a dynamic global at call time (adversarial
|
|
73
|
+
* ## finding A1)
|
|
74
|
+
*
|
|
75
|
+
* `string.byte`/`sub`/`find`/`char`, `table.concat`, `tonumber`,
|
|
76
|
+
* `math.floor`, and `error` are all captured into locals ONCE, at the top
|
|
77
|
+
* of this chunk — which runs immediately when this prelude is injected,
|
|
78
|
+
* before any untrusted script code ever runs — and every nested function
|
|
79
|
+
* below closes over those locals as upvalues. Without this (the ORIGINAL
|
|
80
|
+
* shape of this file resolved them as plain global lookups INSIDE
|
|
81
|
+
* `__smd_json_decode`, i.e. at every CALL, not just once), a script could
|
|
82
|
+
* do `error = function() end` (or `string = {}`) before a LATER
|
|
83
|
+
* `net.fetch_json`/`cache.get` call and silently disable this decoder's own
|
|
84
|
+
* safety checks (verified empirically: rebinding `error` this way disables
|
|
85
|
+
* the depth guard entirely, since `fail()` would then no longer actually
|
|
86
|
+
* raise). This mirrors `./marshal`'s own `buildMarshalPrelude`, which
|
|
87
|
+
* already does exactly this for the same reason (see that module's doc
|
|
88
|
+
* comment referencing sandbox-audit finding F-1).
|
|
89
|
+
*/
|
|
90
|
+
export function buildJsonDecodePrelude(limits) {
|
|
91
|
+
return `
|
|
92
|
+
local __smd_jd_error, __smd_jd_tonumber, __smd_jd_floor =
|
|
93
|
+
error, tonumber, math.floor
|
|
94
|
+
local __smd_jd_sbyte, __smd_jd_ssub, __smd_jd_sfind, __smd_jd_schar =
|
|
95
|
+
string.byte, string.sub, string.find, string.char
|
|
96
|
+
local __smd_jd_tconcat = table.concat
|
|
97
|
+
|
|
98
|
+
function __smd_json_decode(__smd_json_text)
|
|
99
|
+
local error, tonumber, floor = __smd_jd_error, __smd_jd_tonumber, __smd_jd_floor
|
|
100
|
+
local sbyte, ssub, sfind, schar = __smd_jd_sbyte, __smd_jd_ssub, __smd_jd_sfind, __smd_jd_schar
|
|
101
|
+
local tconcat = __smd_jd_tconcat
|
|
102
|
+
|
|
103
|
+
local text = __smd_json_text
|
|
104
|
+
local len = #text
|
|
105
|
+
local pos = 1
|
|
106
|
+
local maxDepth = ${limits.maxDepth}
|
|
107
|
+
|
|
108
|
+
local function fail(reason)
|
|
109
|
+
error("${FETCH_DECODE_ERROR_TAG}:" .. reason)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
local function skip_ws()
|
|
113
|
+
while pos <= len do
|
|
114
|
+
local c = sbyte(text, pos)
|
|
115
|
+
if c == 32 or c == 9 or c == 10 or c == 13 then
|
|
116
|
+
pos = pos + 1
|
|
117
|
+
else
|
|
118
|
+
return
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
-- Encodes one Unicode code point as UTF-8 bytes, for \\uXXXX escapes
|
|
124
|
+
-- inside JSON strings. No \`utf8\` library is loaded in this sandbox (see
|
|
125
|
+
-- \`./globals\`), so this is a small hand-rolled encoder covering the full
|
|
126
|
+
-- Unicode range JSON can express (a lone \\uXXXX escape, or a surrogate
|
|
127
|
+
-- pair for a codepoint above the Basic Multilingual Plane).
|
|
128
|
+
local function utf8_encode(cp)
|
|
129
|
+
if cp <= 0x7F then
|
|
130
|
+
return schar(cp)
|
|
131
|
+
elseif cp <= 0x7FF then
|
|
132
|
+
return schar(0xC0 + floor(cp / 0x40), 0x80 + (cp % 0x40))
|
|
133
|
+
elseif cp <= 0xFFFF then
|
|
134
|
+
return schar(
|
|
135
|
+
0xE0 + floor(cp / 0x1000),
|
|
136
|
+
0x80 + (floor(cp / 0x40) % 0x40),
|
|
137
|
+
0x80 + (cp % 0x40)
|
|
138
|
+
)
|
|
139
|
+
else
|
|
140
|
+
return schar(
|
|
141
|
+
0xF0 + floor(cp / 0x40000),
|
|
142
|
+
0x80 + (floor(cp / 0x1000) % 0x40),
|
|
143
|
+
0x80 + (floor(cp / 0x40) % 0x40),
|
|
144
|
+
0x80 + (cp % 0x40)
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
local decode_value
|
|
150
|
+
|
|
151
|
+
local function decode_string()
|
|
152
|
+
-- text:sub(pos, pos) == '"' on entry
|
|
153
|
+
pos = pos + 1
|
|
154
|
+
local parts = {}
|
|
155
|
+
local start = pos
|
|
156
|
+
while true do
|
|
157
|
+
if pos > len then fail("malformed") end
|
|
158
|
+
local c = sbyte(text, pos)
|
|
159
|
+
if c == 34 then -- '"'
|
|
160
|
+
parts[#parts + 1] = ssub(text, start, pos - 1)
|
|
161
|
+
pos = pos + 1
|
|
162
|
+
return tconcat(parts)
|
|
163
|
+
elseif c == 92 then -- '\\\\'
|
|
164
|
+
parts[#parts + 1] = ssub(text, start, pos - 1)
|
|
165
|
+
local e = ssub(text, pos + 1, pos + 1)
|
|
166
|
+
if e == '"' or e == '\\\\' or e == '/' then
|
|
167
|
+
parts[#parts + 1] = e
|
|
168
|
+
pos = pos + 2
|
|
169
|
+
elseif e == 'b' then
|
|
170
|
+
parts[#parts + 1] = schar(8)
|
|
171
|
+
pos = pos + 2
|
|
172
|
+
elseif e == 'f' then
|
|
173
|
+
parts[#parts + 1] = schar(12)
|
|
174
|
+
pos = pos + 2
|
|
175
|
+
elseif e == 'n' then
|
|
176
|
+
parts[#parts + 1] = schar(10)
|
|
177
|
+
pos = pos + 2
|
|
178
|
+
elseif e == 'r' then
|
|
179
|
+
parts[#parts + 1] = schar(13)
|
|
180
|
+
pos = pos + 2
|
|
181
|
+
elseif e == 't' then
|
|
182
|
+
parts[#parts + 1] = schar(9)
|
|
183
|
+
pos = pos + 2
|
|
184
|
+
elseif e == 'u' then
|
|
185
|
+
local hex = ssub(text, pos + 2, pos + 5)
|
|
186
|
+
local code = tonumber(hex, 16)
|
|
187
|
+
if not code then fail("malformed") end
|
|
188
|
+
pos = pos + 6
|
|
189
|
+
if code >= 0xD800 and code <= 0xDBFF
|
|
190
|
+
and ssub(text, pos, pos + 1) == "\\\\u" then
|
|
191
|
+
local hex2 = ssub(text, pos + 2, pos + 5)
|
|
192
|
+
local low = tonumber(hex2, 16)
|
|
193
|
+
if low and low >= 0xDC00 and low <= 0xDFFF then
|
|
194
|
+
code = 0x10000 + (code - 0xD800) * 0x400 + (low - 0xDC00)
|
|
195
|
+
pos = pos + 6
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
parts[#parts + 1] = utf8_encode(code)
|
|
199
|
+
else
|
|
200
|
+
fail("malformed")
|
|
201
|
+
end
|
|
202
|
+
start = pos
|
|
203
|
+
else
|
|
204
|
+
pos = pos + 1
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
local function decode_number()
|
|
210
|
+
local s, e = sfind(text, "^-?%d+%.?%d*[eE]?[%+%-]?%d*", pos)
|
|
211
|
+
if not s then fail("malformed") end
|
|
212
|
+
local numText = ssub(text, s, e)
|
|
213
|
+
pos = e + 1
|
|
214
|
+
local n = tonumber(numText)
|
|
215
|
+
if not n then fail("malformed") end
|
|
216
|
+
return n
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
local function decode_object(depth)
|
|
220
|
+
if depth >= maxDepth then fail("depth") end
|
|
221
|
+
pos = pos + 1 -- skip '{'
|
|
222
|
+
local out = {}
|
|
223
|
+
skip_ws()
|
|
224
|
+
if pos <= len and sbyte(text, pos) == 125 then -- '}'
|
|
225
|
+
pos = pos + 1
|
|
226
|
+
return out
|
|
227
|
+
end
|
|
228
|
+
while true do
|
|
229
|
+
skip_ws()
|
|
230
|
+
if sbyte(text, pos) ~= 34 then fail("malformed") end
|
|
231
|
+
local key = decode_string()
|
|
232
|
+
skip_ws()
|
|
233
|
+
if sbyte(text, pos) ~= 58 then fail("malformed") end -- ':'
|
|
234
|
+
pos = pos + 1
|
|
235
|
+
local value = decode_value(depth + 1)
|
|
236
|
+
-- JSON null becomes an ABSENT key, not an assigned nil -- reading it
|
|
237
|
+
-- back from Lua is then an ordinary, un-raising nil (issue #6, trap 3).
|
|
238
|
+
--
|
|
239
|
+
-- A key EXACTLY equal to the trusted marshal walk's array marker
|
|
240
|
+
-- (\`./marshal\`'s \`ARRAY_MARKER\`) is dropped, never stored -- this is
|
|
241
|
+
-- attacker-controlled JSON (a remote fetch body, or a host-stored
|
|
242
|
+
-- cache value), and \`finalizeMarshaledValue\` (\`./marshal\`) treats
|
|
243
|
+
-- that exact key as a trusted signal that ITS OWN marshal walk
|
|
244
|
+
-- produced this table and it should be reshaped into a JS array.
|
|
245
|
+
-- Without dropping it here, a response body such as
|
|
246
|
+
-- \`{"${ARRAY_MARKER}":true,"1":"a","2":"b","x":"kept"}\` would decode
|
|
247
|
+
-- to a Lua table carrying that same key, and later marshal to the
|
|
248
|
+
-- host as a JS ARRAY -- silently reshaping attacker-controlled data
|
|
249
|
+
-- and dropping its real keys (adversarial finding A4). Dropping
|
|
250
|
+
-- (rather than rejecting the whole response) keeps an otherwise
|
|
251
|
+
-- ordinary API response usable even if it happens to use this exact
|
|
252
|
+
-- field name for something unrelated.
|
|
253
|
+
if value ~= nil and key ~= "${ARRAY_MARKER}" then
|
|
254
|
+
out[key] = value
|
|
255
|
+
end
|
|
256
|
+
skip_ws()
|
|
257
|
+
local c = sbyte(text, pos)
|
|
258
|
+
if c == 44 then -- ','
|
|
259
|
+
pos = pos + 1
|
|
260
|
+
elseif c == 125 then -- '}'
|
|
261
|
+
pos = pos + 1
|
|
262
|
+
break
|
|
263
|
+
else
|
|
264
|
+
fail("malformed")
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
return out
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
local function decode_array(depth)
|
|
271
|
+
if depth >= maxDepth then fail("depth") end
|
|
272
|
+
pos = pos + 1 -- skip '['
|
|
273
|
+
local out = {}
|
|
274
|
+
local n = 0
|
|
275
|
+
skip_ws()
|
|
276
|
+
if pos <= len and sbyte(text, pos) == 93 then -- ']'
|
|
277
|
+
pos = pos + 1
|
|
278
|
+
return out
|
|
279
|
+
end
|
|
280
|
+
while true do
|
|
281
|
+
local value = decode_value(depth + 1)
|
|
282
|
+
n = n + 1
|
|
283
|
+
-- A JSON null ARRAY ELEMENT decodes to Lua \`false\`, not an absent
|
|
284
|
+
-- slot (orchestrator decision, adversarial finding C2): an absent
|
|
285
|
+
-- slot would leave a hole (\`#\`/\`ipairs\` stop early, and returning
|
|
286
|
+
-- the table to the host fails \`MARK_MARSHAL:key-type\` since the walk
|
|
287
|
+
-- no longer sees sequential \`1..n\` keys) -- reproducing issue #6's
|
|
288
|
+
-- headline crash for any API that puts \`null\` inside an array. \`nil\`
|
|
289
|
+
-- stays the right encoding for a JSON null OBJECT VALUE (an absent
|
|
290
|
+
-- key, see \`decode_object\` above) because a Lua table simply has no
|
|
291
|
+
-- way to represent "key present, value nil" at all; an array
|
|
292
|
+
-- POSITION has no such ambiguity to preserve, so \`false\` -- itself a
|
|
293
|
+
-- valid, falsy, round-trippable Lua/JSON value -- is used instead.
|
|
294
|
+
if value == nil then
|
|
295
|
+
out[n] = false
|
|
296
|
+
else
|
|
297
|
+
out[n] = value
|
|
298
|
+
end
|
|
299
|
+
skip_ws()
|
|
300
|
+
local c = sbyte(text, pos)
|
|
301
|
+
if c == 44 then
|
|
302
|
+
pos = pos + 1
|
|
303
|
+
elseif c == 93 then
|
|
304
|
+
pos = pos + 1
|
|
305
|
+
break
|
|
306
|
+
else
|
|
307
|
+
fail("malformed")
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
return out
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
decode_value = function(depth)
|
|
314
|
+
skip_ws()
|
|
315
|
+
if pos > len then fail("malformed") end
|
|
316
|
+
local c = sbyte(text, pos)
|
|
317
|
+
if c == 34 then
|
|
318
|
+
return decode_string()
|
|
319
|
+
elseif c == 123 then
|
|
320
|
+
return decode_object(depth)
|
|
321
|
+
elseif c == 91 then
|
|
322
|
+
return decode_array(depth)
|
|
323
|
+
elseif c == 116 then -- 't'rue
|
|
324
|
+
if ssub(text, pos, pos + 3) == "true" then
|
|
325
|
+
pos = pos + 4
|
|
326
|
+
return true
|
|
327
|
+
end
|
|
328
|
+
fail("malformed")
|
|
329
|
+
elseif c == 102 then -- 'f'alse
|
|
330
|
+
if ssub(text, pos, pos + 4) == "false" then
|
|
331
|
+
pos = pos + 5
|
|
332
|
+
return false
|
|
333
|
+
end
|
|
334
|
+
fail("malformed")
|
|
335
|
+
elseif c == 110 then -- 'n'ull
|
|
336
|
+
if ssub(text, pos, pos + 3) == "null" then
|
|
337
|
+
pos = pos + 4
|
|
338
|
+
return nil
|
|
339
|
+
end
|
|
340
|
+
fail("malformed")
|
|
341
|
+
elseif c == 45 or (c >= 48 and c <= 57) then
|
|
342
|
+
return decode_number()
|
|
343
|
+
else
|
|
344
|
+
fail("malformed")
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
local result = decode_value(0)
|
|
349
|
+
skip_ws()
|
|
350
|
+
if pos <= len then fail("malformed") end
|
|
351
|
+
return result
|
|
352
|
+
end
|
|
353
|
+
`;
|
|
354
|
+
}
|
package/dist/marshal.d.ts
CHANGED
|
@@ -98,6 +98,40 @@ export declare function buildMarshalPrelude(limits: MarshalLimits): string;
|
|
|
98
98
|
* mechanism (a custom `_ENV` per chunk) that this phase doesn't need.
|
|
99
99
|
*/
|
|
100
100
|
export declare function wrapUserCode(code: string): string;
|
|
101
|
+
/**
|
|
102
|
+
* Depth/node budget check for a plain, already-`JSON.parse`d JS value
|
|
103
|
+
* (arrays/objects/strings/numbers/booleans/`null`; never a cycle, since
|
|
104
|
+
* `JSON.parse` output is always a tree). Used by `./capabilities`' `net.fetch_json`
|
|
105
|
+
* to reject an oversized/too-deep fetch response BEFORE the raw JSON text
|
|
106
|
+
* is ever handed to Lua for decoding (`./json-decode`) — the JS-side
|
|
107
|
+
* mirror of what `buildMarshalPrelude`'s in-Lua walk does for the return
|
|
108
|
+
* value going the OTHER direction. Counting rule matches that walk exactly
|
|
109
|
+
* so the two budgets read as one shared limit, not two independently-tuned
|
|
110
|
+
* ones: every value (leaf or container) counts as one node, and `depth`
|
|
111
|
+
* increases by one for each level of array/object nesting.
|
|
112
|
+
*/
|
|
113
|
+
export declare function checkJsonWithinLimits(value: unknown, limits: MarshalLimits): {
|
|
114
|
+
ok: true;
|
|
115
|
+
} | {
|
|
116
|
+
ok: false;
|
|
117
|
+
reason: 'depth' | 'nodes';
|
|
118
|
+
message: string;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* True array marker set by the Lua-side marshal walk (see
|
|
122
|
+
* `buildMarshalPrelude`). Exported (not just module-private) so
|
|
123
|
+
* `./json-decode`'s JSON decoder can recognize and DROP this exact key if
|
|
124
|
+
* it ever appears as an object key in attacker-controlled JSON (adversarial
|
|
125
|
+
* finding A4): without that, a remote response body like
|
|
126
|
+
* `{"__smd_is_array":true,"1":"a","2":"b","x":"kept"}` would decode to an
|
|
127
|
+
* ordinary Lua table carrying this same marker key, and `finalizeMarshaledValue`
|
|
128
|
+
* below — which has no way to tell "the trusted marshal walk set this" apart
|
|
129
|
+
* from "the JSON itself happened to contain a key with this exact name" —
|
|
130
|
+
* would then convert it to a JS ARRAY on the way out, silently reshaping
|
|
131
|
+
* attacker-controlled data and dropping the object's real keys. See
|
|
132
|
+
* `./json-decode`'s decoder for where the corresponding key is dropped.
|
|
133
|
+
*/
|
|
134
|
+
export declare const ARRAY_MARKER = "__smd_is_array";
|
|
101
135
|
/**
|
|
102
136
|
* Final JS-side pass over the value wasmoon already converted from the
|
|
103
137
|
* Lua-side-capped table: strips the `__smd_is_array` marker (converting
|
package/dist/marshal.js
CHANGED
|
@@ -168,8 +168,71 @@ end
|
|
|
168
168
|
export function wrapUserCode(code) {
|
|
169
169
|
return `local function __smd_user_chunk()\n${code}\nend\nreturn __smd_marshal_root(__smd_user_chunk())`;
|
|
170
170
|
}
|
|
171
|
-
/**
|
|
172
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Depth/node budget check for a plain, already-`JSON.parse`d JS value
|
|
173
|
+
* (arrays/objects/strings/numbers/booleans/`null`; never a cycle, since
|
|
174
|
+
* `JSON.parse` output is always a tree). Used by `./capabilities`' `net.fetch_json`
|
|
175
|
+
* to reject an oversized/too-deep fetch response BEFORE the raw JSON text
|
|
176
|
+
* is ever handed to Lua for decoding (`./json-decode`) — the JS-side
|
|
177
|
+
* mirror of what `buildMarshalPrelude`'s in-Lua walk does for the return
|
|
178
|
+
* value going the OTHER direction. Counting rule matches that walk exactly
|
|
179
|
+
* so the two budgets read as one shared limit, not two independently-tuned
|
|
180
|
+
* ones: every value (leaf or container) counts as one node, and `depth`
|
|
181
|
+
* increases by one for each level of array/object nesting.
|
|
182
|
+
*/
|
|
183
|
+
export function checkJsonWithinLimits(value, limits) {
|
|
184
|
+
let nodes = 0;
|
|
185
|
+
function walk(v, depth) {
|
|
186
|
+
nodes++;
|
|
187
|
+
if (nodes > limits.maxNodes) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
reason: 'nodes',
|
|
191
|
+
message: `exceeds the ${limits.maxNodes}-node limit`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (v !== null && typeof v === 'object') {
|
|
195
|
+
if (depth >= limits.maxDepth) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
reason: 'depth',
|
|
199
|
+
message: `exceeds the ${limits.maxDepth}-level depth limit`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (Array.isArray(v)) {
|
|
203
|
+
for (const item of v) {
|
|
204
|
+
const r = walk(item, depth + 1);
|
|
205
|
+
if (!r.ok)
|
|
206
|
+
return r;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
for (const val of Object.values(v)) {
|
|
211
|
+
const r = walk(val, depth + 1);
|
|
212
|
+
if (!r.ok)
|
|
213
|
+
return r;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { ok: true };
|
|
218
|
+
}
|
|
219
|
+
return walk(value, 0);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* True array marker set by the Lua-side marshal walk (see
|
|
223
|
+
* `buildMarshalPrelude`). Exported (not just module-private) so
|
|
224
|
+
* `./json-decode`'s JSON decoder can recognize and DROP this exact key if
|
|
225
|
+
* it ever appears as an object key in attacker-controlled JSON (adversarial
|
|
226
|
+
* finding A4): without that, a remote response body like
|
|
227
|
+
* `{"__smd_is_array":true,"1":"a","2":"b","x":"kept"}` would decode to an
|
|
228
|
+
* ordinary Lua table carrying this same marker key, and `finalizeMarshaledValue`
|
|
229
|
+
* below — which has no way to tell "the trusted marshal walk set this" apart
|
|
230
|
+
* from "the JSON itself happened to contain a key with this exact name" —
|
|
231
|
+
* would then convert it to a JS ARRAY on the way out, silently reshaping
|
|
232
|
+
* attacker-controlled data and dropping the object's real keys. See
|
|
233
|
+
* `./json-decode`'s decoder for where the corresponding key is dropped.
|
|
234
|
+
*/
|
|
235
|
+
export const ARRAY_MARKER = '__smd_is_array';
|
|
173
236
|
/**
|
|
174
237
|
* Final JS-side pass over the value wasmoon already converted from the
|
|
175
238
|
* Lua-side-capped table: strips the `__smd_is_array` marker (converting
|
package/dist/sandbox.js
CHANGED
|
@@ -216,6 +216,7 @@ export async function runScript(options) {
|
|
|
216
216
|
cache: options.cache,
|
|
217
217
|
bundle: options.bundle,
|
|
218
218
|
maxFetchBytes: options.maxFetchBytes,
|
|
219
|
+
marshalLimits,
|
|
219
220
|
});
|
|
220
221
|
for (const [name, fn] of Object.entries(rawGlobals)) {
|
|
221
222
|
engine.global.set(name, fn);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markii/lua",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Sandboxed Lua 5.4 (wasmoon) execution runtime for Mark's document scripting: an empty-env global whitelist, two-tier capability-gated net/cache/bundle access, instruction-count/wall-clock/memory limits, and depth/size-capped Lua<->JS marshaling.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"markdown",
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"lint": "eslint ."
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@markii/bundle": "0.3.
|
|
48
|
-
"@markii/runtime": "0.3.
|
|
47
|
+
"@markii/bundle": "0.3.1",
|
|
48
|
+
"@markii/runtime": "0.3.1",
|
|
49
49
|
"wasmoon": "^1.16.0"
|
|
50
50
|
}
|
|
51
51
|
}
|