mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
Fixes #17168 Supersedes community PR #17220. Original contributor credit is preserved below. Co-authored-by: Arthur Chang <arthurc0102@gmail.com>
657 lines
32 KiB
Swift
657 lines
32 KiB
Swift
import Foundation
|
|
|
|
// Everything the shell decides before WebKit is involved: the session id it will accept, the
|
|
// requests it will answer, the map it builds from a manifest, and the policy header. Compiled and
|
|
// run without a device:
|
|
//
|
|
// swiftc -O -o /tmp/mobile-web-shell-checks \
|
|
// ios/MobileWebShellOrigin.swift ios/MobileWebShellGeneration.swift ios/MobileWebShellCsp.swift \
|
|
// ios/MobileWebShellLoadState.swift ios/MobileWebShellResponseHeaders.swift \
|
|
// ios/MobileWebShellBridge.swift ios/MobileWebShellAppliedProps.swift \
|
|
// ios/MobileWebShellNavigationPolicy.swift \
|
|
// tests/MobileWebShellChecks.swift && /tmp/mobile-web-shell-checks
|
|
@main struct MobileWebShellChecks {
|
|
static let session = "sess-01JN_aZ9"
|
|
|
|
static func parts(
|
|
path: String,
|
|
method: String = "GET",
|
|
hasRangeHeader: Bool = false,
|
|
scheme: String? = MobileWebShellOrigin.scheme,
|
|
host: String? = session,
|
|
port: Int? = nil,
|
|
user: String? = nil,
|
|
query: String? = nil,
|
|
fragment: String? = nil,
|
|
urlByteCount: Int = 64
|
|
) -> MobileWebShellRequestParts {
|
|
MobileWebShellRequestParts(
|
|
method: method,
|
|
hasRangeHeader: hasRangeHeader,
|
|
scheme: scheme,
|
|
host: host,
|
|
port: port,
|
|
user: user,
|
|
query: query,
|
|
fragment: fragment,
|
|
percentEncodedPath: path,
|
|
urlByteCount: urlByteCount
|
|
)
|
|
}
|
|
|
|
static func resolve(_ request: MobileWebShellRequestParts) -> String? {
|
|
MobileWebShellOrigin.resolveRequestPath(request, sessionId: session)
|
|
}
|
|
|
|
static func manifest(
|
|
schemaVersion: Int = 1,
|
|
entrypoint: String = "index.html",
|
|
assets: [[String: Any]] = [
|
|
["path": "index.html", "contentType": "text/html; charset=utf-8"],
|
|
["path": "assets/aa.js", "contentType": "text/javascript; charset=utf-8"],
|
|
["path": "assets/bb.png", "contentType": "image/png"]
|
|
]
|
|
) -> Data {
|
|
let root: [String: Any] = [
|
|
"schemaVersion": schemaVersion,
|
|
"entrypoint": entrypoint,
|
|
"assets": assets
|
|
]
|
|
return try! JSONSerialization.data(withJSONObject: root)
|
|
}
|
|
|
|
static func generation(_ data: Data) -> MobileWebShellGeneration? {
|
|
try? MobileWebShellGeneration.make(
|
|
manifestData: data,
|
|
directory: URL(fileURLWithPath: "/tmp/generation", isDirectory: true)
|
|
)
|
|
}
|
|
|
|
static func checkSessionIds() {
|
|
precondition(MobileWebShellOrigin.isValidSessionId("aZ0-_"))
|
|
precondition(MobileWebShellOrigin.isValidSessionId(String(repeating: "a", count: 128)))
|
|
precondition(!MobileWebShellOrigin.isValidSessionId(String(repeating: "a", count: 129)))
|
|
precondition(!MobileWebShellOrigin.isValidSessionId(""))
|
|
precondition(!MobileWebShellOrigin.isValidSessionId("has space"))
|
|
precondition(!MobileWebShellOrigin.isValidSessionId("dots.are.hosts.too"))
|
|
precondition(!MobileWebShellOrigin.isValidSessionId("sl/ash"))
|
|
// Non-ASCII letters and digits satisfy Character.isLetter/isNumber, so the ASCII gate is load
|
|
// bearing: an IDNA-mapped host would not be the origin we minted.
|
|
precondition(!MobileWebShellOrigin.isValidSessionId("sessioñ"))
|
|
precondition(!MobileWebShellOrigin.isValidSessionId("session٣"))
|
|
precondition(MobileWebShellOrigin.documentUrl(sessionId: session)?.absoluteString ==
|
|
"orca-mobile-web://\(session)/")
|
|
precondition(MobileWebShellOrigin.documentUrl(sessionId: "bad host") == nil)
|
|
}
|
|
|
|
static func checkRequestResolution() {
|
|
precondition(resolve(parts(path: "/")) == "/")
|
|
precondition(resolve(parts(path: "")) == "/")
|
|
precondition(resolve(parts(path: "/assets/aa.js")) == "/assets/aa.js")
|
|
// A host a parser canonicalised must still bind to this session.
|
|
precondition(resolve(parts(path: "/", host: session.uppercased())) == "/")
|
|
|
|
precondition(resolve(parts(path: "/", method: "POST")) == nil)
|
|
precondition(resolve(parts(path: "/", method: "HEAD")) == nil)
|
|
precondition(resolve(parts(path: "/", hasRangeHeader: true)) == nil)
|
|
precondition(resolve(parts(path: "/", scheme: "https")) == nil)
|
|
precondition(resolve(parts(path: "/", scheme: nil)) == nil)
|
|
// The same ASCII-only fold as the bridge: a Kelvin-sign host is a host nobody minted, and a
|
|
// caseInsensitiveCompare here would serve it every asset.
|
|
precondition(MobileWebShellOrigin.resolveRequestPath(
|
|
parts(path: "/", host: "\u{212A}ey"),
|
|
sessionId: "key"
|
|
) == nil)
|
|
precondition(MobileWebShellOrigin.resolveRequestPath(
|
|
parts(path: "/", host: "KEY"),
|
|
sessionId: "key"
|
|
) == "/")
|
|
precondition(resolve(parts(path: "/", host: "other-session")) == nil)
|
|
precondition(resolve(parts(path: "/", host: nil)) == nil)
|
|
precondition(resolve(parts(path: "/", port: 443)) == nil)
|
|
precondition(resolve(parts(path: "/", user: "someone")) == nil)
|
|
precondition(resolve(parts(path: "/", query: "v=1")) == nil)
|
|
precondition(resolve(parts(path: "/", fragment: "frag")) == nil)
|
|
precondition(resolve(parts(path: "/assets/%2e%2e/etc")) == nil)
|
|
precondition(resolve(parts(path: "assets/aa.js")) == nil)
|
|
precondition(resolve(parts(path: "/", urlByteCount: 8 * 1024)) == "/")
|
|
precondition(resolve(parts(path: "/", urlByteCount: 8 * 1024 + 1)) == nil)
|
|
precondition(MobileWebShellOrigin.resolveRequestPath(parts(path: "/"), sessionId: "") == nil)
|
|
}
|
|
|
|
static func checkAssetPaths() {
|
|
precondition(MobileWebShellGeneration.isServableAssetPath("index.html"))
|
|
precondition(MobileWebShellGeneration.isServableAssetPath("assets/a-b_c.2.js"))
|
|
precondition(!MobileWebShellGeneration.isServableAssetPath(""))
|
|
precondition(!MobileWebShellGeneration.isServableAssetPath("/leading"))
|
|
precondition(!MobileWebShellGeneration.isServableAssetPath("trailing/"))
|
|
precondition(!MobileWebShellGeneration.isServableAssetPath("a//b"))
|
|
precondition(!MobileWebShellGeneration.isServableAssetPath("../secret"))
|
|
precondition(!MobileWebShellGeneration.isServableAssetPath("assets/../../secret"))
|
|
precondition(!MobileWebShellGeneration.isServableAssetPath("assets/./a.js"))
|
|
precondition(!MobileWebShellGeneration.isServableAssetPath("back\\slash"))
|
|
precondition(!MobileWebShellGeneration.isServableAssetPath("has space.js"))
|
|
precondition(MobileWebShellGeneration.isServableAssetPath(String(repeating: "a", count: 255)))
|
|
precondition(!MobileWebShellGeneration.isServableAssetPath(String(repeating: "a", count: 256)))
|
|
}
|
|
|
|
static func checkContentTypes() {
|
|
precondition(MobileWebShellGeneration.isServableContentType("image/png"))
|
|
precondition(MobileWebShellGeneration.isServableContentType("text/html; charset=utf-8"))
|
|
precondition(MobileWebShellGeneration.isServableContentType("application/manifest+json"))
|
|
precondition(!MobileWebShellGeneration.isServableContentType(""))
|
|
precondition(!MobileWebShellGeneration.isServableContentType("text/html"
|
|
+ "\r\nX-Injected: 1"))
|
|
precondition(!MobileWebShellGeneration.isServableContentType("text/html; charset=utf-8; x=1"))
|
|
precondition(!MobileWebShellGeneration.isServableContentType("TEXT/HTML"))
|
|
// A header value we did not mint character for character is a value we did not check.
|
|
precondition(!MobileWebShellGeneration.isServableContentType("text/html; charset=UTF-8"))
|
|
precondition(!MobileWebShellGeneration.isServableContentType("text"))
|
|
precondition(!MobileWebShellGeneration.isServableContentType("text/html/extra"))
|
|
precondition(!MobileWebShellGeneration.isServableContentType("/html"))
|
|
precondition(!MobileWebShellGeneration.isServableContentType("-text/html"))
|
|
precondition(!MobileWebShellGeneration.isServableContentType("text/html; charset="))
|
|
precondition(!MobileWebShellGeneration.isServableContentType(
|
|
String(repeating: "a", count: 130) + "/b"))
|
|
}
|
|
|
|
static func checkGenerationMap() {
|
|
guard let built = generation(manifest()) else { preconditionFailure("manifest rejected") }
|
|
precondition(built.entries.count == 4)
|
|
precondition(built.entries["/"]?.file.path == "/tmp/generation/index.html")
|
|
precondition(built.entries["/"]?.contentType == "text/html; charset=utf-8")
|
|
// Only "/" reaches the document: a second URL for the same bytes would answer without the CSP
|
|
// header, which rides the document response alone.
|
|
precondition(built.entries["/index.html"] == nil)
|
|
precondition(built.entries["/assets/aa.js"]?.contentType == "text/javascript; charset=utf-8")
|
|
precondition(built.entries["/assets/bb.png"]?.file.path == "/tmp/generation/assets/bb.png")
|
|
precondition(built.entries["/manifest.json"]?.contentType == "application/json")
|
|
precondition(built.entries["/assets/cc.js"] == nil)
|
|
precondition(built.entries["/../secret"] == nil)
|
|
|
|
precondition(generation(manifest(schemaVersion: 2)) == nil)
|
|
precondition(generation(manifest(entrypoint: "start.html")) == nil)
|
|
precondition(generation(manifest(assets: [])) == nil)
|
|
// The entrypoint must be one of the assets, or "/" would map to a file nobody declared.
|
|
precondition(generation(manifest(assets: [
|
|
["path": "assets/aa.js", "contentType": "text/javascript; charset=utf-8"]
|
|
])) == nil)
|
|
precondition(generation(manifest(assets: [
|
|
["path": "index.html", "contentType": "text/html; charset=utf-8"],
|
|
["path": "../escape.js", "contentType": "text/javascript; charset=utf-8"]
|
|
])) == nil)
|
|
precondition(generation(manifest(assets: [
|
|
["path": "index.html", "contentType": "text/html; charset=utf-8"],
|
|
["path": "assets/aa.js", "contentType": "text/javascript\r\nX-Injected: 1"]
|
|
])) == nil)
|
|
precondition(generation(manifest(assets: [
|
|
["path": "index.html", "contentType": "text/html; charset=utf-8"],
|
|
["path": 7, "contentType": "text/javascript; charset=utf-8"]
|
|
])) == nil)
|
|
let tooMany = (0..<257).map { index in
|
|
["path": "assets/a\(index).js", "contentType": "text/javascript; charset=utf-8"]
|
|
}
|
|
precondition(generation(manifest(assets: tooMany)) == nil)
|
|
// A JSON string is not a JSON number, and true and 1.0 are not the integer 1, though NSNumber
|
|
// bridges all three to something `as? Int` accepts.
|
|
precondition(generation(Data(#"{"schemaVersion":true,"entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) == nil)
|
|
precondition(generation(Data(#"{"schemaVersion":1.0,"entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) == nil)
|
|
precondition(generation(Data(#"{"schemaVersion":1,"entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) != nil)
|
|
precondition(generation(Data(#"{"schemaVersion":"1","entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) == nil)
|
|
precondition(generation(Data("not json".utf8)) == nil)
|
|
precondition(generation(Data("[]".utf8)) == nil)
|
|
}
|
|
|
|
static func checkCsp() {
|
|
let header = MobileWebShellCsp.header
|
|
let directives = header.components(separatedBy: "; ")
|
|
precondition(directives.contains("default-src 'none'"))
|
|
precondition(directives.contains("script-src 'self'"))
|
|
// React Native Web injects runtime styles with no nonce; see MobileWebShellCsp.
|
|
precondition(directives.contains("style-src 'self' 'unsafe-inline'"))
|
|
// A file preview is a `data:` URI; `https:` is the favicon, project icon and avatar the
|
|
// page already renders, and the sealed preview frame. See MobileWebShellCsp.
|
|
precondition(directives.contains("img-src 'self' data: blob: https:"))
|
|
precondition(directives.contains("connect-src 'self'"))
|
|
precondition(directives.contains("worker-src 'none'"))
|
|
precondition(directives.contains("frame-src 'none'"))
|
|
precondition(directives.contains("base-uri about:"))
|
|
precondition(directives.contains("form-action 'none'"))
|
|
precondition(directives.contains("frame-ancestors 'none'"))
|
|
// 'unsafe-inline' is granted to style-src and to nothing else: the page's code still has to
|
|
// arrive as a fetched same-origin script, which is the directive that matters.
|
|
precondition(directives.filter { $0.contains("unsafe-inline") } == ["style-src 'self' 'unsafe-inline'"])
|
|
precondition(!header.contains("unsafe-eval"))
|
|
// Only images and bundled fonts can use embedded data URLs.
|
|
precondition(directives.filter { $0.contains("data:") } == ["img-src 'self' data: blob: https:", "font-src 'self' data:"])
|
|
precondition(directives.filter { $0.contains("blob:") } == ["img-src 'self' data: blob: https:", "media-src blob:"])
|
|
// Same shape for `https:`: images and nothing else. `http:` is not a substring of `https:`, so
|
|
// this still refuses a cleartext source anywhere in the header.
|
|
precondition(directives.filter { $0.contains("https:") } == ["img-src 'self' data: blob: https:"])
|
|
precondition(!header.contains("http:"))
|
|
precondition(!header.contains("\r") && !header.contains("\n"))
|
|
}
|
|
|
|
static func checkLoadStateMachine() {
|
|
precondition(MobileWebShellFailureReason.generationUnreadable.rawValue == "generation-unreadable")
|
|
precondition(MobileWebShellFailureReason.isolationUnavailable.rawValue == "isolation-unavailable")
|
|
precondition(MobileWebShellFailureReason.documentLoadFailed.rawValue == "document-load-failed")
|
|
precondition(MobileWebShellFailureReason.renderProcessGone.rawValue == "render-process-gone")
|
|
|
|
// The own-load flag's whole lifetime, which is what decides whether a navigation to the document
|
|
// may be allowed. Raised only by the view's own `load`, and dropped by anything that ends the
|
|
// document -- a commit, a failure, a dead renderer, a prop update that never loaded.
|
|
let ownLoad = MobileWebShellLoadStateMachine()
|
|
precondition(!ownLoad.isShellLoad)
|
|
ownLoad.shellLoadStarted()
|
|
precondition(ownLoad.isShellLoad)
|
|
ownLoad.committed()
|
|
precondition(!ownLoad.isShellLoad)
|
|
ownLoad.shellLoadStarted()
|
|
_ = ownLoad.failed(.documentLoadFailed)
|
|
precondition(!ownLoad.isShellLoad)
|
|
ownLoad.reset()
|
|
ownLoad.shellLoadStarted()
|
|
ownLoad.documentEnded()
|
|
precondition(!ownLoad.isShellLoad)
|
|
|
|
let progress = MobileWebShellLoadStateMachine()
|
|
precondition(progress.started()?.state == "loading")
|
|
precondition(progress.started() == nil)
|
|
progress.committed()
|
|
precondition(progress.finished()?.state == "ready")
|
|
precondition(progress.finished() == nil)
|
|
|
|
// The document's path is not an input here, and that is the point: the page rewrites its own
|
|
// with history.replaceState before its first render, so `didFinish` arrives at a URL no policy
|
|
// would allow. What is asked instead is whether this load committed.
|
|
let unseated = MobileWebShellLoadStateMachine()
|
|
_ = unseated.started()
|
|
precondition(unseated.finished() == nil)
|
|
unseated.committed()
|
|
precondition(unseated.finished()?.state == "ready")
|
|
|
|
// A document replaced mid-load: the finish belongs to the one that is already gone.
|
|
let replaced = MobileWebShellLoadStateMachine()
|
|
replaced.committed()
|
|
replaced.documentEnded()
|
|
precondition(replaced.finished() == nil)
|
|
|
|
// A rule list compiles asynchronously, so it can fail after the generation was already refused.
|
|
let refused = MobileWebShellLoadStateMachine()
|
|
precondition(refused.failed(.generationUnreadable)?.reason == "generation-unreadable")
|
|
precondition(refused.failed(.isolationUnavailable) == nil)
|
|
precondition(refused.failed(.renderProcessGone) == nil)
|
|
precondition(refused.finished() == nil)
|
|
precondition(refused.started() == nil)
|
|
|
|
refused.reset()
|
|
precondition(refused.failed(.generationUnreadable)?.reason == "generation-unreadable")
|
|
|
|
// A document is heard only between its own commit and the end of that load.
|
|
let arming = MobileWebShellLoadStateMachine()
|
|
precondition(!arming.hasCommittedDocument)
|
|
_ = arming.started()
|
|
// The previous document is alive and same-origin until the next one commits.
|
|
precondition(!arming.hasCommittedDocument)
|
|
arming.committed()
|
|
precondition(arming.hasCommittedDocument)
|
|
|
|
// A new prop triple: the committed document is the one being replaced.
|
|
arming.reset()
|
|
precondition(!arming.hasCommittedDocument)
|
|
arming.committed()
|
|
arming.documentEnded()
|
|
precondition(!arming.hasCommittedDocument)
|
|
|
|
// A failure ends the document, and nothing after it re-arms: a retry is a remount.
|
|
arming.committed()
|
|
_ = arming.failed(.renderProcessGone)
|
|
precondition(!arming.hasCommittedDocument)
|
|
arming.committed()
|
|
precondition(!arming.hasCommittedDocument)
|
|
}
|
|
|
|
static func checkResponseHeaders() {
|
|
let document = MobileWebShellResponseHeaders.forPath(
|
|
"/",
|
|
contentType: "text/html; charset=utf-8",
|
|
byteCount: 12
|
|
)
|
|
precondition(document["Content-Security-Policy"] == MobileWebShellCsp.header)
|
|
precondition(document["Content-Type"] == "text/html; charset=utf-8")
|
|
precondition(document["Content-Length"] == "12")
|
|
precondition(document["Cache-Control"] == "no-store")
|
|
precondition(document["X-Content-Type-Options"] == "nosniff")
|
|
// The document origin is the session id, and `img-src https:` gives the page somewhere to send
|
|
// it. See MobileWebShellResponseHeaders.
|
|
precondition(document["Referrer-Policy"] == "no-referrer")
|
|
|
|
// The policy rides the document alone; on a subresource response it is inert.
|
|
for path in ["/index.html", "/assets/aa.js", "/manifest.json", "/assets/bb.png"] {
|
|
let headers = MobileWebShellResponseHeaders.forPath(
|
|
path,
|
|
contentType: "text/javascript; charset=utf-8",
|
|
byteCount: 0
|
|
)
|
|
precondition(headers["Content-Security-Policy"] == nil)
|
|
// Rides the document with the policy: the referrer of a request is decided by the document
|
|
// that made it, so on a subresource response this would govern nothing.
|
|
precondition(headers["Referrer-Policy"] == nil)
|
|
precondition(headers["Cache-Control"] == "no-store")
|
|
precondition(headers["X-Content-Type-Options"] == "nosniff")
|
|
}
|
|
}
|
|
|
|
static func checkNavigationErrors() {
|
|
let ignorable = MobileWebShellNavigationError.isIgnorable
|
|
// Our own stopLoading on a prop update, and every navigation the policy delegate refuses.
|
|
precondition(ignorable(NSURLErrorDomain, NSURLErrorCancelled))
|
|
precondition(ignorable("WebKitErrorDomain", 102))
|
|
// Anything else is the document failing to load, which is the caller's cue to redownload.
|
|
precondition(!ignorable(NSURLErrorDomain, NSURLErrorNetworkConnectionLost))
|
|
precondition(!ignorable(NSURLErrorDomain, NSURLErrorResourceUnavailable))
|
|
precondition(!ignorable("WebKitErrorDomain", 101))
|
|
precondition(!ignorable("WebKitErrorDomain", NSURLErrorCancelled))
|
|
// WKErrorDomain has no frame-load codes at all, so 102 there is some other error.
|
|
precondition(!ignorable("WKErrorDomain", 102))
|
|
precondition(!ignorable("SomeOtherDomain", 102))
|
|
}
|
|
|
|
static func bridgeSource(
|
|
isOurWebView: Bool = true,
|
|
isMainFrame: Bool = true,
|
|
hasCommittedDocument: Bool = true,
|
|
originProtocol: String = MobileWebShellOrigin.scheme,
|
|
originHost: String = session
|
|
) -> MobileWebShellBridgeSource {
|
|
MobileWebShellBridgeSource(
|
|
isOurWebView: isOurWebView,
|
|
isMainFrame: isMainFrame,
|
|
hasCommittedDocument: hasCommittedDocument,
|
|
originProtocol: originProtocol,
|
|
originHost: originHost
|
|
)
|
|
}
|
|
|
|
static func acceptsBridge(_ source: MobileWebShellBridgeSource) -> Bool {
|
|
MobileWebShellBridge.accepts(source, sessionId: session)
|
|
}
|
|
|
|
static func checkAppliedProps() {
|
|
func props(
|
|
directory: String = "/gen/aa",
|
|
session: String = session,
|
|
bridge: Bool = true
|
|
) -> MobileWebShellAppliedProps {
|
|
MobileWebShellAppliedProps(
|
|
generationDirectory: directory,
|
|
sessionId: session,
|
|
bridgeEnabled: bridge
|
|
)
|
|
}
|
|
|
|
precondition(props().matches(props()))
|
|
precondition(!props().matches(props(directory: "/gen/ab")))
|
|
precondition(!props().matches(props(session: "sess-01JN_aZ8")))
|
|
precondition(!props().matches(props(bridge: false)))
|
|
// A triple that could not be honoured is still applied: re-entry reads the props, never whether
|
|
// the install succeeded, so a corrupt generation reports its failure once rather than on every
|
|
// commit for the life of the mount.
|
|
precondition(props(directory: "/gen/corrupt").matches(props(directory: "/gen/corrupt")))
|
|
|
|
// A fourth prop that nobody compared is a prop that silently never reloads, so the record's
|
|
// shape is pinned here rather than left to whoever adds the field.
|
|
let fields = Mirror(reflecting: props()).children.compactMap(\.label).sorted()
|
|
precondition(fields == ["bridgeEnabled", "generationDirectory", "sessionId"])
|
|
}
|
|
|
|
static func checkBridgeAcceptance() {
|
|
precondition(acceptsBridge(bridgeSource()))
|
|
// Simulator-measured: WebKit reports the custom scheme's host ASCII-lowercased, so the session
|
|
// we minted never equals the host verbatim. Exact equality here refuses every message.
|
|
precondition(acceptsBridge(bridgeSource(originHost: "sess-01jn_az9")))
|
|
precondition(acceptsBridge(bridgeSource(originHost: "SESS-01JN_AZ9")))
|
|
|
|
// A frame we did not serve.
|
|
precondition(!acceptsBridge(bridgeSource(originHost: "sess-01JN_aZ8")))
|
|
precondition(!acceptsBridge(bridgeSource(originHost: "")))
|
|
precondition(!acceptsBridge(bridgeSource(originHost: "sess-01JN_aZ9.evil")))
|
|
// ASCII folding only: U+212A KELVIN SIGN lowercases to "k" under Unicode case folding, so a
|
|
// caseInsensitiveCompare would accept a host nobody minted.
|
|
precondition(!MobileWebShellBridge.accepts(
|
|
bridgeSource(originHost: "\u{212A}ey"),
|
|
sessionId: "key"
|
|
))
|
|
precondition(MobileWebShellOrigin.asciiLowercased("\u{212A}EY") == "\u{212A}ey")
|
|
|
|
// Another scheme reaching the same handler.
|
|
precondition(!acceptsBridge(bridgeSource(originProtocol: "https")))
|
|
precondition(!acceptsBridge(bridgeSource(originProtocol: "")))
|
|
precondition(!acceptsBridge(bridgeSource(originProtocol: "orca-mobile-web ")))
|
|
|
|
// A subframe, and a message routed to a WebView that is not ours.
|
|
precondition(!acceptsBridge(bridgeSource(isMainFrame: false)))
|
|
precondition(!acceptsBridge(bridgeSource(isOurWebView: false)))
|
|
|
|
// The document the current props replaced: same session, same origin, still alive between
|
|
// `stopLoading` and the next commit, speaking for a load already reported as `loading`.
|
|
precondition(!acceptsBridge(bridgeSource(hasCommittedDocument: false)))
|
|
|
|
// No applied session is not an empty one: nothing may be accepted before a load.
|
|
precondition(!MobileWebShellBridge.accepts(bridgeSource(originHost: ""), sessionId: ""))
|
|
precondition(!MobileWebShellBridge.accepts(bridgeSource(originHost: "a b"), sessionId: "a b"))
|
|
}
|
|
|
|
static func checkBridgePostTarget() {
|
|
func canPost(
|
|
_ host: String?,
|
|
_ sessionId: String = session,
|
|
committed: Bool = true
|
|
) -> Bool {
|
|
MobileWebShellBridge.canPost(
|
|
toFrameOriginHost: host,
|
|
sessionId: sessionId,
|
|
hasCommittedDocument: committed
|
|
)
|
|
}
|
|
|
|
precondition(canPost(session))
|
|
// The same ASCII fold as acceptance: WebKit reports the host lowercased.
|
|
precondition(canPost("sess-01jn_az9"))
|
|
|
|
// Nowhere to post, all four for the same reason: no frame has been accepted. A page that has
|
|
// never spoken, a document whose load failed, a renderer that died, a bridge not installed.
|
|
precondition(!canPost(nil))
|
|
|
|
// A frame from another document, and a frame under no session at all.
|
|
precondition(!canPost("sess-01JN_aZ8"))
|
|
precondition(!canPost("\u{212A}ey", "key"))
|
|
precondition(!canPost(session, ""))
|
|
precondition(!canPost("", ""))
|
|
|
|
// In flight: a navigation has started and not committed, so there is no document to post into
|
|
// even while a frame from the one being replaced is still held.
|
|
precondition(!canPost(session, committed: false))
|
|
}
|
|
|
|
/// The target across one document replacing another, in the order the navigation delegate runs:
|
|
/// a frame armed by document A is never what a post to document B goes to.
|
|
static func checkBridgeTargetLifecycle() {
|
|
func canPost(_ target: MobileWebShellBridgeTarget<String>, committed: Bool) -> Bool {
|
|
MobileWebShellBridge.canPost(
|
|
toFrameOriginHost: target.originHost,
|
|
sessionId: session,
|
|
hasCommittedDocument: committed
|
|
)
|
|
}
|
|
|
|
var target = MobileWebShellBridgeTarget<String>()
|
|
precondition(target.frame == nil && target.originHost == nil)
|
|
precondition(!canPost(target, committed: true))
|
|
|
|
// didCommit for document A, then A's first accepted message.
|
|
target.clear()
|
|
target.arm(frame: "frame-a", originHost: session)
|
|
precondition(target.frame == "frame-a")
|
|
precondition(canPost(target, committed: true))
|
|
|
|
// didStartProvisionalNavigation for document B. Refused twice over: nothing armed, and nothing
|
|
// committed to post into.
|
|
target.clear()
|
|
precondition(target.frame == nil)
|
|
precondition(!canPost(target, committed: false))
|
|
|
|
// didCommit for document B. Arming re-opens, so the clear has to happen here as well or A's
|
|
// frame becomes postable again as B's.
|
|
target.clear()
|
|
precondition(!canPost(target, committed: true))
|
|
|
|
// B speaks for itself, and that is the only way a post reaches it.
|
|
target.arm(frame: "frame-b", originHost: session)
|
|
precondition(target.frame == "frame-b")
|
|
precondition(canPost(target, committed: true))
|
|
}
|
|
|
|
static func checkBridgeByteCap() {
|
|
let cap = MobileWebShellBridge.maxMessageByteCount
|
|
precondition(cap == 640 * 1024)
|
|
precondition(MobileWebShellBridge.acceptsByteCount(0))
|
|
precondition(MobileWebShellBridge.acceptsByteCount(cap - 1))
|
|
precondition(MobileWebShellBridge.acceptsByteCount(cap))
|
|
precondition(!MobileWebShellBridge.acceptsByteCount(cap + 1))
|
|
|
|
// The cap is on UTF-8 bytes, not characters: a multi-byte payload must not buy extra room.
|
|
let wide = String(repeating: "\u{1F600}", count: 4)
|
|
precondition(wide.count == 4 && wide.utf8.count == 16)
|
|
|
|
let gate = MobileWebShellBridgeGate()
|
|
precondition(gate.refusedCount == 0)
|
|
precondition(gate.accepts(byteCount: cap))
|
|
precondition(gate.refusedCount == 0)
|
|
precondition(!gate.accepts(byteCount: cap + 1))
|
|
precondition(!gate.accepts(byteCount: cap * 2))
|
|
precondition(gate.refusedCount == 2)
|
|
}
|
|
|
|
/// The whole navigation decision, which is one function so the allow half and the offer half
|
|
/// cannot drift. The rule is the frame and the gesture, not the scheme: TypeScript's
|
|
/// `readBridgeExternalLinkUrl` owns which URLs open, and a second scheme list here would be two
|
|
/// rules that drift.
|
|
static func checkNavigationVerdict() {
|
|
let foreign = "https://example.com/artifact-link"
|
|
let document = "orca-mobile-web://\(session)/"
|
|
func verdict(
|
|
_ url: String? = "https://example.com/artifact-link",
|
|
isMainFrame: Bool = true,
|
|
isDocumentUrl: Bool = false,
|
|
isShellLoad: Bool = false,
|
|
hasGesture: Bool = true,
|
|
isDownload: Bool = false
|
|
) -> MobileWebShellNavigationVerdict {
|
|
MobileWebShellNavigationPolicy.verdict(
|
|
url: url,
|
|
isMainFrame: isMainFrame,
|
|
isDocumentUrl: isDocumentUrl,
|
|
isShellLoad: isShellLoad,
|
|
hasGesture: hasGesture,
|
|
isDownload: isDownload
|
|
)
|
|
}
|
|
precondition(verdict() == .cancelAndOffer(foreign))
|
|
// The shell's own load, which is the only navigation to the document this view ever performs.
|
|
// Measured on WebKit: `webView.load` arrives with target and source both the main frame.
|
|
precondition(verdict(document, isDocumentUrl: true, isShellLoad: true, hasGesture: false) == .allow)
|
|
// Everything else that names the document is refused, whatever the host says about a gesture,
|
|
// and is never offered -- handing the shell's own URL to the opener would bounce the user out.
|
|
// The host is not trusted to report the gesture: measured on WebKit, a sandboxed subframe
|
|
// navigating the top frame to the document URL arrives with no gesture at all.
|
|
precondition(verdict(document, isDocumentUrl: true, hasGesture: false) == .cancel)
|
|
precondition(verdict(document, isDocumentUrl: true, hasGesture: true) == .cancel)
|
|
// A load the shell did not start is refused whichever frame initiated it: the page rewriting
|
|
// its own document away, or the sealed preview navigating the top frame.
|
|
precondition(verdict(document, isDocumentUrl: true, isShellLoad: false, hasGesture: false) == .cancel)
|
|
// A top-page meta refresh or a redirect to somewhere else: refused, and never opened.
|
|
precondition(verdict(hasGesture: false) == .cancel)
|
|
// A tap inside the sealed preview is exactly a subframe-initiated foreign navigation, and that
|
|
// is the one thing the artifact is allowed to ask for.
|
|
precondition(verdict() == .cancelAndOffer(foreign))
|
|
// A download is not a document load, so it is refused there rather than allowed; started by a
|
|
// tap it reaches the opener, which is what makes `<a download>` behave as it does natively.
|
|
precondition(
|
|
verdict(document, isDocumentUrl: true, isShellLoad: true, hasGesture: false, isDownload: true)
|
|
== .cancel
|
|
)
|
|
precondition(verdict(isDownload: true) == .cancelAndOffer(foreign))
|
|
// `<a href="/" download>`: a download that still names the shell's own document, which is the
|
|
// one thing never handed to the opener. Refused, gesture or not.
|
|
precondition(verdict(document, isDocumentUrl: true, isDownload: true) == .cancel)
|
|
// A subframe is the sealed preview loading itself, which is not the user leaving the app.
|
|
precondition(verdict(isMainFrame: false) == .cancel)
|
|
precondition(verdict(isMainFrame: false, hasGesture: false) == .cancel)
|
|
precondition(verdict(nil) == .cancel)
|
|
precondition(verdict("") == .cancel)
|
|
// The crossing cap, at it and one past it.
|
|
let cap = MobileWebShellNavigationPolicy.maxCancelledNavigationUrlCharacters
|
|
let prefix = "https://example.com/"
|
|
let atCap = prefix + String(repeating: "a", count: cap - prefix.count)
|
|
precondition(atCap.count == cap)
|
|
precondition(MobileWebShellNavigationPolicy.offerableUrl(atCap) == atCap)
|
|
precondition(MobileWebShellNavigationPolicy.offerableUrl(atCap + "a") == nil)
|
|
precondition(verdict(atCap + "a") == .cancel)
|
|
// A scheme the opener will refuse still crosses: one filter, in the half that updates.
|
|
precondition(verdict("javascript:alert(1)") == .cancelAndOffer("javascript:alert(1)"))
|
|
}
|
|
|
|
/// The own-load flag against the policy that reads it: one load allowed, and only one.
|
|
///
|
|
/// The flag and the rule are separate types, and the gap between them is where a second main-frame
|
|
/// action to the same URL before the first commits would have been allowed too. So the seam is
|
|
/// checked rather than each half on its own.
|
|
static func checkOwnLoadIsSpentOnce() {
|
|
let document = "orca-mobile-web://\(session)/"
|
|
func decide(_ machine: MobileWebShellLoadStateMachine) -> MobileWebShellNavigationVerdict {
|
|
MobileWebShellNavigationPolicy.verdict(
|
|
url: document,
|
|
isMainFrame: true,
|
|
isDocumentUrl: true,
|
|
isShellLoad: machine.isShellLoad,
|
|
hasGesture: false,
|
|
isDownload: false
|
|
)
|
|
}
|
|
let machine = MobileWebShellLoadStateMachine()
|
|
machine.shellLoadStarted()
|
|
let first = decide(machine)
|
|
precondition(first == .allow)
|
|
// Spent by the allow itself, not by the commit that follows it: WebKit can decide a second action
|
|
// before the first one starts, and that one would have replaced the document.
|
|
machine.shellLoadConsumed()
|
|
precondition(decide(machine) == .cancel)
|
|
// And the endings still drop it, for a load that is allowed and then never commits.
|
|
machine.shellLoadStarted()
|
|
machine.documentEnded()
|
|
precondition(decide(machine) == .cancel)
|
|
}
|
|
|
|
static func main() {
|
|
checkSessionIds()
|
|
checkRequestResolution()
|
|
checkAssetPaths()
|
|
checkContentTypes()
|
|
checkGenerationMap()
|
|
checkCsp()
|
|
checkLoadStateMachine()
|
|
checkResponseHeaders()
|
|
checkNavigationErrors()
|
|
checkNavigationVerdict()
|
|
checkOwnLoadIsSpentOnce()
|
|
checkAppliedProps()
|
|
checkBridgeAcceptance()
|
|
checkBridgePostTarget()
|
|
checkBridgeTargetLifecycle()
|
|
checkBridgeByteCap()
|
|
print("mobile web shell checks OK")
|
|
}
|
|
}
|