Source policy
A plugin is compiled into the same bundle as the host and runs with the page’s authority. There
is no sandbox to put it in, so the next best thing is to refuse, before compiling, the handful
of constructs that let code reach past the ctx.* API: evaluating strings, touching window or
storage directly, opening its own sockets, loading code at runtime.
Before every install and update the bridge scans every .ts, .tsx, .mts, .cts, .js,
.jsx, .mjs and .cjs file in the repository (the extensions esbuild resolves, so renaming a
file buys nothing). One hit refuses the operation with policy: file:line rule; an update that
fails leaves the previously installed tree untouched.
The shape rules
These run first, on every scanned file, because everything below is a search for names and
that only works on code a person could have read. A minified bundle, a string of
\x65\x76\x61\x6c escapes or a _0x3a2b[17] lookup table passes every rule in the next
table without hiding anything from the runtime — and the point was never the grep, it was that a
dishonest plugin is obvious in review.
| Rule | Refuses |
|---|---|
minified |
a line longer than 1000 characters |
packed source |
a file of 20 or more non-empty lines whose mean line is over 250 characters |
escape sequences |
more than eight \xNN or \uNNNN escapes in a row |
mangled identifiers |
_0x followed by four hex digits — the signature javascript-obfuscator leaves |
opaque blob |
an unbroken run of 256+ characters from [A-Za-z0-9+/=_-] with ≥ 3.5 bits of Shannon entropy per character |
invisible characters |
zero-width characters and bidirectional overrides (U+200B–U+200F, U+202A–U+202E, U+2060–U+2064, U+2066–U+2069, U+FEFF), which make the source read differently than it runs — the Trojan Source attack. A byte-order mark at the very start of a file is fine. |
Nothing here detects obfuscation in general; that is undecidable, and unreadable code can be written in plain ASCII at honest line lengths. What it does is take the cheap, tool-generated kind off the table and make the expensive kind look like what it is.
One honest thing is refused on purpose: a binary asset inlined as a data: URI trips
opaque blob, because a text scan cannot tell it from a payload. Ship images as files, or
draw them.
Real plugin sources are nowhere near these limits — the longest line across the published plugins is about 210 characters — but if one of them fires on code you consider ordinary, that is worth reporting.
The rules
In the order the bridge reports them:
| Rule | Matches |
|---|---|
eval |
the word eval, called or not: (0, eval)(s) is refused too |
Function |
the word Function: new Function(…), or a reference to call later |
globalThis |
the word globalThis, so globalThis['fe' + 'tch'] is refused as well as globalThis.x |
window |
the word window, with no exceptions, not even window.location.href |
self |
self. or self[; a function named self() is fine |
top, parent, frames |
the name followed by [ or by a member only the window has (parent.postMessage, top.location); parent.appendChild(li) and rect.top are fine |
opener |
the word opener |
defaultView |
defaultView, even after a dot: el.ownerDocument.defaultView is the window |
__receiveMessageCallbacks |
anywhere; it is VRCNext’s own message channel |
Reflect.get |
Reflect.get( |
document.cookie |
document.cookie |
document[ |
document[, so document['cook' + 'ie'] is refused |
document.location, location.href, location.assign |
navigating the page away, which is also a way to send data out |
localStorage, sessionStorage, indexedDB |
the name, even after a dot, so self.localStorage is refused |
XMLHttpRequest, fetch, WebSocket, EventSource |
the word, called or referenced. ctx.http.fetch( and ctx.router.fetch( are fine, and so is fetchStars |
sendBeacon |
the name, even after a dot, so const n = navigator; n.sendBeacon(…) is refused |
Worker, SharedWorker, importScripts |
the word |
dynamic import |
import(, however it is spaced: import ( too |
script tag |
<script anywhere |
innerHTML assignment, outerHTML assignment |
.innerHTML =, .innerHTML +=, and the same for outerHTML (a comparison or a read is fine) |
insertAdjacentHTML |
insertAdjacentHTML anywhere |
srcdoc |
the name, even after a dot |
createElement of a code-loading tag |
createElement( of script, iframe, frame, object or embed |
setTimeout with a string, setInterval with a string |
the timer whose first argument is a string literal |
constructor |
.constructor or ['constructor']: Object.getPrototypeOf(async () => {}).constructor is a Function constructor without the word. A class’s own constructor(…) { is fine |
import outside the plugin |
an import specifier that climbs past the plugin’s root ('../../state.json'), an absolute path, or a data:/file: URL |
imports a test file |
an import of a *.test.* or *.spec.* file (see below) |
require |
the word require |
process |
process. |
references a tooling config |
a source file, or any .json file, that names one of the exempt files below, in any letter case. package.json’s scripts block is not checked. |
A word match needs something other than an identifier character on both sides, and no dot
in front. So retrieval(x) does not trip eval, prefetch( does not trip fetch, and a
member access like ctx.http.fetch is left alone. Taking a reference is refused like a call:
const f = fetch and (0, fetch)(url) both trip fetch. The rules marked “even after a dot”
exist because those names reach the same object through another path.
Also enforced: at most 200 source files and 2 MiB of source in total, no symlinks inside
the clone. .git/ and non-source files such as README.md are not scanned.
The build checks too. A text scan can only guess what a specifier means; esbuild knows. Every
build asks it for the list of files it bundled and refuses the bundle unless each one is the
host, the generated import table, or a file inside an installed plugin’s own directory — and a
plugin’s files may import only each other and @vrcnext/plugin-api. So state.json (every
plugin’s settings), a config file elsewhere on disk, the host’s internal modules and another
plugin’s files cannot reach a bundle however the import is spelled. An install or update whose
build fails is rolled back.
Both lists live in Rust modules in the bridge (policy.rs and obfuscation.rs under
crates/vrcnext-bridge-plugins/src/) with a unit test per rule, and this page mirrors them.
What it is and is not
It is a text scan, not a parser. It is meant to catch honest mistakes and make dishonest
ones obvious in a review, not to be unbypassable, and the bridge’s own documentation says so.
A plugin that wants to reach window badly enough can find a spelling this list does not cover.
It does not confine a plugin to ctx.*. Plain DOM work is allowed, and the DOM has ways
out that no rule here covers: an <img> or <link> whose URL a plugin sets is a request to that
URL, CSS url() is another, and the page’s own elements — VRCNext’s login form included — can be
read like any other. What the policy does is make the obvious routes (fetch, storage, eval,
the window) fail loudly, so that what is left looks deliberate in a review. Those routes are
gated by permissions the user can see; the rest is trust in the author, which
is what signing is for.
Plain DOM work is allowed. document.createElement, addEventListener, textContent,
IntersectionObserver and friends are how plugin UI gets built; the policy is about reaching
outside the plugin’s own panels, not about building them.
Finding problems before the bridge does
The template’s eslint.config.mjs reports most of these as lint errors, so npm run check in
your repository catches them locally. The bridge’s message names the file, line and rule, so the
rest are one-line fixes.
What is not scanned
Three narrow exemptions, all for files that never reach the bundle: the tooling configs at the
repository root (eslint.config.*, vitest.config.*), scripts/sign-plugin.mjs, the
signing tool, which runs under Node and has to name process and Buffer to do
its job, and test files (*.test.*, *.spec.*), since a fake of ctx.http has to write
fetch. The first two are exempt by exact path.
Importing an exempt file would pull it into the bundle unscanned, which is the whole thing the
exemption must not allow. So a source file whose import specifier names one is refused (in any
letter case; naming one in a comment is fine), any .json file that names a tooling config or
the signer is refused (an imports alias in package.json, a paths entry in a tsconfig), and
the build refuses a bundle that has a test file among its inputs.
Dependencies
The bridge runs no package manager. Anything you import must be committed into the repository,
and it is scanned like your own code and counts against the 200-file and 2 MiB limits. A library
that touches window. will be refused. Keep dependencies small and vendored, or do without.