@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.
@@ -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. */
@@ -1,4 +1,7 @@
1
- import { CAPABILITY_ERROR_TAG } from './errors.js';
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
- return parsed;
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
- return config.net.post(url, body);
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) return __smd_net_post(url, body):await() end
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
- return config.net.patch(url, body);
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) return __smd_net_patch(url, body):await() end
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
- rawGlobals.__smd_cache_get_raw = (async (key) => config.cache.get(key));
252
- rawGlobals.__smd_cache_set_raw = (async (key, value, storedAtMs) => {
253
- await config.cache.set(key, { value, storedAtMs });
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 existing = __smd_cache_get(key):await()
272
- if existing ~= nil then
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 - existing.storedAtMs) < (ttl * 1000) then
275
- return existing.value
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
- /** True array marker set by the Lua-side marshal walk (see `buildMarshalPrelude`). */
172
- const ARRAY_MARKER = '__smd_is_array';
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.0",
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.0",
48
- "@markii/runtime": "0.3.0",
47
+ "@markii/bundle": "0.3.1",
48
+ "@markii/runtime": "0.3.1",
49
49
  "wasmoon": "^1.16.0"
50
50
  }
51
51
  }