How Instatic Handles Plugin Permissions and Network Access
TLDR: Instatic isolates each plugin in a QuickJS-WASM sandbox and enforces a fail-closed permission model defined in the plugin's manifest, requiring explicit grants for capabilities like network.outbound and validating every HTTP request against host allow-lists and blocked IP ranges to prevent SSRF attacks.
Instatic is a CMS that extends functionality through isolated plugins. According to the CoreBunch/Instatic source code, the platform implements a defense-in-depth strategy for plugin permissions and network access, combining manifest declarations with runtime validation and Content Security Policy enforcement.
Manifest-Driven Permission Grants
Instatic's permission system begins with the PluginManifest schema defined in src/core/plugins/manifest.ts. Each plugin declares its required capabilities in an optional grantedPermissions array within the manifest.
During installation, the UI collects the permissions a plugin requests and stores the granted subset in the database alongside the manifest. This persistence layer is implemented in src/core/persistence/cmsPlugins.ts:
// src/core/persistence/cmsPlugins.ts (excerpt)
if (grantedPermissions.length > 0) {
// Store the granted list alongside the manifest
formData.set('grantedPermissions', JSON.stringify(grantedPermissions));
}
await apiRequest('/api/cms/plugins', { method: 'POST', body: formData });
The granted permissions are stored with the plugin record, creating an audit trail of what the administrator explicitly approved.
Runtime Permission Enforcement
When core code performs actions on behalf of a plugin, it validates the granted permission set before execution. This check occurs in multiple subsystems.
Admin-Side Hook Validation
The admin interface guards plugin hook handlers in src/admin/plugin-host-hooks/index.ts. Before invoking a handler, the dispatcher verifies the specific permission exists in the context:
if (!ctx.grantedPermissions.includes(permission)) {
// Permission denied
}
Editor-Side Module Guards
Similarly, the editor loader checks permissions before allowing module registration or code editing capabilities. The src/core/plugins/editorPluginLoader.ts and src/core/plugins/moduleAdapter.ts files enforce these restrictions during plugin initialization:
// src/core/plugins/moduleAdapter.ts (excerpt)
if (!grantedPermissions.includes('frontend.assets')) {
throw new Error('Plugin lacks permission to load frontend assets');
}
Network Outbound Access Control
A plugin may request the network.outbound permission, but actual HTTP requests undergo two additional validation layers.
Host Allow-List Validation
The manifest must contain a networkAllowedHosts array specifying approved destinations. The host bridge in src/server/plugins/host/network.ts validates every request URL against this list using the hostMatchesAllowlist function, which supports wildcards like *.example.com but restricts matching to hostnames only (no ports, paths, or query strings).
The core validation routine assertOutboundAllowed (lines 26-58 of network.ts) parses the URL and enforces these constraints.
IP Address Blocking
To prevent Server-Side Request Forgery (SSRF) attacks, the system resolves the target host to IP addresses and blocks any address falling into private, loopback, link-local, or CGNAT ranges using the isBlockedAddress function. If the host resolves to a blocked range, the plugin receives an error before any network connection attempts.
The gated fetch implementation demonstrates this flow:
// server/plugins/host/network.ts (excerpt)
export async function performGatedFetch(
entry: HostPluginRecord,
urlString: string,
init: { method?: string; headers?: Record<string, string>; body?: string },
) {
// 1️⃣ Ensure the plugin has the network permission (asserted by caller)
// 2️⃣ Validate target against allow-list & blocked IP ranges
const parsed = await assertOutboundAllowed(entry.manifest, urlString, defaultResolveHost);
// 3️⃣ Execute the real fetch and serialize the response for the VM
const resp = await fetchImpl(parsed.toString(), { method: init.method, headers: init.headers });
return { status: resp.status, ok: resp.ok, headers: Object.fromEntries(resp.headers), body: await resp.text(), bodyEncoding: 'utf8' };
}
Content Security Policy Integration
Instatic extends network controls to the client side during publication. The system aggregates all networkAllowedHosts from enabled plugins and injects them into the Content Security Policy (CSP) connect-src directive.
This enforcement occurs in src/server/publish/frontendInjections.ts:
// src/server/publish/frontendInjections.ts (excerpt)
const networkAllowedHostsSet = new Set<string>();
for (const plugin of enabledPlugins) {
for (const host of plugin.manifest.networkAllowedHosts ?? []) {
if (host) networkAllowedHostsSet.add(host);
}
}
plan.networkAllowedHosts = [...networkAllowedHostsSet].sort();
addCspSources(csp, 'connect-src', ["'self'", ...toCspHostSources(plan.networkAllowedHosts)]);
This ensures that client-side code generated by plugins cannot bypass the same host restrictions enforced server-side.
Security-By-Design Principles
The permission model is deliberately fail-closed:
- If
networkAllowedHostsis empty or missing, outbound fetches are denied entirely. - The host-match logic rejects localhost and IP literals at the manifest validation stage.
- The address-blocking logic prevents SSRF attacks by refusing connections to internal networks.
Together, these layers guarantee that a plugin can only perform actions explicitly approved by both the manifest author and the site administrator.
Summary
- Manifest-based permissions: Instatic uses the
grantedPermissionsarray inPluginManifestto track approved capabilities. - Runtime validation: Both admin hooks (
src/admin/plugin-host-hooks/index.ts) and editor modules (src/core/plugins/moduleAdapter.ts) verify permissions before execution. - Network gates: The
network.outboundpermission requires accompanyingnetworkAllowedHostsentries, validated byassertOutboundAllowedinsrc/server/plugins/host/network.ts. - SSRF protection: The
isBlockedAddressfunction blocks private and loopback IP ranges after DNS resolution. - CSP enforcement: Allowed hosts are aggregated at publish time and injected into the
connect-srcdirective viasrc/server/publish/frontendInjections.ts.
Frequently Asked Questions
What happens if a plugin requests network access but has no allowed hosts defined?
If the manifest's networkAllowedHosts array is empty or missing, the assertOutboundAllowed function denies all outbound requests, even if the network.outbound permission is granted. This fail-closed design prevents unrestricted internet access.
How does Instatic prevent plugins from accessing internal network resources?
The system resolves target hostnames to IP addresses and validates them against isBlockedAddress, which rejects private ranges (RFC 1918), loopback addresses (127.0.0.0/8), link-local addresses (169.254.0.0/16), and CGNAT ranges (100.64.0.0/10). This blocks SSRF attacks attempting to reach internal services.
Can plugins use wildcards in the network allow-list?
Yes, the hostMatchesAllowlist function supports wildcard patterns like *.example.com. However, matching applies only to the hostname component; ports, paths, and query parameters are not considered, and IP literals are rejected during manifest validation.
Where are granted permissions stored persistently?
The granted permission subset is stored alongside the plugin manifest in the database via src/core/persistence/cmsPlugins.ts. This storage captures the explicit approvals collected during the installation UI flow, ensuring permissions persist across server restarts and are available for runtime checks.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →