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