fix(napplets/nsites): render real SPAs in the sandbox (blank-page + reload-loop)

Static-site / SPA nApplets & nSites (Vite/nsyte/CRA/webpack output) rendered as a
blank page, then — once that was fixed — as a fast reload-loop blink. Three layered
causes, each found on-device via logcat:

1. Sub-path serving. The applet loaded under https://napplet.local/app/, but bundlers
   emit absolute asset URLs (/assets/app.js, /fonts/…) that resolve against the origin
   ROOT, so every script/style/font 404'd. nSites are defined to be hosted at the domain
   root; serve there.

2. Opaque-origin storage. The applet ran in an `allow-scripts`-only iframe, so its origin
   was opaque ("null"): module scripts + asset fetches were CORS-blocked, and reading
   localStorage/IndexedDB/serviceWorker threw SecurityError — which crash-loops every SPA
   (gruuv: "cache version 0 < 23 → reset → reload", forever, because IndexedDB never
   worked so the version never persisted).

Fix: give each applet its OWN real, persistent, isolated origin — a per-applet subdomain
https://<id>.napplet.local (id = sha256(author:identifier)), framed by the shell with
`allow-scripts allow-same-origin`. A real origin restores localStorage/IndexedDB/SW and
makes the applet's own assets same-origin (no CORS). Isolation is preserved because the
origin is DISTINCT from the shell's: the native bridge stays origin-restricted to the
shell (napplet.local), so the cross-origin applet still can't reach it or read the shell
DOM, and per-applet subdomains keep applets' storage isolated from each other. The shell
HTML's iframe src + CSP frame-src are bound to the specific applet origin at serve time.

Also add an in-memory localStorage/sessionStorage polyfill to the injected shim as
belt-and-suspenders for any context where DOM storage is still unavailable.

