@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.61.20",
3
+ "version": "0.61.22",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -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
- private async onMessage(json: string): Promise<void> {
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
- _url: string,
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
- CustomWebView.forNative(view)?.onAppwrapMessage?.(message.slice(PROMPT_PREFIX.length));
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
- view.onAppwrapMessage(body);
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 };