Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/features/plugin-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ Inside the admin window, plugin React surfaces (panels, app pages, canvas overla
- **`console.{log, info, warn, error, debug, trace}`** — routes to `api.plugin.log`.
- **`fetch(url, init)`** — opt-in: requires `network.outbound` permission AND the URL host on the `networkAllowedHosts` allowlist. Byte-safe: `arrayBuffer()` returns exact bytes; request bodies accept `string | ArrayBuffer | TypedArray/DataView`.
- **`crypto.subtle`** — pure computation bridge: `digest(...)`, `importKey('raw', ..., { name: 'HMAC', hash })`, and `sign('HMAC', ...)`. These map to ungated `crypto.digest` / `crypto.signHmac` RPC targets because they do no I/O.
- **`crypto.getRandomValues(view)` / `crypto.randomUUID()`** — CSPRNG entropy from the host, for tokens, nonces, invitation codes and one-time links. Unlike the digest/HMAC pair these do **not** use the `__hostCall` RPC bridge, because that returns a Promise and `getRandomValues` is synchronous by spec; they call the dedicated synchronous `__hostRandomBytes` host function instead. Also ungated (no I/O, nothing to escalate). `getRandomValues` accepts integer-typed views only, throwing `TypeMismatchError` for float or non-view arguments, and caps a single call at 65536 bytes with `QuotaExceededError` above it — the WebCrypto quota, enforced in both the shim and the host function. `randomUUID` returns an RFC 9562 version-4 UUID.

### What's denied

Expand Down Expand Up @@ -364,7 +365,7 @@ VM budgets live in `server/plugins/quickjs/limits.ts`; the host-side RPC timeout

Before any plugin code runs, the host evaluates a **bootstrap** program inside the
VM: Web-Platform polyfills (URL, TextEncoder, console, AbortController, timers,
crypto.subtle, fetch) plus the SDK factory `__buildApi()` and the `__run*`
crypto.subtle, crypto.getRandomValues, fetch) plus the SDK factory `__buildApi()` and the `__run*`
dispatchers the host calls to drive plugin code. QuickJS has no module loader, so
this bootstrap must reach the VM as a single source **string** — but that string
is a build artifact, not the authoring surface.
Expand Down
100 changes: 96 additions & 4 deletions server/plugins/quickjs/bootstrap/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
/**
* WebCrypto-compatible crypto.subtle shim evaluated inside every plugin
* QuickJS VM.
* WebCrypto-compatible crypto shim evaluated inside every plugin QuickJS VM.
*
* Exposed surface: crypto.subtle.digest, crypto.subtle.importKey (raw HMAC),
* and crypto.subtle.sign (HMAC). Bytes cross the host bridge as base64
* strings via __hostCall('crypto.digest') / __hostCall('crypto.signHmac').
* crypto.subtle.sign (HMAC), plus crypto.getRandomValues and
* crypto.randomUUID. Digest/HMAC bytes cross the host bridge as base64
* strings via __hostCall('crypto.digest') / __hostCall('crypto.signHmac');
* entropy uses the synchronous __hostRandomBytes bridge instead, because
* getRandomValues is synchronous by spec and __hostCall returns a Promise.
*/

/**
* Per-call entropy ceiling, shared by the VM shim and the host function so
* both agree on one bound. Matches the WebCrypto quota for
* `crypto.getRandomValues`, which throws QuotaExceededError above 65536 bytes.
*/
export const CRYPTO_RANDOM_BYTES_MAX = 65536

export const CRYPTO_SUBTLE_SHIM = `// ------- crypto.subtle — WebCrypto-compatible shim --------------------------
// Storage / auth plugins need SHA-256 + HMAC-SHA256 (AWS Sigv4, JWT signing,
// OAuth, presigned URLs). Without a host bridge they'd have to vendor a
Expand Down Expand Up @@ -140,3 +149,86 @@ globalThis.crypto.subtle = {
};

`