By-design sandbox enforcement is unchanged and correctly blocks the rest (external CDN
scripts, direct relay WebSockets via connect-src 'none', external images) — apps must go
through the napplet SDK for those.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-22 18:18:56 -04:00
co-authored by Claude Opus 4.8
parent 176c96ef01
commit 53d6a2f8b4
5 changed files with 145 additions and 37 deletions
@@ -1,9 +1,13 @@
<!doctype html>
<!--
Trusted napplet shell page (served from app assets at https://napplet.local/__shell__).
It hosts the untrusted applet in an opaque-origin sandboxed iframe and relays capability
messages between the applet (window.postMessage) and the native bridge (__nappletBridge),
which is origin-restricted to this page only. The applet iframe can never reach the bridge.
Trusted napplet shell page (served on the shell origin https://napplet.local at /__shell__).
It hosts the untrusted applet in a sandboxed iframe pointed at the applet's OWN per-applet origin
(a distinct napplet.local subdomain, injected as __APP_ORIGIN__) and relays capability messages
between the applet (window.postMessage) and the native bridge (__nappletBridge), which is
origin-restricted to this shell page only. The applet is cross-origin to the shell, so it can never
reach the bridge or read the shell DOM. The iframe carries allow-same-origin so the applet has real,
isolated storage on its own origin — which is safe precisely because that origin is never the
shell's: the applet is same-origin only with itself.
-->
<html>
<head>
@@ -15,7 +19,7 @@
</style>
</head>
<body>
<iframe id="app" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe>
<iframe id="app" sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer"></iframe>
<script>
(function () {
var iframe = document.getElementById('app');
@@ -70,7 +74,7 @@
};
}
iframe.src = 'https://napplet.local/app/';
iframe.src = '__APP_ORIGIN__/';
})();
</script>
</body>
@@ -3,6 +3,49 @@
// This is loaded as an asset and injected into the applet document by NappletHostActivity.
(function(){
if (window.__nappletShimInstalled) return; window.__nappletShimInstalled = true;
// Web Storage polyfill. The applet runs in an `allow-scripts` (no `allow-same-origin`) iframe, so
// its origin is opaque and reading `window.localStorage`/`sessionStorage` throws a SecurityError —
// which aborts the bootstrap of essentially every bundler-built SPA (they read storage at init) and
// leaves a blank page. We can't grant `allow-same-origin` (that would hand the applet the
// napplet.local origin the native bridge trusts — a sandbox escape), so we shadow the throwing
// native accessor with a synchronous in-memory Storage. It is per-launch (not persisted); durable
// storage is available separately and asynchronously via `window.napplet.storage.*`. This inline
// shim runs before the applet's deferred module script, so the polyfill is in place first.
(function(){
function makeStorage(){
var data = Object.create(null);
var methods = {
getItem: function(k){ k = String(k); return Object.prototype.hasOwnProperty.call(data, k) ? data[k] : null; },
setItem: function(k, v){ data[String(k)] = String(v); },
removeItem: function(k){ delete data[String(k)]; },
clear: function(){ data = Object.create(null); },
key: function(i){ var ks = Object.keys(data); i = i >>> 0; return i < ks.length ? ks[i] : null; }
};
return new Proxy(methods, {
get: function(t, p){
if (p === 'length') return Object.keys(data).length;
if (typeof p !== 'string' || p in t) return t[p];
return Object.prototype.hasOwnProperty.call(data, p) ? data[p] : undefined;
},
set: function(t, p, v){ if (p in t) { t[p] = v; } else { data[String(p)] = String(v); } return true; },
has: function(t, p){ return (p in t) || (p === 'length') || Object.prototype.hasOwnProperty.call(data, p); },
deleteProperty: function(t, p){ delete data[p]; return true; },
ownKeys: function(){ return Object.keys(data); },
getOwnPropertyDescriptor: function(t, p){
if (Object.prototype.hasOwnProperty.call(data, p)) return { value: data[p], writable: true, enumerable: true, configurable: true };
return undefined;
}
});
}
function install(name){
try { if (window[name]) return; } catch (_) { /* native getter threw — install the polyfill */ }
try { Object.defineProperty(window, name, { value: makeStorage(), configurable: true, enumerable: true, writable: false }); } catch (_) {}
}
install('localStorage');
install('sessionStorage');
})();
var seq = 0, pending = {}, subs = {}, actions = {}, identityHandlers = [];
function send(env){ env.id = env.id || ('r' + (seq++)); parent.postMessage(JSON.stringify(env), '*'); return env.id; }
function call(type, fields){
@@ -33,39 +33,66 @@ import org.jetbrains.compose.resources.ExperimentalResourceApi
* [Res] accessor; the constants below are duplicated nowhere else.
*/
object NappletWebContract {
/** Internal host the sandbox is served from (an opaque sandboxed origin, never the user's keys). */
/** Internal host the trusted **shell** is served from (never the user's keys). */
const val HOST = "napplet.local"
const val ORIGIN = "https://napplet.local"
/** The trusted shell document. */
/** The trusted shell document (top frame, on the shell [ORIGIN], where the native bridge lives). */
const val SHELL_URL = "$ORIGIN/__shell__"
/** Base path the applet's own (verified) blobs are served under. */
const val APP_BASE = "$ORIGIN/app/"
/**
* The applet runs on its **own per-applet origin** — a unique subdomain of [HOST] — served at the
* origin root, NOT on the shell [ORIGIN]. Two reasons, both load-bearing:
*
* 1. **A real (non-opaque) origin is what gives the applet working, persistent storage.** An
* `allow-scripts`-only opaque-origin iframe has no `localStorage`/`IndexedDB`/service worker
* (reads throw `SecurityError`), which crash-loops essentially every SPA. A real origin with
* `allow-same-origin` has them, scoped and isolated per applet (subdomains don't share
* storage), so applets can't read each other's data.
* 2. **Keeping it on a DISTINCT origin from the shell is what preserves the trust boundary.** The
* native bridge is origin-restricted to the shell [ORIGIN]; the applet, being cross-origin,
* still can't reach it (nor read the shell DOM) — it talks only via `postMessage`, which the
* shell relays. `allow-same-origin` is therefore safe here precisely because the applet is
* same-origin only with *itself*, never with the shell.
*
* The applet is served at its origin root because SPA bundlers (Vite, CRA, webpack, nsyte, …) emit
* **absolute** asset URLs (`/assets/app.js`, `/fonts/x.woff2`) that resolve against the origin root.
*
* [appId] must be a stable, unique, DNS-label-safe token per applet (the host derives it from the
* applet's author + identifier), so the same applet keeps its storage across launches.
*/
fun appOrigin(appId: String): String = "https://$appId.$HOST"
/** True for the shell host and any per-applet subdomain — i.e. everything we serve internally. */
fun isInternalHost(host: String?): Boolean = host == HOST || (host != null && host.endsWith(".$HOST"))
/** Placeholder in [SHELL_HTML_PATH] the host replaces with the per-applet [appOrigin] before serving. */
const val APP_ORIGIN_PLACEHOLDER = "__APP_ORIGIN__"
/** Name of the origin-restricted native bridge the shell (and only the shell) can reach. */
const val BRIDGE_NAME = "__nappletBridge"
/**
* CSP for the shell document: it may inline its own bridge script/style and frame the applet, but
* has no network and cannot navigate or submit anywhere.
* CSP for the shell document: it may inline its own bridge script/style and frame **only this
* applet's** origin, but has no network and cannot navigate or submit anywhere.
*/
const val SHELL_CSP: String =
fun shellCsp(appOrigin: String): String =
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
"frame-src https://napplet.local; base-uri 'none'; form-action 'none'"
"frame-src $appOrigin; base-uri 'none'; form-action 'none'"
/**
* CSP for the applet document. `'self'` does not match an opaque (sandboxed) origin, so the host
* is listed explicitly. The key lever is `connect-src 'none'`: the applet gets **no** direct
* network — every fetch goes through the brokered, consent-gated `resource.bytes`.
* CSP for the applet document. The applet has a real origin now, so `'self'` resolves to its own
* per-applet origin and the shell origin is deliberately NOT granted. The key lever is
* `connect-src 'none'`: the applet gets **no** direct network — every fetch goes through the
* brokered, consent-gated `resource.bytes`.
*/
const val APP_CSP: String =
"default-src 'self' https://napplet.local; " +
"script-src 'self' https://napplet.local 'unsafe-inline'; " +
"style-src 'self' https://napplet.local 'unsafe-inline'; " +
"img-src 'self' https://napplet.local data: blob:; " +
"font-src 'self' https://napplet.local data:; " +
"media-src 'self' https://napplet.local blob: data:; " +
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: blob:; " +
"font-src 'self' data:; " +
"media-src 'self' blob: data:; " +
"connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'"
const val SHELL_HTML_PATH = "files/napplet/shell.html"
@@ -54,6 +54,9 @@ class NappletContentServer(
cacheDir: File,
private val shellHtmlBytes: ByteArray,
private val shimJs: String,
// The applet's own per-applet origin (a distinct napplet.local subdomain). The shell is on
// NappletWebContract.ORIGIN; app blobs are served here so the applet has a real, isolated origin.
private val appOrigin: String,
) {
private val cache = NappletBlobCache(NappletBlobCache.dirFor(cacheDir))
private val http = NappletBlobHttp.client(proxyPort)
@@ -88,16 +91,15 @@ class NappletContentServer(
fun resolve(requestPath: String): StaticSiteResolution = resolveCacheFirst(requestPath)
/**
* Serves the trusted shell or a verified app blob for a GET to our origin; 404s anything else on
* the origin, and returns null (defer to the WebView) for non-GET or off-origin requests.
* Serves the trusted shell (on the shell origin) or a verified app blob (on the per-applet
* [appOrigin]); 404s anything else, and returns null (defer to the WebView) for non-GET requests.
*/
fun serve(request: WebResourceRequest): WebResourceResponse? {
val url = request.url.toString()
if (!request.method.equals("GET", ignoreCase = true)) return null
if (!url.startsWith(NappletWebContract.ORIGIN)) return notFound()
if (url == NappletWebContract.SHELL_URL) return serveShell()
if (url == NappletWebContract.APP_BASE || url.startsWith(NappletWebContract.APP_BASE)) {
if (url == appOrigin || url.startsWith("$appOrigin/")) {
// A document navigation accepts text/html; a sub-resource (js/css/img) does not.
val acceptsHtml = request.requestHeaders["Accept"]?.contains("text/html", ignoreCase = true) == true
return serveAppResource(url, acceptsHtml)
@@ -105,15 +107,19 @@ class NappletContentServer(
return notFound()
}
private fun serveShell(): WebResourceResponse =
WebResourceResponse(
private fun serveShell(): WebResourceResponse {
// The shell HTML carries an APP_ORIGIN_PLACEHOLDER for the iframe src; bind it to this applet's
// origin so the shell frames exactly this applet (and the CSP frame-src is pinned to it too).
val html = shellHtmlBytes.decodeToString().replace(NappletWebContract.APP_ORIGIN_PLACEHOLDER, appOrigin).encodeToByteArray()
return WebResourceResponse(
"text/html",
"utf-8",
200,
"OK",
mapOf("Content-Security-Policy" to NappletWebContract.SHELL_CSP),
ByteArrayInputStream(shellHtmlBytes),
mapOf("Content-Security-Policy" to NappletWebContract.shellCsp(appOrigin)),
ByteArrayInputStream(html),
)
}
private fun serveAppResource(
url: String,
@@ -121,10 +127,10 @@ class NappletContentServer(
): WebResourceResponse {
val requestPath =
url
.removePrefix(NappletWebContract.APP_BASE)
.removePrefix(appOrigin)
.substringBefore('?')
.substringBefore('#')
.let { if (it.isEmpty()) "/" else "/$it" }
.ifEmpty { "/" }
var resolution = resolveCacheFirst(requestPath)
@@ -142,6 +148,8 @@ class NappletContentServer(
val isHtml = mime.equals("text/html", ignoreCase = true)
val bytes = if (isHtml) injectShim(resolution.bytes) else resolution.bytes
// No CORS header needed: the applet document and these blobs are now on the same (per-applet)
// origin, so its own module scripts / stylesheets / assets load as same-origin requests.
return WebResourceResponse(
mime,
charset,
@@ -60,8 +60,10 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities
import com.vitorpamplona.amethyst.napplethost.R
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -91,6 +93,11 @@ class NappletHostActivity : ComponentActivity() {
private val servers = mutableListOf<String>()
private var author: String = ""
// The applet's stable identifier (the manifest `d` tag; empty for a root/replaceable applet).
// Combined with [author] it gives the applet's per-launch-stable identity, used to derive its
// own sandbox origin so its storage persists across launches and stays isolated from other applets.
private var identifier: String = ""
// Opaque token the broker resolves to this launch's trusted identity + declared capabilities.
// The sandbox never carries its own coordinate, so a compromised :napplet process can't forge one.
private var launchToken: String = ""
@@ -164,7 +171,8 @@ class NappletHostActivity : ComponentActivity() {
fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() }
val shellHtml = readContractAsset(NappletWebContract.SHELL_HTML_PATH)
val shim = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString()
contentServer = NappletContentServer(paths, servers, proxyPort, cacheDir, shellHtml, shim)
val appOrigin = NappletWebContract.appOrigin(deriveAppId(author, identifier))
contentServer = NappletContentServer(paths, servers, proxyPort, cacheDir, shellHtml, shim, appOrigin)
// Create + warm the WebView NOW so its (slow, first-in-process) Chromium init runs on the main
// thread concurrently with the index probe below (which runs on IO) — instead of serially after
@@ -285,6 +293,7 @@ class NappletHostActivity : ComponentActivity() {
for (i in pathList.indices) paths.add(PathTag(pathList[i], hashList[i]))
servers.addAll(intent.getStringArrayListExtra(NappletHostContract.EXTRA_SERVERS) ?: emptyList())
author = intent.getStringExtra(NappletHostContract.EXTRA_AUTHOR).orEmpty()
identifier = intent.getStringExtra(NappletHostContract.EXTRA_IDENTIFIER).orEmpty()
title = intent.getStringExtra(NappletHostContract.EXTRA_TITLE).orEmpty()
proxyPort = intent.getIntExtra(NappletHostContract.EXTRA_PROXY_PORT, -1)
launchToken = intent.getStringExtra(NappletHostContract.EXTRA_LAUNCH_TOKEN).orEmpty()
@@ -299,13 +308,29 @@ class NappletHostActivity : ComponentActivity() {
return author.isNotEmpty() && launchToken.isNotEmpty()
}
/**
* Stable, unique, DNS-label-safe id for this applet's sandbox origin: a sha256 of
* `author:identifier`, hex, truncated to 31 chars and letter-prefixed (`n`). The leading letter
* avoids any numeric-host parsing quirk and keeps it ≤63 chars (a valid DNS label). The same
* applet always derives the same id, so its origin — and therefore its localStorage/IndexedDB —
* persists across launches; different applets derive different subdomains, so their storage is
* isolated from one another.
*/
private fun deriveAppId(
author: String,
identifier: String,
): String = "n" + sha256("$author:$identifier".encodeToByteArray()).toHexKey().take(31)
private var title: String = ""
@Suppress("SetJavaScriptEnabled")
private fun hardenWebView(webView: WebView) {
webView.settings.apply {
javaScriptEnabled = true // the applet needs JS; isolation comes from process + CSP + sandbox
domStorageEnabled = false
// DOM storage (localStorage/sessionStorage) is on because the applet runs on its OWN real,
// per-applet origin (a napplet.local subdomain) — storage is scoped to that origin, isolated
// from other applets and from the shell. SPAs need it at boot; without it they crash-loop.
domStorageEnabled = true
databaseEnabled = false
allowFileAccess = false
allowContentAccess = false
@@ -339,8 +364,9 @@ class NappletHostActivity : ComponentActivity() {
request: WebResourceRequest,
): Boolean {
val uri = request.url
// Internal origin: let the WebView load it (it goes through shouldInterceptRequest).
if (uri.host == NappletWebContract.HOST) return false
// Internal hosts (the shell host and this applet's own per-applet subdomain): let the
// WebView load them — they go through shouldInterceptRequest and are served from cache.
if (NappletWebContract.isInternalHost(uri.host)) return false
// An external link the user actually tapped is handed to the system browser. A user
// gesture is required so a hostile site can't auto-redirect to spam-open the browser,
// and only http(s) is honored so it can't fire arbitrary intent schemes.