@livx.cc/appwrap 0.61.20 → 0.61.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/app/shell/bridge-origin.ts +39 -0
- package/runtime/app/shell/bridge.ts +9 -2
- package/runtime/app/shell/custom-webview.android.ts +4 -3
- package/runtime/app/shell/custom-webview.ios.ts +4 -2
- package/runtime/app/shell/custom-webview.ts +1 -1
- package/runtime/app/shell/handlers-webview.ts +2 -1
- package/runtime/tests/bridge-origin.test.ts +68 -0
- package/runtime/tests/bridge-response-delivery.test.ts +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { SHELL_CONFIG } from './config';
|
|
2
|
+
import { effectiveServerUrl } from './server-url';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bridge origin gate. The shell's WebView can show ANY page (a link, a redirect, a page the app opens), and
|
|
6
|
+
* every page in it can post to the `appwrap` channel — so without a gate a foreign page reads the app's
|
|
7
|
+
* keychain (`storage.secure`), files, cookies, contacts… The APP's own origin keeps the full bridge:
|
|
8
|
+
* the bundled `app://localhost`, the server loader's origin (incl. an allowlisted env override), and
|
|
9
|
+
* `appBoundDomains`. Any other origin (or an unknown one) gets only FOREIGN_ALLOWED: UI feedback and
|
|
10
|
+
* permission-prompted features that expose no stored app data.
|
|
11
|
+
*/
|
|
12
|
+
const FOREIGN_ALLOWED = [
|
|
13
|
+
'app.handshake', 'app.environment', 'device.info', 'network.status',
|
|
14
|
+
'haptics.', 'toast.', 'keyboard.', 'ui.', 'screen.', 'scanner.', 'push.', 'share.share',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** `scheme://host[:port]` of a URL, lowercased, default ports dropped; '' if unparseable. */
|
|
18
|
+
export function originOf(url: string | null | undefined): string {
|
|
19
|
+
const m = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i.exec(String(url || ''));
|
|
20
|
+
if (!m) return '';
|
|
21
|
+
const scheme = m[1].toLowerCase();
|
|
22
|
+
const host = (m[2].split('@').pop() || '').toLowerCase();
|
|
23
|
+
const dflt = scheme === 'https' ? ':443' : scheme === 'http' ? ':80' : '';
|
|
24
|
+
return `${scheme}://${dflt && host.endsWith(dflt) ? host.slice(0, -dflt.length) : host}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Is `origin` the app's own (full bridge)? Fail-closed: an empty/unknown origin is foreign. */
|
|
28
|
+
export function isTrustedOrigin(origin: string | null | undefined): boolean {
|
|
29
|
+
const o = originOf(origin);
|
|
30
|
+
if (!o) return false;
|
|
31
|
+
if (o === 'app://localhost' || (SHELL_CONFIG.loader === 'file' && o === 'file://')) return true;
|
|
32
|
+
if (SHELL_CONFIG.loader === 'server' && o === originOf(effectiveServerUrl())) return true;
|
|
33
|
+
return (SHELL_CONFIG.appBoundDomains ?? []).some((h) => o === `https://${String(h).toLowerCase()}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** May a page of `origin` call `method`? */
|
|
37
|
+
export function bridgeAllows(origin: string | null | undefined, method: string): boolean {
|
|
38
|
+
return isTrustedOrigin(origin) || FOREIGN_ALLOWED.some((a) => (a.endsWith('.') ? method.startsWith(a) : method === a));
|
|
39
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isAndroid, isIOS } from '@nativescript/core';
|
|
2
2
|
import { CustomWebView } from './custom-webview';
|
|
3
|
+
import { bridgeAllows } from './bridge-origin';
|
|
3
4
|
|
|
4
5
|
// params: any — the bridge payload is an untyped JSON object decoded from the WebView; each handler
|
|
5
6
|
// narrows it to its own param shape at the call site.
|
|
@@ -39,7 +40,7 @@ export class Bridge {
|
|
|
39
40
|
*/
|
|
40
41
|
attach(webView: CustomWebView): void {
|
|
41
42
|
this.webView = webView;
|
|
42
|
-
webView.onAppwrapMessage = (json) => this.onMessage(json);
|
|
43
|
+
webView.onAppwrapMessage = (json, origin) => this.onMessage(json, origin);
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
detach(): void {
|
|
@@ -68,7 +69,8 @@ export class Bridge {
|
|
|
68
69
|
* renderer would silently stall the retry loop and we would be back to the 60s lie. */
|
|
69
70
|
static responseAttemptTimeoutMs = 3_000;
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
/** `origin` = the calling frame's origin, from the transport (never from the page's own payload). */
|
|
73
|
+
private async onMessage(json: string, origin: string): Promise<void> {
|
|
72
74
|
let req: RequestEnvelope;
|
|
73
75
|
try {
|
|
74
76
|
req = JSON.parse(json);
|
|
@@ -78,6 +80,11 @@ export class Bridge {
|
|
|
78
80
|
}
|
|
79
81
|
if (req.kind !== 'request' || !req.id || !req.method) return;
|
|
80
82
|
|
|
83
|
+
if (!bridgeAllows(origin, req.method)) {
|
|
84
|
+
console.warn(`Bridge: ${req.method} denied for foreign origin ${origin || '(unknown)'}`);
|
|
85
|
+
this.respond(req.id, undefined, { code: 'FORBIDDEN', message: `${req.method} is not available to ${origin || 'this page'}` });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
81
88
|
const handler = this.handlers.get(req.method);
|
|
82
89
|
if (!handler) {
|
|
83
90
|
this.respond(req.id, undefined, { code: 'UNSUPPORTED', message: `No handler for ${req.method}` });
|
|
@@ -49,14 +49,15 @@ function getChromeClientClass(): any {
|
|
|
49
49
|
chromeClientClass = (android.webkit.WebChromeClient as any).extend({
|
|
50
50
|
onJsPrompt(
|
|
51
51
|
view: android.webkit.WebView,
|
|
52
|
-
|
|
52
|
+
url: string,
|
|
53
53
|
message: string,
|
|
54
54
|
_defaultValue: string,
|
|
55
55
|
result: android.webkit.JsPromptResult
|
|
56
56
|
): boolean {
|
|
57
57
|
if (typeof message === 'string' && message.startsWith(PROMPT_PREFIX)) {
|
|
58
58
|
result.confirm('');
|
|
59
|
-
|
|
59
|
+
// `url` = the page that called prompt() (the bridge gates on its origin).
|
|
60
|
+
CustomWebView.forNative(view)?.onAppwrapMessage?.(message.slice(PROMPT_PREFIX.length), url);
|
|
60
61
|
return true;
|
|
61
62
|
}
|
|
62
63
|
return false; // genuine page prompt — default handling
|
|
@@ -91,7 +92,7 @@ function getChromeClientClass(): any {
|
|
|
91
92
|
*/
|
|
92
93
|
export class CustomWebView extends WebView {
|
|
93
94
|
/** Set by the bridge before load; receives raw envelope JSON. */
|
|
94
|
-
onAppwrapMessage: ((json: string) => void) | null = null;
|
|
95
|
+
onAppwrapMessage: ((json: string, origin: string) => void) | null = null;
|
|
95
96
|
|
|
96
97
|
/**
|
|
97
98
|
* A TLS handshake this view REFUSED (onReceivedSslError → handler.cancel()), pending attribution to the
|
|
@@ -38,7 +38,9 @@ class AppwrapScriptHandler extends NSObject implements WKScriptMessageHandler {
|
|
|
38
38
|
const body = message.body;
|
|
39
39
|
// Envelopes always travel as JSON strings (protocol v1)
|
|
40
40
|
if (view?.onAppwrapMessage && typeof body === 'string') {
|
|
41
|
-
|
|
41
|
+
// The CALLING frame's origin (WebKit-reported, not page-controlled) — the bridge gates on it.
|
|
42
|
+
const so = message.frameInfo?.securityOrigin;
|
|
43
|
+
view.onAppwrapMessage(body, so ? `${so.protocol}://${so.host}${so.port ? `:${so.port}` : ''}` : '');
|
|
42
44
|
} else {
|
|
43
45
|
// Nothing to dispatch to (bridge detached / view gone) or a non-string body ⇒ this request is
|
|
44
46
|
// DROPPED and can never be answered — the caller's watchdog will report a false TIMEOUT for it.
|
|
@@ -195,7 +197,7 @@ export class CustomWebView extends WebView {
|
|
|
195
197
|
}
|
|
196
198
|
|
|
197
199
|
/** Set by the bridge before load; receives raw envelope JSON. */
|
|
198
|
-
onAppwrapMessage: ((json: string) => void) | null = null;
|
|
200
|
+
onAppwrapMessage: ((json: string, origin: string) => void) | null = null;
|
|
199
201
|
private _scriptHandler!: AppwrapScriptHandler; // retained — WKUserContentController holds it weakly
|
|
200
202
|
private _logHandler!: AppwrapLogHandler; // retained — debug console-forwarding handler
|
|
201
203
|
private _schemeHandler!: WKURLSchemeHandler;
|
|
@@ -7,7 +7,7 @@ import { WebView } from '@nativescript/core';
|
|
|
7
7
|
*/
|
|
8
8
|
export class CustomWebView extends WebView {
|
|
9
9
|
/** Set by the bridge before load; receives raw envelope JSON. */
|
|
10
|
-
onAppwrapMessage: ((json: string) => void) | null = null;
|
|
10
|
+
onAppwrapMessage: ((json: string, origin: string) => void) | null = null;
|
|
11
11
|
|
|
12
12
|
/** Pause/resume the render + JS-timer pipeline on app background/foreground. Platform-specific
|
|
13
13
|
* (see custom-webview.android.ts); no-op on iOS, which suspends rAF on its own. */
|
|
@@ -2,6 +2,7 @@ import { Utils, isIOS, isAndroid, Application } from '@nativescript/core';
|
|
|
2
2
|
import { bridge } from './bridge';
|
|
3
3
|
import { createUiDelegate } from './ios-ui-delegate';
|
|
4
4
|
import { handleWebPermissionRequest } from './android-helpers';
|
|
5
|
+
import { SHELL_CONFIG } from './config';
|
|
5
6
|
|
|
6
7
|
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
7
8
|
|
|
@@ -274,7 +275,7 @@ function registerAndroid(): void {
|
|
|
274
275
|
(view as any)._appwrapClients = [client, chrome]; // keep JS peers alive (NS GC doesn't see the native hold)
|
|
275
276
|
const bg = host?.getBackground?.();
|
|
276
277
|
if (bg instanceof android.graphics.drawable.ColorDrawable) view.setBackgroundColor(bg.getColor());
|
|
277
|
-
android.webkit.WebView.setWebContentsDebuggingEnabled(true);
|
|
278
|
+
if (SHELL_CONFIG.debug) android.webkit.WebView.setWebContentsDebuggingEnabled(true); // remote DevTools only in debug builds, never store builds
|
|
278
279
|
wv = view;
|
|
279
280
|
frame.addView(view);
|
|
280
281
|
layout();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// Same superset @nativescript/core mock as the other runtime tests (shared module cache).
|
|
4
|
+
mock.module('@nativescript/core', () => ({
|
|
5
|
+
Http: { request: async () => ({ content: null }) },
|
|
6
|
+
isIOS: false, isAndroid: false, WebView: class {},
|
|
7
|
+
ApplicationSettings: { getString: (_k: string, d = '') => d, setString: () => {}, remove: () => {} },
|
|
8
|
+
Dialogs: { confirm: async () => true, action: async () => '', alert: async () => undefined, prompt: async () => ({ result: false, text: '' }) },
|
|
9
|
+
Utils: { dispatchToMainThread: (fn: () => void) => fn() },
|
|
10
|
+
Application: { on: () => {}, suspendEvent: 's', resumeEvent: 'r', orientationChangedEvent: 'o', android: {} },
|
|
11
|
+
Connectivity: { startMonitoring: () => {} },
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const { SHELL_CONFIG } = await import('../app/shell/config');
|
|
15
|
+
const { Bridge } = await import('../app/shell/bridge');
|
|
16
|
+
const { bridgeAllows, originOf } = await import('../app/shell/bridge-origin');
|
|
17
|
+
|
|
18
|
+
/** A request from a page of `origin`; resolves with the response envelope the bridge delivers. */
|
|
19
|
+
async function call(method: string, origin: string) {
|
|
20
|
+
const b = new Bridge();
|
|
21
|
+
const view: any = { onAppwrapMessage: null };
|
|
22
|
+
b.attach(view);
|
|
23
|
+
b.register(method, () => 'secret');
|
|
24
|
+
let out: any;
|
|
25
|
+
b.evalJs = async (js: string) => { out = JSON.parse(JSON.parse(js.slice(js.indexOf('(') + 1, js.lastIndexOf(')')))); };
|
|
26
|
+
view.onAppwrapMessage(JSON.stringify({ v: 1, id: 'k', kind: 'request', method }), origin);
|
|
27
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('Bridge origin gate — a foreign page must not reach the app’s stored data', () => {
|
|
32
|
+
test('the app origin keeps the full bridge', async () => {
|
|
33
|
+
expect((await call('storage.secure.get', 'app://localhost')).result).toBe('secret');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a foreign page (a LAN/Tailscale server) is FORBIDDEN storage.secure, fs, webview cookies', async () => {
|
|
37
|
+
for (const m of ['storage.secure.get', 'storage.get', 'fs.read', 'webview.getCookies', 'clipboard.read', 'share.files']) {
|
|
38
|
+
const r = await call(m, 'http://192.168.1.5:7707');
|
|
39
|
+
expect(r.result).toBeUndefined();
|
|
40
|
+
expect(r.error.code).toBe('FORBIDDEN');
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('an unknown origin fails closed', async () => {
|
|
45
|
+
expect((await call('storage.secure.get', '')).error.code).toBe('FORBIDDEN');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a foreign page keeps UI feedback + permission-prompted features', () => {
|
|
49
|
+
for (const m of ['haptics.impact', 'keyboard.hide', 'share.share', 'ui.statusBar.setStyle', 'push.register', 'app.handshake'])
|
|
50
|
+
expect(bridgeAllows('https://evil.example', m)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('server loader: its own origin (default port, any path) is trusted; a lookalike is not', () => {
|
|
54
|
+
const prev = { loader: SHELL_CONFIG.loader, serverUrl: SHELL_CONFIG.serverUrl };
|
|
55
|
+
Object.assign(SHELL_CONFIG, { loader: 'server', serverUrl: 'https://app.example.com/start?x=1' });
|
|
56
|
+
try {
|
|
57
|
+
expect(bridgeAllows('https://app.example.com:443', 'storage.secure.get')).toBe(true);
|
|
58
|
+
expect(bridgeAllows('https://app.example.com.evil.io', 'storage.secure.get')).toBe(false);
|
|
59
|
+
expect(bridgeAllows('http://app.example.com', 'storage.secure.get')).toBe(false);
|
|
60
|
+
} finally { Object.assign(SHELL_CONFIG, prev); }
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('originOf normalises', () => {
|
|
64
|
+
expect(originOf('HTTP://U:p@Host:80/a?b#c')).toBe('http://host');
|
|
65
|
+
expect(originOf('app://localhost/index.html')).toBe('app://localhost');
|
|
66
|
+
expect(originOf('not a url')).toBe('');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -22,7 +22,7 @@ function harness(evalImpl: (js: string) => Promise<unknown>) {
|
|
|
22
22
|
b.attach(view);
|
|
23
23
|
b.evalJs = evalImpl;
|
|
24
24
|
const delivered: any[] = [];
|
|
25
|
-
const send = (method: string, id = 'k1') => view.onAppwrapMessage(JSON.stringify({ v: 1, id, kind: 'request', method }));
|
|
25
|
+
const send = (method: string, id = 'k1') => view.onAppwrapMessage(JSON.stringify({ v: 1, id, kind: 'request', method }), 'app://localhost');
|
|
26
26
|
/** Parse an envelope back out of the `window.__appwrapDeliver("…")` script the bridge builds. */
|
|
27
27
|
const parse = (js: string) => JSON.parse(JSON.parse(js.slice(js.indexOf('(') + 1, js.lastIndexOf(')'))));
|
|
28
28
|
return { b, send, delivered, parse };
|