@markii/lua 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/capabilities.d.ts +4 -0
- package/dist/capabilities.js +113 -20
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# @markii/lua
|
|
2
2
|
|
|
3
|
-
A sandboxed Lua 5.4 (via `wasmoon`) execution runtime for [
|
|
3
|
+
A sandboxed Lua 5.4 (via `wasmoon`) execution runtime for [Markii](https://github.com/sadigaxund/markii)'s document scripting: an empty-environment global whitelist, capability-gated net/cache/bundle access, instruction-count and wall-clock/memory limits, and depth/size-capped Lua↔JS value marshaling. No React, no markdown parsing.
|
|
4
4
|
|
|
5
5
|
See the [repository](https://github.com/sadigaxund/markii) for the format spec and the reference library as a whole.
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -85,6 +85,10 @@ export interface CapabilityDenial {
|
|
|
85
85
|
export interface CapabilityDenials {
|
|
86
86
|
last(): CapabilityDenial | undefined;
|
|
87
87
|
}
|
|
88
|
+
/** Construct an `Error` a `NetProvider` throws for a policy denial — see {@link NET_PROVIDER_DENIAL}. */
|
|
89
|
+
export declare function netProviderDenial(message: string): Error;
|
|
90
|
+
/** True when `err` was built by {@link netProviderDenial} — a provider's policy refusal, not a transport failure. */
|
|
91
|
+
export declare function isNetProviderDenial(err: unknown): err is Error;
|
|
88
92
|
/**
|
|
89
93
|
* `Uint8Array` <-> Lua string, byte-for-byte via one JS UTF-16 code unit
|
|
90
94
|
* per byte (Latin-1-style). Lua strings are themselves 8-bit-clean byte
|
package/dist/capabilities.js
CHANGED
|
@@ -6,6 +6,37 @@ export const DEFAULT_MAX_FETCH_BYTES = 2_000_000;
|
|
|
6
6
|
function capabilityError(message) {
|
|
7
7
|
return new Error(`${CAPABILITY_ERROR_TAG}: ${message}`);
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Brand marking an `Error` a {@link NetProvider} throws for a POLICY denial
|
|
11
|
+
* (an ungranted redirect hop, an over-size body, too many hops, a
|
|
12
|
+
* credential-bearing redirect) as opposed to a genuine transport failure
|
|
13
|
+
* (DNS, connection refused). A provider is one layer below `buildCapabilities`'
|
|
14
|
+
* own grant check, so its throws would otherwise surface as an ordinary
|
|
15
|
+
* runtime error and be classified `'script-error'`. Marking the throw lets
|
|
16
|
+
* this module record it on the non-spoofable {@link CapabilityDenials} handle
|
|
17
|
+
* (see `callNetProvider` below) instead of relying on a message string a
|
|
18
|
+
* script could read and forge.
|
|
19
|
+
*
|
|
20
|
+
* This closes P2-c (PENTEST-REPORT-2026-08-23.md §9.3): the earlier host-side
|
|
21
|
+
* fix embedded a per-run random tag in the thrown message so the host could
|
|
22
|
+
* reclassify the failure, but that message crosses back into Lua, where a
|
|
23
|
+
* script's own `pcall`/`tostring` reads the tag and then forges it. The brand
|
|
24
|
+
* is a JS `Symbol` checked on the JS side, BEFORE the error ever crosses the
|
|
25
|
+
* Lua boundary, so nothing a script can observe distinguishes a denial, and
|
|
26
|
+
* classification no longer depends on any Lua-visible string.
|
|
27
|
+
*/
|
|
28
|
+
const NET_PROVIDER_DENIAL = Symbol('markii.net.provider-denial');
|
|
29
|
+
/** Construct an `Error` a `NetProvider` throws for a policy denial — see {@link NET_PROVIDER_DENIAL}. */
|
|
30
|
+
export function netProviderDenial(message) {
|
|
31
|
+
return Object.assign(new Error(message), {
|
|
32
|
+
[NET_PROVIDER_DENIAL]: true,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** True when `err` was built by {@link netProviderDenial} — a provider's policy refusal, not a transport failure. */
|
|
36
|
+
export function isNetProviderDenial(err) {
|
|
37
|
+
return (err instanceof Error &&
|
|
38
|
+
err[NET_PROVIDER_DENIAL] === true);
|
|
39
|
+
}
|
|
9
40
|
function describeThrown(err) {
|
|
10
41
|
return err instanceof Error ? err.message : String(err);
|
|
11
42
|
}
|
|
@@ -138,6 +169,28 @@ export function buildCapabilities(config) {
|
|
|
138
169
|
lastDenial = { reason, message };
|
|
139
170
|
}
|
|
140
171
|
const denials = { last: () => lastDenial };
|
|
172
|
+
/**
|
|
173
|
+
* Invokes a `NetProvider` method, turning a POLICY denial the provider
|
|
174
|
+
* throws (marked via `netProviderDenial`) into the same non-spoofable
|
|
175
|
+
* outcome an in-house grant check produces: recorded on `recordDenial` and
|
|
176
|
+
* re-thrown as a `capabilityError` carrying the provider's own plain
|
|
177
|
+
* message. A transport failure (unmarked) is left to propagate untouched,
|
|
178
|
+
* so it stays a `'script-error'`. This is what lets the host provider stop
|
|
179
|
+
* smuggling a secret classification tag through the Lua-visible error text
|
|
180
|
+
* (P2-c — see `netProviderDenial`).
|
|
181
|
+
*/
|
|
182
|
+
async function callNetProvider(op) {
|
|
183
|
+
try {
|
|
184
|
+
return await op();
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
if (isNetProviderDenial(err)) {
|
|
188
|
+
recordDenial('denied', err.message);
|
|
189
|
+
throw capabilityError(err.message);
|
|
190
|
+
}
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
141
194
|
// --- net --------------------------------------------------------------
|
|
142
195
|
// `fetch_json` and `post`/`patch` are gated INDEPENDENTLY of each other
|
|
143
196
|
// (a manifest can grant POST to a host without granting it GET, or vice
|
|
@@ -163,7 +216,7 @@ export function buildCapabilities(config) {
|
|
|
163
216
|
recordDenial('denied', message);
|
|
164
217
|
throw capabilityError(message);
|
|
165
218
|
}
|
|
166
|
-
const res = await config.net.get(url);
|
|
219
|
+
const res = await callNetProvider(() => config.net.get(url));
|
|
167
220
|
if (res.body.length > maxFetchBytes) {
|
|
168
221
|
const message = `fetch response for "${url}" exceeds the ${maxFetchBytes}-byte cap`;
|
|
169
222
|
recordDenial('denied', message);
|
|
@@ -235,7 +288,7 @@ net.fetch_json = function(url) return __smd_net_get_json_decode(__smd_net_get(ur
|
|
|
235
288
|
recordDenial('denied', message);
|
|
236
289
|
throw capabilityError(message);
|
|
237
290
|
}
|
|
238
|
-
const res = await config.net.post(url, body);
|
|
291
|
+
const res = await callNetProvider(() => config.net.post(url, body));
|
|
239
292
|
// As with `net.fetch_json` above (GitHub issue #6): a plain JS object
|
|
240
293
|
// (even one this shallow) crosses into Lua as a `js_proxy` userdata,
|
|
241
294
|
// not a genuine table. `status`/`body` are both scalars, so instead
|
|
@@ -289,7 +342,7 @@ net.post = function(url, body) return __smd_net_post_blocked(url, body):await()
|
|
|
289
342
|
recordDenial('denied', message);
|
|
290
343
|
throw capabilityError(message);
|
|
291
344
|
}
|
|
292
|
-
const res = await config.net.patch(url, body);
|
|
345
|
+
const res = await callNetProvider(() => config.net.patch(url, body));
|
|
293
346
|
// Same fix as `net.post` above (GitHub issue #6) — see that block's
|
|
294
347
|
// comment for the full mechanism.
|
|
295
348
|
return LuaMultiReturn.of(res.status, res.body);
|
|
@@ -390,28 +443,58 @@ net.patch = function(url, body) return __smd_net_patch_blocked(url, body):await(
|
|
|
390
443
|
// below — so without this check, a 300k-element cached array reached
|
|
391
444
|
// the script completely uncapped even though the FETCH path was
|
|
392
445
|
// already capped.
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
446
|
+
//
|
|
447
|
+
// An entry that fails the budget check, isn't a plain object at all,
|
|
448
|
+
// or carries a missing/non-finite `storedAtMs` (adversarial finding:
|
|
449
|
+
// a poisoned host snapshot like `{value: 'x'}` with no `storedAtMs`,
|
|
450
|
+
// or `storedAtMs: 'abc'`, would otherwise reach the Lua-side
|
|
451
|
+
// `now - storedAtMs` arithmetic in `cache.get` below and throw a
|
|
452
|
+
// script-error, wedging the note permanently instead of self-healing)
|
|
453
|
+
// — or that cannot be `JSON.stringify`'d at all (cyclic, or
|
|
454
|
+
// BigInt-bearing — something other than this sandbox's own WRITE
|
|
455
|
+
// side must have written it, since that side already rejects both) —
|
|
456
|
+
// is SELF-HEALED rather than denied (orchestrator decision, #6
|
|
457
|
+
// verification notes): it is treated as a cache MISS, exactly as if
|
|
458
|
+
// `key` had never been stored. `cache.get`'s Lua body (below) then
|
|
459
|
+
// calls `fn()` and writes the fresh, already-capped result back
|
|
460
|
+
// through `__smd_cache_set_raw`, which quietly repairs the stored
|
|
461
|
+
// entry for next time. No denial is recorded for this path — a
|
|
462
|
+
// capability denial is reserved for the fresh recompute itself
|
|
463
|
+
// failing the WRITE side's own caps, which is unchanged.
|
|
464
|
+
//
|
|
465
|
+
// Finiteness alone isn't enough (pentest finding N-1): a poisoned
|
|
466
|
+
// snapshot with a huge FUTURE `storedAtMs` (e.g.
|
|
467
|
+
// `Number.MAX_SAFE_INTEGER * 1000`) is finite, so it used to pass
|
|
468
|
+
// straight through to the Lua-side `(now - storedAtMs) < ttl*1000`
|
|
469
|
+
// freshness check below, which then went hugely negative and
|
|
470
|
+
// reported the poisoned value as fresh forever — served without
|
|
471
|
+
// `fn()` ever running. `storedAtMs` must additionally be an integer
|
|
472
|
+
// and plausible: not before the epoch, and not after "now" as this
|
|
473
|
+
// same call sees it. `nowMs` is read once, right before the check,
|
|
474
|
+
// and reused for the equality bound so a genuine entry written and
|
|
475
|
+
// read in the same millisecond (`storedAtMs === nowMs`) is still a
|
|
476
|
+
// HIT, not a false self-heal.
|
|
477
|
+
const nowMs = Date.now();
|
|
478
|
+
const storedAtMs = entry.storedAtMs;
|
|
479
|
+
if (typeof entry !== 'object' ||
|
|
480
|
+
entry === null ||
|
|
481
|
+
Array.isArray(entry) ||
|
|
482
|
+
typeof storedAtMs !== 'number' ||
|
|
483
|
+
!Number.isInteger(storedAtMs) ||
|
|
484
|
+
storedAtMs < 0 ||
|
|
485
|
+
storedAtMs > nowMs) {
|
|
486
|
+
return undefined;
|
|
398
487
|
}
|
|
488
|
+
const budgetCheck = checkJsonWithinLimits(entry.value, marshalLimits);
|
|
489
|
+
if (!budgetCheck.ok)
|
|
490
|
+
return undefined;
|
|
399
491
|
let text;
|
|
400
492
|
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
493
|
const encoded = JSON.stringify(entry.value);
|
|
409
494
|
text = encoded === undefined ? 'null' : encoded;
|
|
410
495
|
}
|
|
411
|
-
catch
|
|
412
|
-
|
|
413
|
-
recordDenial('denied', message);
|
|
414
|
-
throw capabilityError(message);
|
|
496
|
+
catch {
|
|
497
|
+
return undefined;
|
|
415
498
|
}
|
|
416
499
|
return LuaMultiReturn.of(text, entry.storedAtMs);
|
|
417
500
|
});
|
|
@@ -498,7 +581,17 @@ end
|
|
|
498
581
|
recordDenial('denied', message);
|
|
499
582
|
throw capabilityError(message);
|
|
500
583
|
}
|
|
501
|
-
|
|
584
|
+
// Resolve with `undefined`, NOT `null`, for a missing path. wasmoon's
|
|
585
|
+
// `Thread.pushValue` special-cases `typeof target === 'undefined'`
|
|
586
|
+
// with a direct `lua_pushnil` BEFORE it ever reaches its
|
|
587
|
+
// type-extension dispatch loop (`dist/index.js`'s `pushValue`
|
|
588
|
+
// `switch`); `null` is `typeof 'object'`, so it instead falls into
|
|
589
|
+
// that loop, where `PromiseTypeExtension.pushValue` unconditionally
|
|
590
|
+
// reads `decoration.target.then` and throws `Cannot read properties
|
|
591
|
+
// of null (reading 'then')` — verified empirically (wasmoon 1.16.0).
|
|
592
|
+
// This is exactly the failure this fixes (GitHub issue #9): a
|
|
593
|
+
// missing bundle path must resolve to Lua `nil`, not throw.
|
|
594
|
+
return data === undefined ? undefined : bytesToLuaString(data);
|
|
502
595
|
});
|
|
503
596
|
rawGlobals.__smd_bundle_exists_raw = (async (path) => {
|
|
504
597
|
try {
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { ALLOWED_GLOBALS, DENIED_GLOBALS, createEmptyLuaEngine, } from './global
|
|
|
5
5
|
export type { LimitHandle, ScriptLimits } from './limits.js';
|
|
6
6
|
export { DEFAULT_LIMITS, installLimits } from './limits.js';
|
|
7
7
|
export type { CacheEntry, CacheProvider, CapabilityConfig, CapabilityDenial, CapabilityDenials, CapabilityTier, NetGrants, NetProvider, NetResponse, } from './capabilities.js';
|
|
8
|
-
export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, luaStringToBytes, } from './capabilities.js';
|
|
8
|
+
export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, isNetProviderDenial, luaStringToBytes, netProviderDenial, } from './capabilities.js';
|
|
9
9
|
export type { MarshalLimits } from './marshal.js';
|
|
10
10
|
export { DEFAULT_MARSHAL_LIMITS, buildMarshalPrelude, checkJsonWithinLimits, finalizeMarshaledValue, wrapUserCode, } from './marshal.js';
|
|
11
11
|
export { NOT_YET_SUPPORTED_MESSAGE, buildRequireStub } from './require.js';
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
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
|
-
export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, luaStringToBytes, } from './capabilities.js';
|
|
9
|
+
export { DEFAULT_MAX_FETCH_BYTES, bytesToLuaString, buildCapabilities, isNetProviderDenial, luaStringToBytes, netProviderDenial, } from './capabilities.js';
|
|
10
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';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markii/lua",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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.
|
|
48
|
-
"@markii/runtime": "0.
|
|
47
|
+
"@markii/bundle": "0.5.0",
|
|
48
|
+
"@markii/runtime": "0.5.0",
|
|
49
49
|
"wasmoon": "^1.16.0"
|
|
50
50
|
}
|
|
51
51
|
}
|