1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
|
package org.meshbay.client.shell
import android.content.Context
import android.webkit.WebResourceResponse
import androidx.webkit.WebViewAssetLoader
import java.io.File
/**
* Serves the packaged interface, and nothing else.
*
* The page's origin is `https://appassets.androidplatform.net` — a secure
* context, without which `crypto.subtle` does not exist — and every file comes
* out of the APK's `assets/ui/`, copied from the hub's static directory at build
* time (§8.3). The hub never becomes the document origin: that is the whole
* reason the application exists (T3).
*
* The stock asset handler sets no headers, so this one exists for three: the
* policy, sent as a header because a <meta> policy drops `frame-ancestors`;
* `nosniff`; and `no-store`, since every file is already local.
*/
class UiAssets(private val context: Context) : WebViewAssetLoader.PathHandler {
override fun handle(path: String): WebResourceResponse? {
// Asset paths are not a filesystem, but a `..` that reached
// AssetManager would still be a path the page chose; refuse it.
if (path.split('/').any { it == ".." || it == "." } || path.startsWith("/")) return notFound()
val asset = "ui/" + path.ifEmpty { "index.html" }
val stream = try { context.assets.open(asset) } catch (e: java.io.IOException) { return notFound() }
val headers = mapOf(
"Content-Security-Policy" to CSP,
"X-Content-Type-Options" to "nosniff",
"Cache-Control" to "no-store",
)
val type = contentType(asset)
val charset = if (type.startsWith("text/") || type == "application/json") "utf-8" else null
return WebResourceResponse(type, charset, 200, "OK", headers, stream)
}
private fun notFound() =
WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), "".byteInputStream())
companion object {
const val HOST = "appassets.androidplatform.net"
const val ORIGIN = "https://$HOST"
const val PREFIX = "/ui/"
const val START = "$ORIGIN${PREFIX}index.html"
// reCAPTCHA gates sign-up here as it does in a browser and on the
// desktop; these two hosts and no others.
private const val RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com"
/**
* meshbay-client/src/main.js's CSP, directive for directive
* (test_android_shell.py holds them together). `'wasm-unsafe-eval'` is
* the Argon2 that opens bundles: without it nobody reaches their keys.
*/
val CSP = listOf(
"default-src 'none'",
"script-src 'self' 'wasm-unsafe-eval' $RECAPTCHA_SRC",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: $RECAPTCHA_SRC",
"media-src 'self' blob:",
"font-src 'self'",
"connect-src 'self' $RECAPTCHA_SRC",
"worker-src 'self'",
"object-src blob:",
"frame-src blob: $RECAPTCHA_SRC",
"frame-ancestors 'none'",
"base-uri 'none'",
"form-action 'none'",
).joinToString("; ")
fun isRecaptcha(host: String?) = host == "www.google.com" || host == "www.gstatic.com"
fun contentType(name: String): String = when (File(name).extension.lowercase()) {
"html" -> "text/html"
"js", "mjs" -> "text/javascript"
"css" -> "text/css"
"json" -> "application/json"
"wasm" -> "application/wasm"
"svg" -> "image/svg+xml"
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
"ico" -> "image/x-icon"
"webp" -> "image/webp"
"woff2" -> "font/woff2"
"woff" -> "font/woff"
"txt" -> "text/plain"
"xml" -> "application/xml"
else -> "application/octet-stream"
}
}
}
|