mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
co-authored by
Claude Opus 4.8
parent
176c96ef01
commit
53d6a2f8b4
@@ -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){
|
||||
|
||||
+44
-17
@@ -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"
|
||||
|
||||
+18
-10
@@ -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,
|
||||
|
||||
+30
-4
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user