From 53d6a2f8b40c1342fc5b0184bbbf330270b4c3c3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 22 Jun 2026 18:16:27 -0400 Subject: [PATCH] fix(napplets/nsites): render real SPAs in the sandbox (blank-page + reload-loop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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://.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) --- .../composeResources/files/napplet/shell.html | 16 +++-- .../composeResources/files/napplet/shim.js | 43 +++++++++++++ .../commons/napplet/NappletWebContract.kt | 61 +++++++++++++------ .../napplethost/NappletContentServer.kt | 28 ++++++--- .../napplethost/NappletHostActivity.kt | 34 +++++++++-- 5 files changed, 145 insertions(+), 37 deletions(-) diff --git a/commons/src/commonMain/composeResources/files/napplet/shell.html b/commons/src/commonMain/composeResources/files/napplet/shell.html index 58ef474a09..78e42c56c3 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shell.html +++ b/commons/src/commonMain/composeResources/files/napplet/shell.html @@ -1,9 +1,13 @@ @@ -15,7 +19,7 @@ - + diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commons/src/commonMain/composeResources/files/napplet/shim.js index 947703f855..3005893155 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shim.js +++ b/commons/src/commonMain/composeResources/files/napplet/shim.js @@ -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){ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt index 7b85ccdaea..74f960c331 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt @@ -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" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt index 322da8cd5b..6d445a07c2 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt @@ -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, diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index 5a3f85d50d..81a0ff9852 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -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() 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.