/**
* CSPRNG shim — `crypto.getRandomValues` and `crypto.randomUUID`.
*
* Must be evaluated after BASE64_SHIM (uses `__base64ToBytes`) and it augments
* whatever `globalThis.crypto` already exists rather than replacing it, so the
* ordering against CRYPTO_SUBTLE_SHIM does not matter.
*
* Without this, `Math.random` and `Date` were the only entropy in the sandbox,
* so a plugin minting a bearer token, nonce, invitation code or one-time link
* had no safe way to do it on the server.
*/
export const CRYPTO_RANDOM_SHIM = `// ------- crypto.getRandomValues / crypto.randomUUID -------------------------
// Entropy comes from the host's CSPRNG through the SYNCHRONOUS
// __hostRandomBytes bridge (base64 in, bytes out). getRandomValues is
// synchronous by spec, so it cannot use the Promise-returning __hostCall the
// digest/HMAC paths use.
var __CRYPTO_RANDOM_MAX = ${CRYPTO_RANDOM_BYTES_MAX};

// QuickJS has no DOMException, so carry the spec's error \`name\` on a plain
// Error. Plugins that branch on err.name still behave the same.
function __cryptoNamedError(name, message) {
var err = new Error(message);
err.name = name;
return err;
}

// getRandomValues accepts only integer-typed views. Float and non-typed views
// are a TypeMismatchError per spec. Named rather than instanceof-checked so a
// missing BigInt64Array in the engine degrades to "unsupported", not a crash.
var __CRYPTO_INTEGER_VIEWS = [
'Int8Array', 'Uint8Array', 'Uint8ClampedArray',
'Int16Array', 'Uint16Array',
'Int32Array', 'Uint32Array',
'BigInt64Array', 'BigUint64Array',
];

function __cryptoRandomBytes(count) {
if (count <= 0) return new Uint8Array(0);
return __base64ToBytes(__hostRandomBytes(count));
}

globalThis.crypto = globalThis.crypto || {};

globalThis.crypto.getRandomValues = function getRandomValues(array) {
if (!array || typeof array !== 'object' || !ArrayBuffer.isView(array)) {
throw __cryptoNamedError('TypeMismatchError', 'getRandomValues expects an integer-typed TypedArray.');
}
var kind = array.constructor && array.constructor.name;
if (__CRYPTO_INTEGER_VIEWS.indexOf(kind) < 0) {
throw __cryptoNamedError('TypeMismatchError', 'getRandomValues does not support ' + String(kind) + '.');
}
if (array.byteLength > __CRYPTO_RANDOM_MAX) {
throw __cryptoNamedError(
'QuotaExceededError',
'getRandomValues supports at most ' + __CRYPTO_RANDOM_MAX + ' bytes per call.',
);
}
if (array.byteLength === 0) return array;
// Fill through a byte view so the element width of the caller's array is
// irrelevant — the spec fills the underlying bytes.
var bytes = __cryptoRandomBytes(array.byteLength);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(bytes);
return array;
};

var __CRYPTO_HEX = '0123456789abcdef';

globalThis.crypto.randomUUID = function randomUUID() {
var b = __cryptoRandomBytes(16);
// RFC 9562 §5.4: version 4 in the high nibble of octet 6, variant 10 in the
// top two bits of octet 8.
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
var out = '';
for (var i = 0; i < 16; i++) {
if (i === 4 || i === 6 || i === 8 || i === 10) out += '-';
out += __CRYPTO_HEX[b[i] >> 4] + __CRYPTO_HEX[b[i] & 0x0f];
}
return out;
};

`
6 changes: 4 additions & 2 deletions server/plugins/quickjs/bootstrap/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@
* Execution order matters: polyfills must be defined before the API layer
* references them (URL, TextEncoder, AbortController, crypto.subtle, fetch),
* and the shared base64 codec must precede crypto, fetch, and the bundled
* runtime — all three move binary payloads through it.
* runtime — all four move binary payloads through it (the CSPRNG shim decodes
* host entropy with `__base64ToBytes`).
* The leading `'use strict';` makes the entire evaluated program — including
* the bundled IIFE — strict.
*/

import { URL_POLYFILL, TEXT_CODEC_POLYFILL, CONSOLE_POLYFILL, ABORT_CONTROLLER_POLYFILL } from './polyfills'
import { TIMERS_SOURCE } from './timers'
import { BASE64_SHIM } from './base64'
import { CRYPTO_SUBTLE_SHIM } from './crypto'
import { CRYPTO_SUBTLE_SHIM, CRYPTO_RANDOM_SHIM } from './crypto'
import { FETCH_SHIM } from './fetch'
import { PLUGIN_BOOTSTRAP_SOURCE } from './generated/pluginBootstrap'

Expand All @@ -33,5 +34,6 @@ export const BOOTSTRAP_SOURCE =
ABORT_CONTROLLER_POLYFILL +
BASE64_SHIM +
CRYPTO_SUBTLE_SHIM +
CRYPTO_RANDOM_SHIM +
FETCH_SHIM +
PLUGIN_BOOTSTRAP_SOURCE
23 changes: 23 additions & 0 deletions server/plugins/quickjs/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import { getQuickJS, type QuickJSContext, type QuickJSHandle, type QuickJSWASMMo
import { BOOTSTRAP_SOURCE } from './bootstrap/index'
import { DEFAULT_EVAL_TIMEOUT_MS, DEFAULT_MEMORY_LIMIT_BYTES, DEFAULT_STACK_SIZE_BYTES } from './limits'
import { jsToHandle } from './marshal'
import { bytesToBase64 } from '../protocol/bodyEncoding'
import { CRYPTO_RANDOM_BYTES_MAX } from './bootstrap/crypto'
import { callString, callVoid, evalJson, withSyncDeadline } from './eval'
import type { PluginVm, PluginVmEnv } from './types'

Expand Down Expand Up @@ -277,6 +279,27 @@ export async function createPluginVm(args: {
ctx.setProp(ctx.global, '__log', logHandle)
hostFunctionHandles.push(logHandle)

// 2b. Wire __hostRandomBytes — CSPRNG entropy, returned SYNCHRONOUSLY as
// base64. Deliberately not routed through __hostCall: that returns a
// VM-side Promise, and `crypto.getRandomValues` is synchronous by
// spec, so a plugin could not await it. Pure computation with no I/O
// and no privilege to escalate, so it needs no permission gate — the
// same reasoning the crypto.digest / crypto.signHmac handlers document.
// Capped at the WebCrypto quota so a plugin cannot ask the host for an
// unbounded allocation; the VM-side shim enforces the same bound and
// throws the spec's QuotaExceededError before ever calling in.
const hostRandomBytesHandle = ctx.newFunction('__hostRandomBytes', (countHandle) => {
const requested = ctx.getNumber(countHandle)
const count = Number.isFinite(requested) ? Math.floor(requested) : 0
if (count <= 0) return ctx.newString('')
if (count > CRYPTO_RANDOM_BYTES_MAX) {
return { error: ctx.newError(`__hostRandomBytes: at most ${CRYPTO_RANDOM_BYTES_MAX} bytes`) }
}
return ctx.newString(bytesToBase64(crypto.getRandomValues(new Uint8Array(count))))
})
ctx.setProp(ctx.global, '__hostRandomBytes', hostRandomBytesHandle)
hostFunctionHandles.push(hostRandomBytesHandle)

// 3. Wire meta + settings as VM globals.
//
// `grantedPermissions` is the AUTHORITATIVE set the operator approved at
Expand Down
Loading
Loading