mcp-browser-extension 0.9.36 → 0.9.38
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/README.md +33 -15
- package/dist/shared/client-auth.d.ts +2 -0
- package/dist/shared/phone-pairing.d.ts +35 -0
- package/dist/shared/phone-pairing.js +10 -0
- package/dist/shared/policy.js +7 -0
- package/dist/shared/protocol.d.ts +13 -2
- package/dist/shared/protocol.js +13 -1
- package/dist/shared/site-risk.d.ts +31 -0
- package/dist/shared/site-risk.js +190 -0
- package/dist/src/bridge/client-identity.d.ts +11 -5
- package/dist/src/bridge/client-identity.js +7 -4
- package/dist/src/bridge/connection.js +1 -0
- package/dist/src/bridge/server.d.ts +1 -0
- package/dist/src/bridge/server.js +26 -11
- package/dist/src/bridge/workspace.d.ts +1 -0
- package/dist/src/bridge/workspace.js +11 -3
- package/dist/src/cli.js +61 -18
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +26 -1
- package/dist/src/executor/extension-executor.d.ts +1 -0
- package/dist/src/executor/extension-executor.js +10 -1
- package/dist/src/executor/types.d.ts +3 -1
- package/dist/src/mcp/helpers.js +18 -6
- package/dist/src/mcp/person-wait.js +2 -1
- package/dist/src/mcp/server.d.ts +2 -1
- package/dist/src/mcp/server.js +4 -4
- package/dist/src/mcp/tools.d.ts +2 -2
- package/dist/src/mcp/tools.js +46 -9
- package/dist/src/native-host.js +11 -1
- package/dist/src/remote/claude-chat.d.ts +21 -0
- package/dist/src/remote/claude-chat.js +209 -0
- package/dist/src/remote/codex-rpc.d.ts +19 -0
- package/dist/src/remote/codex-rpc.js +94 -0
- package/dist/src/remote/http.d.ts +5 -0
- package/dist/src/remote/http.js +99 -7
- package/dist/src/remote/key.d.ts +4 -0
- package/dist/src/remote/key.js +6 -2
- package/dist/src/remote/phone-chat.d.ts +57 -0
- package/dist/src/remote/phone-chat.js +366 -0
- package/dist/src/remote/phone-control.d.ts +6 -0
- package/dist/src/remote/phone-control.js +132 -0
- package/dist/src/remote/phone-pairing.d.ts +41 -0
- package/dist/src/remote/phone-pairing.js +180 -0
- package/dist/src/remote/relay.d.ts +37 -0
- package/dist/src/remote/relay.js +207 -0
- package/docs/CLIENT-APPROVAL.md +44 -9
- package/docs/PHONE-PAIRING.md +97 -0
- package/extension-dist/THIRD-PARTY-NOTICES.txt +35 -0
- package/extension-dist/activity-dashboard.css +4 -9
- package/extension-dist/background.js +725 -127
- package/extension-dist/brand.css +63 -0
- package/extension-dist/manifest.json +1 -1
- package/extension-dist/options.html +25 -22
- package/extension-dist/options.js +179 -42
- package/extension-dist/popup.html +33 -41
- package/extension-dist/popup.js +2529 -126
- package/extension-dist/site-protection.css +11 -0
- package/package.json +13 -2
package/README.md
CHANGED
|
@@ -53,12 +53,12 @@ Chrome, so a page the agent opens is the page you would see.
|
|
|
53
53
|
Other tools drive a signed-in Chrome too (the `hangwin/mcp-chrome` extension,
|
|
54
54
|
for one), so the question is what you get on top of the session:
|
|
55
55
|
|
|
56
|
-
- **
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
- **Website checks with explicit Chrome permission.** Local suspicious-site
|
|
57
|
+
checks are on by default. After one Chrome permission grant, ordinary sites
|
|
58
|
+
work automatically and flagged sites need Allow. Named-site approval remains
|
|
59
|
+
available; `--no-site-grants` enforces the server's fixed allowlist. Reads and
|
|
60
|
+
actions are checked in the extension. Mutations, eval, downloads and uploads
|
|
61
|
+
are separate opt-ins.
|
|
62
62
|
- **Password values are never returned**, and `--redact` scrubs tokens, API
|
|
63
63
|
keys and JWTs out of page reads.
|
|
64
64
|
- **An audit trail**: every call is logged with the URL, the allow/deny
|
|
@@ -142,10 +142,11 @@ with `/mcp` inside a session — no restart needed.
|
|
|
142
142
|
|
|
143
143
|
</details>
|
|
144
144
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
Website access requires app approval and Chrome permission. Suspicious-site
|
|
146
|
+
checks are on by default; click **Allow website access** once in the extension.
|
|
147
|
+
For a fixed named-site policy, use `--no-site-grants --allow-domain <glob>`.
|
|
148
|
+
Mutations, downloads, uploads and eval stay off until enabled with
|
|
149
|
+
`--enable-mutations`, `--enable-downloads`, `--enable-uploads` or `--unsafe-enable-eval`.
|
|
149
150
|
|
|
150
151
|
Email verification is on by default. For a trusted, local unattended setup, you
|
|
151
152
|
can opt out with `--no-email-verification` (or set
|
|
@@ -224,7 +225,22 @@ connection creates a new key and needs a new approval. **Remove access** blocks
|
|
|
224
225
|
commands from that key; an action already running may finish. YOLO, bypass-permission
|
|
225
226
|
mode and `--no-email-verification` cannot skip this extension check.
|
|
226
227
|
|
|
227
|
-
**6. Allow
|
|
228
|
+
**6. Allow website access once.** Suspicious-site checks are on by default. In the
|
|
229
|
+
popup or Settings, **Allow website access** asks Chrome for HTTP/HTTPS access. After
|
|
230
|
+
you accept, ordinary sites work without an Allow request. Local checks pause
|
|
231
|
+
flagged addresses and risky login forms and show their reasons under **Waiting
|
|
232
|
+
for Allow**. Allow approves only the displayed reasons for that exact origin;
|
|
233
|
+
new reasons ask again. **Blocked websites** lets you block or restore any site.
|
|
234
|
+
Both the extension and connected server must be updated; `--no-site-grants`
|
|
235
|
+
disables this option.
|
|
236
|
+
|
|
237
|
+
These checks run locally with no external checking service. They can miss scams
|
|
238
|
+
and flag legitimate sites. Form checks run after a page loads, before app reads
|
|
239
|
+
or actions; they do not prevent page scripts from running or detect all malware.
|
|
240
|
+
App approval, Stop access and the configured tool permissions still apply.
|
|
241
|
+
|
|
242
|
+
Choose **Use per-site approval** or **Turn off** to use named-site access instead.
|
|
243
|
+
With automatic access off, the popup shows a Website access request.
|
|
228
244
|
Click **Allow**, then accept Chrome's website permission prompt if shown. This
|
|
229
245
|
single flow grants that named website in both Chrome and the server's allowlist.
|
|
230
246
|
Allowing a website lets approved apps read and take actions there, including
|
|
@@ -707,17 +723,19 @@ status badge, and a stable pairing token (`--persist-token`).
|
|
|
707
723
|
|
|
708
724
|
## Security posture (default)
|
|
709
725
|
|
|
710
|
-
**
|
|
711
|
-
|
|
726
|
+
**Access requires consent.** The server starts with an empty named-site allowlist;
|
|
727
|
+
the extension's default suspicious-site mode requires an explicit Chrome host
|
|
728
|
+
grant before ordinary sites work automatically. Eval, downloads and mutations
|
|
729
|
+
stay off until enabled. Named-site policy remains available:
|
|
712
730
|
|
|
713
731
|
```
|
|
714
|
-
mcp-browser-extension --allow-domain example.com --enable-mutations
|
|
732
|
+
mcp-browser-extension --no-site-grants --allow-domain example.com --enable-mutations
|
|
715
733
|
mcp-browser-extension --policy ./policy.json # see policy.example.json
|
|
716
734
|
mcp-browser-extension --enable-observers # console/network/dialog capture (patches page globals)
|
|
717
735
|
mcp-browser-extension --redact # scrub secret-shaped strings out of page reads
|
|
718
736
|
```
|
|
719
737
|
|
|
720
|
-
**
|
|
738
|
+
**Named-site mode: allowing a site without a restart.** When a call is refused because its site
|
|
721
739
|
is not allowed, the extension's toolbar icon shows a `?`. Click it: the popup
|
|
722
740
|
lists the blocked site with an **Allow** button, shows whether the site you are on
|
|
723
741
|
is allowed (with its own Allow), and lists the allowed sites. The Options page
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Versioned contract shared by the extension and the future phone client. */
|
|
2
|
+
export declare const PHONE_PAIRING_VERSION = 1;
|
|
3
|
+
export declare const PHONE_INVITATION_MS: number;
|
|
4
|
+
export declare const PHONE_ACCESS_MS: number;
|
|
5
|
+
export declare const PHONE_MAX_DEVICES = 10;
|
|
6
|
+
export interface PhoneDevice {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
clientId: string;
|
|
10
|
+
publicKey: string;
|
|
11
|
+
requestedAt: number;
|
|
12
|
+
expiresAt: number;
|
|
13
|
+
state: 'pending' | 'paired';
|
|
14
|
+
confirmation: string;
|
|
15
|
+
/** Added by Chrome when presenting a saved pairing to the person. */
|
|
16
|
+
browserApproved?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface PhonePairingState {
|
|
19
|
+
online: boolean;
|
|
20
|
+
profile: string;
|
|
21
|
+
invitation?: {
|
|
22
|
+
payload: string;
|
|
23
|
+
expiresAt: number;
|
|
24
|
+
};
|
|
25
|
+
devices: PhoneDevice[];
|
|
26
|
+
}
|
|
27
|
+
export type PhoneAction = 'state' | 'create' | 'cancel' | 'approve' | 'deny' | 'remove';
|
|
28
|
+
export declare const PHONE_ACTIONS: readonly PhoneAction[];
|
|
29
|
+
export type PhoneReply = {
|
|
30
|
+
ok: true;
|
|
31
|
+
state: PhonePairingState;
|
|
32
|
+
} | {
|
|
33
|
+
ok: false;
|
|
34
|
+
message: string;
|
|
35
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PHONE_ACTIONS = exports.PHONE_MAX_DEVICES = exports.PHONE_ACCESS_MS = exports.PHONE_INVITATION_MS = exports.PHONE_PAIRING_VERSION = void 0;
|
|
4
|
+
/** Versioned contract shared by the extension and the future phone client. */
|
|
5
|
+
exports.PHONE_PAIRING_VERSION = 1;
|
|
6
|
+
exports.PHONE_INVITATION_MS = 2 * 60_000;
|
|
7
|
+
exports.PHONE_ACCESS_MS = 30 * 24 * 60 * 60_000;
|
|
8
|
+
exports.PHONE_MAX_DEVICES = 10;
|
|
9
|
+
exports.PHONE_ACTIONS = ['state', 'create', 'cancel', 'approve', 'deny', 'remove'];
|
|
10
|
+
//# sourceMappingURL=phone-pairing.js.map
|
package/dist/shared/policy.js
CHANGED
|
@@ -133,6 +133,9 @@ function isDomainAllowed(url, policy) {
|
|
|
133
133
|
const host = hostOf(url);
|
|
134
134
|
if (!host)
|
|
135
135
|
return false;
|
|
136
|
+
if (policy.automaticSites && /^https?:\/\//i.test(url)) {
|
|
137
|
+
return !(policy.blockedDomains ?? []).some((pat) => globMatches(host, pat));
|
|
138
|
+
}
|
|
136
139
|
return policy.allowDomains.some((pat) => globMatches(host, pat));
|
|
137
140
|
}
|
|
138
141
|
/**
|
|
@@ -177,6 +180,10 @@ function evaluatePolicy(url, method, policy) {
|
|
|
177
180
|
return { ok: true };
|
|
178
181
|
if (!isDomainAllowed(url, policy)) {
|
|
179
182
|
const host = hostOf(url) || url;
|
|
183
|
+
if (policy.automaticSites && /^https?:\/\//i.test(url))
|
|
184
|
+
return {
|
|
185
|
+
ok: false, reason: `Access to ${host} was removed. Restore it under Blocked websites in extension Settings.`,
|
|
186
|
+
};
|
|
180
187
|
return { ok: false, reason: blockedDomainMessage(host) };
|
|
181
188
|
}
|
|
182
189
|
return { ok: true };
|
|
@@ -24,6 +24,8 @@ export type ProtocolVersion = typeof PROTOCOL_VERSION;
|
|
|
24
24
|
* supported because the extension re-reads the handshake on every failed dial.
|
|
25
25
|
*/
|
|
26
26
|
export declare const DEFAULT_WS_PORT: 38017;
|
|
27
|
+
/** Maximum encoded JSON frame, shared by the receiver and result sender. */
|
|
28
|
+
export declare const MAX_BRIDGE_FRAME_BYTES: number;
|
|
27
29
|
/**
|
|
28
30
|
* The Chrome Web Store copy of the extension. Chrome updates that copy on its
|
|
29
31
|
* own; any other id is an unpacked (or self-packed) copy, which only changes
|
|
@@ -48,6 +50,8 @@ export declare const CLOSE_SUPERSEDED: 4000;
|
|
|
48
50
|
* every call. Without it, the server falls back to fetching the URL itself.
|
|
49
51
|
*/
|
|
50
52
|
export declare const WIRE_CAP_TAB_URL: "tab-url";
|
|
53
|
+
/** Signed local website checks, including the browser's automatic-access setting. */
|
|
54
|
+
export declare const WIRE_CAP_SITE_CHECKS: "site-checks-v1";
|
|
51
55
|
/**
|
|
52
56
|
* `fill-form`: this extension handles the `fill_form` wire method — every field
|
|
53
57
|
* of a form written in ONE round-trip. Without it the server fills field by
|
|
@@ -100,6 +104,9 @@ export declare const FILL_FORM_MAX_TIMEOUT_MS = 600000;
|
|
|
100
104
|
* grows with the field count (10 s base + 6 s per field), clamped to 60 s..10 min.
|
|
101
105
|
*/
|
|
102
106
|
export declare function fillFormTimeoutMs(fields: number): number;
|
|
107
|
+
/** Drawing sends one mouse event per point plus press/release events per stroke.
|
|
108
|
+
* Budget the whole drawing separately from the deadline for one stalled event. */
|
|
109
|
+
export declare function drawTimeoutMs(points: number, strokes: number): number;
|
|
103
110
|
/**
|
|
104
111
|
* `fill_form` result. Ops run in order and stop at the first failure, so
|
|
105
112
|
* `filled` counts the fields that landed and `error` names the one that did not.
|
|
@@ -124,10 +131,10 @@ export interface DrawStroke {
|
|
|
124
131
|
* `download_file` (privileged, executor-owned) and `ping_probe` (a short-deadline
|
|
125
132
|
* responsiveness check used to detect a dead-but-not-yet-reconnected worker).
|
|
126
133
|
*/
|
|
127
|
-
export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'draw' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'snapshot' | 'select_option' | 'get_cookies' | 'storage' | 'eval' | 'wait_for' | 'download_file' | 'upload_file' | 'frames_list' | 'observers' | 'print_pdf' | 'fill_form' | 'proxy_status' | 'ping_probe';
|
|
134
|
+
export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'draw' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'snapshot' | 'select_option' | 'get_cookies' | 'storage' | 'eval' | 'wait_for' | 'download_file' | 'upload_file' | 'frames_list' | 'observers' | 'print_pdf' | 'fill_form' | 'proxy_status' | 'ping_probe' | 'site_check';
|
|
128
135
|
/** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
|
|
129
136
|
export declare const WIRE_METHODS: readonly WireMethod[];
|
|
130
|
-
export type ExecutorErrorCode = 'NO_TARGET' | 'TARGET_GONE' | 'DETACHED' | 'DEVTOOLS_OPEN' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_THREW' | 'TIMEOUT' | 'BAD_ARGS' | 'CDP_ERROR' | 'POLICY_DENIED' | 'DOWNLOAD_FAILED' | 'UPLOAD_FAILED' | 'FRAME_NOT_FOUND' | 'OBSERVERS_DISABLED' | 'UNKNOWN_METHOD';
|
|
137
|
+
export type ExecutorErrorCode = 'RESULT_TOO_LARGE' | 'NO_TARGET' | 'TARGET_GONE' | 'DETACHED' | 'DEVTOOLS_OPEN' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_THREW' | 'TIMEOUT' | 'BAD_ARGS' | 'CDP_ERROR' | 'POLICY_DENIED' | 'DOWNLOAD_FAILED' | 'UPLOAD_FAILED' | 'FRAME_NOT_FOUND' | 'OBSERVERS_DISABLED' | 'UNKNOWN_METHOD';
|
|
131
138
|
export interface BaseFrame {
|
|
132
139
|
type: string;
|
|
133
140
|
v: ProtocolVersion;
|
|
@@ -168,6 +175,9 @@ export interface HelloFrame extends BaseFrame {
|
|
|
168
175
|
*/
|
|
169
176
|
export interface WirePolicy {
|
|
170
177
|
allowDomains: string[];
|
|
178
|
+
/** Extension-local overlay only; server configuration cannot opt a browser in. */
|
|
179
|
+
automaticSites?: boolean;
|
|
180
|
+
blockedDomains?: string[];
|
|
171
181
|
allowEval: boolean;
|
|
172
182
|
allowDownloads: boolean;
|
|
173
183
|
allowUploads: boolean;
|
|
@@ -185,6 +195,7 @@ export interface WirePolicy {
|
|
|
185
195
|
}
|
|
186
196
|
export interface WelcomeFrame extends BaseFrame {
|
|
187
197
|
type: 'welcome';
|
|
198
|
+
siteChecks?: boolean;
|
|
188
199
|
serverVersion: string;
|
|
189
200
|
sessionId: string;
|
|
190
201
|
heartbeatMs: number;
|
package/dist/shared/protocol.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.WIRE_METHODS = exports.FILL_FORM_MAX_TIMEOUT_MS = exports.FILL_FORM_PER_FIELD_MS = exports.FILL_FORM_MIN_TIMEOUT_MS = exports.WIRE_CAP_PROXY = exports.WIRE_CAP_FILL_FORM = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.STORE_EXTENSION_URL = exports.STORE_EXTENSION_ID = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
|
|
3
|
+
exports.WIRE_METHODS = exports.FILL_FORM_MAX_TIMEOUT_MS = exports.FILL_FORM_PER_FIELD_MS = exports.FILL_FORM_MIN_TIMEOUT_MS = exports.WIRE_CAP_PROXY = exports.WIRE_CAP_FILL_FORM = exports.WIRE_CAP_SITE_CHECKS = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.STORE_EXTENSION_URL = exports.STORE_EXTENSION_ID = exports.MAX_BRIDGE_FRAME_BYTES = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
|
|
4
4
|
exports.fillFormTimeoutMs = fillFormTimeoutMs;
|
|
5
|
+
exports.drawTimeoutMs = drawTimeoutMs;
|
|
5
6
|
/**
|
|
6
7
|
* shared/protocol.ts — THE SINGLE SOURCE OF TRUTH for the wire contract.
|
|
7
8
|
*
|
|
@@ -26,6 +27,8 @@ exports.PROTOCOL_VERSION = 1;
|
|
|
26
27
|
* supported because the extension re-reads the handshake on every failed dial.
|
|
27
28
|
*/
|
|
28
29
|
exports.DEFAULT_WS_PORT = 38017;
|
|
30
|
+
/** Maximum encoded JSON frame, shared by the receiver and result sender. */
|
|
31
|
+
exports.MAX_BRIDGE_FRAME_BYTES = 4 * 1024 * 1024;
|
|
29
32
|
/**
|
|
30
33
|
* The Chrome Web Store copy of the extension. Chrome updates that copy on its
|
|
31
34
|
* own; any other id is an unpacked (or self-packed) copy, which only changes
|
|
@@ -50,6 +53,8 @@ exports.CLOSE_SUPERSEDED = 4000;
|
|
|
50
53
|
* every call. Without it, the server falls back to fetching the URL itself.
|
|
51
54
|
*/
|
|
52
55
|
exports.WIRE_CAP_TAB_URL = 'tab-url';
|
|
56
|
+
/** Signed local website checks, including the browser's automatic-access setting. */
|
|
57
|
+
exports.WIRE_CAP_SITE_CHECKS = 'site-checks-v1';
|
|
53
58
|
/**
|
|
54
59
|
* `fill-form`: this extension handles the `fill_form` wire method — every field
|
|
55
60
|
* of a form written in ONE round-trip. Without it the server fills field by
|
|
@@ -79,6 +84,12 @@ function fillFormTimeoutMs(fields) {
|
|
|
79
84
|
const n = Number.isFinite(fields) && fields > 0 ? Math.floor(fields) : 0;
|
|
80
85
|
return Math.min(exports.FILL_FORM_MAX_TIMEOUT_MS, Math.max(exports.FILL_FORM_MIN_TIMEOUT_MS, 10_000 + n * exports.FILL_FORM_PER_FIELD_MS));
|
|
81
86
|
}
|
|
87
|
+
/** Drawing sends one mouse event per point plus press/release events per stroke.
|
|
88
|
+
* Budget the whole drawing separately from the deadline for one stalled event. */
|
|
89
|
+
function drawTimeoutMs(points, strokes) {
|
|
90
|
+
const events = Math.max(0, points) + 2 * Math.max(0, strokes);
|
|
91
|
+
return Math.min(240_000, Math.max(30_000, 10_000 + events * 60));
|
|
92
|
+
}
|
|
82
93
|
/** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
|
|
83
94
|
exports.WIRE_METHODS = [
|
|
84
95
|
'tabs_list',
|
|
@@ -112,5 +123,6 @@ exports.WIRE_METHODS = [
|
|
|
112
123
|
'fill_form',
|
|
113
124
|
'proxy_status',
|
|
114
125
|
'ping_probe',
|
|
126
|
+
'site_check',
|
|
115
127
|
];
|
|
116
128
|
//# sourceMappingURL=protocol.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Local warning rules. An empty result means no rule matched, never a safety guarantee. */
|
|
2
|
+
export declare const SITE_CHECKS_KEY = "localSiteChecksV1";
|
|
3
|
+
export declare const SITE_CHECKS_PENDING_KEY = "localSiteWarnings";
|
|
4
|
+
export declare const SITE_CHECKS_APPROVALS_KEY = "localSiteWarningApprovals";
|
|
5
|
+
export declare const SITE_CHECKS_BLOCKED_KEY = "localBlockedSites";
|
|
6
|
+
export declare const AUTOMATIC_SITE_ORIGINS: string[];
|
|
7
|
+
export interface SiteWarning {
|
|
8
|
+
id: string;
|
|
9
|
+
reason: string;
|
|
10
|
+
}
|
|
11
|
+
export interface SiteWarningRequest {
|
|
12
|
+
origin: string;
|
|
13
|
+
host: string;
|
|
14
|
+
warnings: SiteWarning[];
|
|
15
|
+
at: number;
|
|
16
|
+
}
|
|
17
|
+
export interface LoginSignals {
|
|
18
|
+
url: string;
|
|
19
|
+
title: string;
|
|
20
|
+
passwords: number;
|
|
21
|
+
forms: Array<{
|
|
22
|
+
action: string;
|
|
23
|
+
method: string;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
export declare function isLocalDevelopmentHost(host: string): boolean;
|
|
27
|
+
export declare function siteUrlWarnings(input: string): SiteWarning[];
|
|
28
|
+
export declare function loginWarnings(signals: LoginSignals): SiteWarning[];
|
|
29
|
+
export declare function uniqueWarnings(warnings: SiteWarning[]): SiteWarning[];
|
|
30
|
+
/** Self-contained isolated-world reader. Never reads field values or page text. */
|
|
31
|
+
export declare function collectLoginSignals(): LoginSignals;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AUTOMATIC_SITE_ORIGINS = exports.SITE_CHECKS_BLOCKED_KEY = exports.SITE_CHECKS_APPROVALS_KEY = exports.SITE_CHECKS_PENDING_KEY = exports.SITE_CHECKS_KEY = void 0;
|
|
4
|
+
exports.isLocalDevelopmentHost = isLocalDevelopmentHost;
|
|
5
|
+
exports.siteUrlWarnings = siteUrlWarnings;
|
|
6
|
+
exports.loginWarnings = loginWarnings;
|
|
7
|
+
exports.uniqueWarnings = uniqueWarnings;
|
|
8
|
+
exports.collectLoginSignals = collectLoginSignals;
|
|
9
|
+
/** Local warning rules. An empty result means no rule matched, never a safety guarantee. */
|
|
10
|
+
exports.SITE_CHECKS_KEY = 'localSiteChecksV1';
|
|
11
|
+
exports.SITE_CHECKS_PENDING_KEY = 'localSiteWarnings';
|
|
12
|
+
exports.SITE_CHECKS_APPROVALS_KEY = 'localSiteWarningApprovals';
|
|
13
|
+
exports.SITE_CHECKS_BLOCKED_KEY = 'localBlockedSites';
|
|
14
|
+
exports.AUTOMATIC_SITE_ORIGINS = ['http://*/*', 'https://*/*'];
|
|
15
|
+
const BRANDS = {
|
|
16
|
+
google: ['google.com', 'google.co.uk', 'google.com.pk', 'google.de', 'google.fr', 'google.co.in', 'google.com.au', 'google.ca', 'google.co.jp', 'youtube.com'],
|
|
17
|
+
microsoft: ['microsoft.com', 'microsoftonline.com', 'live.com', 'office.com', 'outlook.com'],
|
|
18
|
+
apple: ['apple.com', 'icloud.com'],
|
|
19
|
+
paypal: ['paypal.com'],
|
|
20
|
+
amazon: ['amazon.com', 'amazon.co.uk', 'amazon.de', 'amazon.fr', 'amazon.in', 'amazon.ca', 'amazon.com.au', 'amazon.co.jp', 'amazon.es', 'amazon.it'],
|
|
21
|
+
github: ['github.com', 'github.dev'],
|
|
22
|
+
facebook: ['facebook.com', 'meta.com'],
|
|
23
|
+
instagram: ['instagram.com', 'meta.com'],
|
|
24
|
+
linkedin: ['linkedin.com'],
|
|
25
|
+
dropbox: ['dropbox.com'],
|
|
26
|
+
coinbase: ['coinbase.com'],
|
|
27
|
+
};
|
|
28
|
+
const AUTH_WORD = /(?:^|[.\-])(login|signin|sign-in|verify|verification|secure|account|password|recover|support)(?:[.\-]|$)/;
|
|
29
|
+
function hostIn(host, domains) {
|
|
30
|
+
return domains.some((domain) => host === domain || host.endsWith(`.${domain}`));
|
|
31
|
+
}
|
|
32
|
+
/** RFC 3492 decoding, bounded by DNS label length. Invalid labels remain unchanged. */
|
|
33
|
+
function unicodeLabel(label) {
|
|
34
|
+
if (!label.startsWith('xn--') || label.length > 63)
|
|
35
|
+
return label;
|
|
36
|
+
try {
|
|
37
|
+
const input = label.slice(4);
|
|
38
|
+
const split = input.lastIndexOf('-');
|
|
39
|
+
const points = split < 0 ? [] : Array.from(input.slice(0, split), (c) => c.codePointAt(0));
|
|
40
|
+
let pos = split < 0 ? 0 : split + 1;
|
|
41
|
+
let n = 128, i = 0, bias = 72;
|
|
42
|
+
while (pos < input.length) {
|
|
43
|
+
const old = i;
|
|
44
|
+
let weight = 1;
|
|
45
|
+
for (let k = 36;; k += 36) {
|
|
46
|
+
const code = input.charCodeAt(pos++);
|
|
47
|
+
const digit = code >= 97 && code <= 122 ? code - 97 : code >= 48 && code <= 57 ? code - 22 : 36;
|
|
48
|
+
if (digit >= 36 || !Number.isSafeInteger(i + digit * weight))
|
|
49
|
+
return label;
|
|
50
|
+
i += digit * weight;
|
|
51
|
+
const t = k <= bias ? 1 : k >= bias + 26 ? 26 : k - bias;
|
|
52
|
+
if (digit < t)
|
|
53
|
+
break;
|
|
54
|
+
weight *= 36 - t;
|
|
55
|
+
}
|
|
56
|
+
const size = points.length + 1;
|
|
57
|
+
let delta = Math.floor((i - old) / (old === 0 ? 700 : 2));
|
|
58
|
+
delta += Math.floor(delta / size);
|
|
59
|
+
let k = 0;
|
|
60
|
+
while (delta > 455) {
|
|
61
|
+
delta = Math.floor(delta / 35);
|
|
62
|
+
k += 36;
|
|
63
|
+
}
|
|
64
|
+
bias = k + Math.floor(36 * delta / (delta + 38));
|
|
65
|
+
n += Math.floor(i / size);
|
|
66
|
+
i %= size;
|
|
67
|
+
if (n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff))
|
|
68
|
+
return label;
|
|
69
|
+
points.splice(i++, 0, n);
|
|
70
|
+
}
|
|
71
|
+
return String.fromCodePoint(...points);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return label;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** A deliberately small confusable set for these rules, not a full Unicode classifier. */
|
|
78
|
+
function skeleton(label) {
|
|
79
|
+
const lookalikes = {
|
|
80
|
+
'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c', 'у': 'y', 'х': 'x',
|
|
81
|
+
'і': 'i', 'ј': 'j', 'ӏ': 'l', 'ѕ': 's', 'ԁ': 'd', 'ԛ': 'q',
|
|
82
|
+
'α': 'a', 'ο': 'o', 'ρ': 'p', 'ν': 'v', 'ι': 'i', '0': 'o', '1': 'l',
|
|
83
|
+
};
|
|
84
|
+
return Array.from(unicodeLabel(label).toLowerCase(), (c) => lookalikes[c] ?? c).join('');
|
|
85
|
+
}
|
|
86
|
+
function isLocalDevelopmentHost(host) {
|
|
87
|
+
return host === 'localhost' || host.endsWith('.localhost') || host === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(host);
|
|
88
|
+
}
|
|
89
|
+
function siteUrlWarnings(input) {
|
|
90
|
+
let url;
|
|
91
|
+
try {
|
|
92
|
+
url = new URL(input);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
if (!/^https?:$/.test(url.protocol))
|
|
98
|
+
return [];
|
|
99
|
+
const host = url.hostname.toLowerCase().replace(/\.$/, '');
|
|
100
|
+
const warnings = [];
|
|
101
|
+
if (url.username && /[.]/.test(decodeURIComponentSafe(url.username))) {
|
|
102
|
+
warnings.push({ id: 'hidden-destination', reason: `The address puts a website name before @. The actual destination is ${host}.` });
|
|
103
|
+
}
|
|
104
|
+
const labels = host.split('.');
|
|
105
|
+
for (const [brand, domains] of Object.entries(BRANDS)) {
|
|
106
|
+
if (hostIn(host, domains))
|
|
107
|
+
continue;
|
|
108
|
+
if (domains.some((domain) => host.includes(`${domain}.`))) {
|
|
109
|
+
warnings.push({ id: `misleading-domain:${brand}`, reason: `The address includes ${brand}'s domain but actually belongs to ${host}.` });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (labels.some((label) => label !== brand && skeleton(label) === brand)) {
|
|
113
|
+
warnings.push({ id: `lookalike:${brand}`, reason: `The address uses lookalike characters to resemble ${brand}.` });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// A brand in an article path or an ordinary unrelated domain is not enough.
|
|
117
|
+
if (labels.some((label) => label.split('-').includes(brand)) && AUTH_WORD.test(host)) {
|
|
118
|
+
warnings.push({ id: `brand-login:${brand}`, reason: `The address combines ${brand}'s name with login or account wording outside its recognised domains.` });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return warnings;
|
|
122
|
+
}
|
|
123
|
+
function decodeURIComponentSafe(value) {
|
|
124
|
+
try {
|
|
125
|
+
return decodeURIComponent(value);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function loginWarnings(signals) {
|
|
132
|
+
const warnings = siteUrlWarnings(signals.url);
|
|
133
|
+
if (!signals.passwords)
|
|
134
|
+
return warnings;
|
|
135
|
+
let page;
|
|
136
|
+
try {
|
|
137
|
+
page = new URL(signals.url);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return warnings;
|
|
141
|
+
}
|
|
142
|
+
if (!/^https?:$/.test(page.protocol))
|
|
143
|
+
return warnings;
|
|
144
|
+
const local = isLocalDevelopmentHost(page.hostname);
|
|
145
|
+
if (page.protocol === 'http:' && !local)
|
|
146
|
+
warnings.push({
|
|
147
|
+
id: 'unencrypted-login', reason: 'This page asks for a password over an unencrypted HTTP connection.',
|
|
148
|
+
});
|
|
149
|
+
for (const form of signals.forms.slice(0, 32)) {
|
|
150
|
+
let action;
|
|
151
|
+
try {
|
|
152
|
+
action = new URL(form.action || signals.url, signals.url);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (action.protocol === 'http:' && !(local && isLocalDevelopmentHost(action.hostname)))
|
|
158
|
+
warnings.push({
|
|
159
|
+
id: `unencrypted-password-destination:${action.origin}`, reason: `A password form sends to an unencrypted address at ${action.hostname}.`,
|
|
160
|
+
});
|
|
161
|
+
if (form.method === 'get')
|
|
162
|
+
warnings.push({
|
|
163
|
+
id: 'password-in-address', reason: 'A password form is configured to put its fields in the website address.',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const title = signals.title.slice(0, 300).toLowerCase();
|
|
167
|
+
if (/\b(sign[ -]?in|log[ -]?in|verify|account|password)\b/.test(title)) {
|
|
168
|
+
for (const [brand, domains] of Object.entries(BRANDS)) {
|
|
169
|
+
if (new RegExp(`\\b${brand}\\b`).test(title) && !hostIn(page.hostname, domains)) {
|
|
170
|
+
// Mere "Sign in with X" buttons without password fields are never enough.
|
|
171
|
+
warnings.push({ id: `login-brand:${brand}`, reason: `This password page presents itself as ${brand}, but its address is ${page.hostname}.` });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return uniqueWarnings(warnings);
|
|
176
|
+
}
|
|
177
|
+
function uniqueWarnings(warnings) {
|
|
178
|
+
return [...new Map(warnings.map((warning) => [warning.id, warning])).values()];
|
|
179
|
+
}
|
|
180
|
+
/** Self-contained isolated-world reader. Never reads field values or page text. */
|
|
181
|
+
function collectLoginSignals() {
|
|
182
|
+
const inputs = Array.from(document.querySelectorAll('input[type="password"]'))
|
|
183
|
+
.filter((input) => !input.disabled && input.getClientRects().length > 0).slice(0, 32);
|
|
184
|
+
const forms = [...new Set(inputs.map((input) => input.form).filter((form) => !!form))];
|
|
185
|
+
return {
|
|
186
|
+
url: location.href, title: document.title.slice(0, 300), passwords: inputs.length,
|
|
187
|
+
forms: forms.map((form) => ({ action: form.action, method: form.getAttribute('method')?.toLowerCase() ?? '' })),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
//# sourceMappingURL=site-risk.js.map
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
import { type KeyObject } from 'node:crypto';
|
|
1
2
|
import { type ClientProof } from '../../shared/client-auth';
|
|
2
3
|
import type { CommandFrame } from '../../shared/protocol';
|
|
3
|
-
|
|
4
|
-
export
|
|
4
|
+
import type { Workspace } from './datadir';
|
|
5
|
+
export interface ClientIdentity {
|
|
5
6
|
id: string;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
publicKey: string;
|
|
8
|
+
profile?: string;
|
|
9
|
+
workspace?: Workspace;
|
|
10
|
+
assertAccess?: () => void;
|
|
11
|
+
sign(cmd: Pick<CommandFrame, 'v' | 'method' | 'params' | 'tabId' | 'timeoutMs'>, challenge: string): ClientProof;
|
|
12
|
+
}
|
|
13
|
+
/** Ordinary app keys are ephemeral; paired phones supply their privately stored key. */
|
|
14
|
+
export declare function createClientIdentity(name: () => string, savedKey?: KeyObject): ClientIdentity;
|
|
9
15
|
export declare const withClientIdentity: <T>(identity: ClientIdentity, fn: () => T) => T;
|
|
10
16
|
export declare const currentClientIdentity: () => ClientIdentity | undefined;
|
|
@@ -5,13 +5,16 @@ exports.createClientIdentity = createClientIdentity;
|
|
|
5
5
|
const node_async_hooks_1 = require("node:async_hooks");
|
|
6
6
|
const node_crypto_1 = require("node:crypto");
|
|
7
7
|
const client_auth_1 = require("../../shared/client-auth");
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const workspace_1 = require("./workspace");
|
|
9
|
+
/** Ordinary app keys are ephemeral; paired phones supply their privately stored key. */
|
|
10
|
+
function createClientIdentity(name, savedKey) {
|
|
11
|
+
const privateKey = savedKey ?? (0, node_crypto_1.generateKeyPairSync)('ec', { namedCurve: 'prime256v1' }).privateKey;
|
|
12
|
+
const publicKey = (0, node_crypto_1.createPublicKey)(privateKey);
|
|
11
13
|
const der = publicKey.export({ type: 'spki', format: 'der' });
|
|
12
14
|
const encoded = der.toString('base64');
|
|
13
15
|
return {
|
|
14
16
|
id: (0, node_crypto_1.createHash)('sha256').update(der).digest('hex'),
|
|
17
|
+
publicKey: encoded,
|
|
15
18
|
sign(cmd, challenge) {
|
|
16
19
|
const proof = { publicKey: encoded, name: (0, client_auth_1.clientName)(name()), challenge, issuedAt: Date.now(), nonce: (0, node_crypto_1.randomUUID)() };
|
|
17
20
|
return { ...proof, signature: (0, node_crypto_1.sign)('sha256', Buffer.from((0, client_auth_1.clientSigningPayload)(cmd, proof)), {
|
|
@@ -21,7 +24,7 @@ function createClientIdentity(name) {
|
|
|
21
24
|
};
|
|
22
25
|
}
|
|
23
26
|
const current = new node_async_hooks_1.AsyncLocalStorage();
|
|
24
|
-
const withClientIdentity = (identity, fn) => current.run(identity, fn);
|
|
27
|
+
const withClientIdentity = (identity, fn) => current.run(identity, () => identity.workspace ? (0, workspace_1.withWorkspace)(identity.workspace, fn) : fn());
|
|
25
28
|
exports.withClientIdentity = withClientIdentity;
|
|
26
29
|
const currentClientIdentity = () => current.getStore();
|
|
27
30
|
exports.currentClientIdentity = currentClientIdentity;
|
|
@@ -14,6 +14,7 @@ function defaultTimeoutFor(method) {
|
|
|
14
14
|
* near-superset; unknown codes degrade to CDP_ERROR while keeping the message). */
|
|
15
15
|
function mapWireErrorCode(code) {
|
|
16
16
|
const known = {
|
|
17
|
+
RESULT_TOO_LARGE: 'RESULT_TOO_LARGE',
|
|
17
18
|
TIMEOUT: 'TIMEOUT',
|
|
18
19
|
POLICY_DENIED: 'POLICY_DENIED',
|
|
19
20
|
DETACHED: 'DETACHED',
|
|
@@ -14,6 +14,7 @@ export type PairState = 'ok' | 'profile_mismatch' | 'token_mismatch' | 'version_
|
|
|
14
14
|
export type ProfileNaming = 'options' | 'auto' | 'legacy';
|
|
15
15
|
/** One paired browser, as reported by chrome_status. */
|
|
16
16
|
export interface PairedProfile {
|
|
17
|
+
siteChecks?: boolean;
|
|
17
18
|
/** Public, extension-generated replay scope; not a credential. */
|
|
18
19
|
approvalChallenge?: string;
|
|
19
20
|
name: string;
|