pi-ele-picker 0.3.1 → 0.3.2
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 +1 -1
- package/extensions/pi-picker.ts +90 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -131,7 +131,7 @@ The same payload is available from the bridge directly:
|
|
|
131
131
|
"provider": "anthropic", "pid": 12345, "cwd": "…", "updatedAt": 1700000000000 }
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
-
`/status` needs no token, but non-
|
|
134
|
+
`/status` needs no token, but non-local browser origins get `403`, so an arbitrary website cannot probe session metadata. "Local" covers `localhost` / `*.localhost`, `127.0.0.0/8`, `0.0.0.0`, `[::1]`, and private or link-local IPv4 literals (`10.x.x.x`, `172.16-31.x.x`, `192.168.x.x`, `169.254.x.x`) — so a dev server you open over your machine's LAN IPv4 address (Vite binds `0.0.0.0` by default) works, while an arbitrary site does not.
|
|
135
135
|
|
|
136
136
|
## Notes
|
|
137
137
|
|
package/extensions/pi-picker.ts
CHANGED
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
* Wire protocol (POST /pick):
|
|
13
13
|
* headers: content-type: application/json
|
|
14
14
|
* x-pi-picker-token: <token from the registry file>
|
|
15
|
-
* origin:
|
|
15
|
+
* origin: a local origin — http(s)://localhost:<port>,
|
|
16
|
+
* http(s)://127.0.0.1:<port>, or any loopback /
|
|
17
|
+
* private IPv4 literal (192.168.x.x, 10.x.x.x,
|
|
18
|
+
* 172.16-31.x.x, 169.254.x.x, 0.0.0.0)
|
|
16
19
|
* body: { text?, images?: [{name, dataUrl}], element?: {...}, url?, title?, submit? }
|
|
17
20
|
* reply: { ok: true, mode, inserted } | { ok: true, mode, submitted, queued? }
|
|
18
21
|
* | { ok: false, error }
|
|
@@ -22,9 +25,9 @@
|
|
|
22
25
|
* mode, sessionId, model, provider, pid, cwd, updatedAt }
|
|
23
26
|
*
|
|
24
27
|
* `/status` is polled by the browser picker to render a live session badge (the
|
|
25
|
-
* launcher dot + the panel status line). It has no token: any
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
+
* launcher dot + the panel status line). It has no token: any local-origin page
|
|
29
|
+
* may read it, but non-local browser origins are rejected so an arbitrary website
|
|
30
|
+
* cannot probe session metadata.
|
|
28
31
|
*
|
|
29
32
|
* Two delivery modes:
|
|
30
33
|
* - default (submit falsy): pre-fill the current pi input editor for review.
|
|
@@ -50,12 +53,79 @@ function log(msg: string): void {
|
|
|
50
53
|
}
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
/** Loopback / private / link-local / "this host" IPv4 literals. */
|
|
57
|
+
function isLocalIpv4(ip: string): boolean {
|
|
58
|
+
const parts = ip.split('.');
|
|
59
|
+
if (parts.length !== 4) return false;
|
|
60
|
+
const octets: number[] = [];
|
|
61
|
+
for (const p of parts) {
|
|
62
|
+
if (!/^\d{1,3}$/.test(p)) return false;
|
|
63
|
+
const n = Number(p);
|
|
64
|
+
if (n > 255) return false;
|
|
65
|
+
octets.push(n);
|
|
66
|
+
}
|
|
67
|
+
const [a, b] = octets;
|
|
68
|
+
if (a === 127 || a === 0) return true; // 127.0.0.0/8 loopback · 0.0.0.0 "this host"
|
|
69
|
+
if (a === 10) return true; // 10.0.0.0/8
|
|
70
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
|
|
71
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16
|
|
72
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `::ffff:7f00:1` (the hex form WHATWG normalises IPv4-mapped IPv6 to) and the
|
|
78
|
+
* literal `::ffff:127.0.0.1` both become `127.0.0.1`; anything else is null.
|
|
79
|
+
*/
|
|
80
|
+
function v4MappedToIpv4(host: string): string | null {
|
|
81
|
+
const m = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
|
|
82
|
+
if (!m) return null;
|
|
83
|
+
const hi = parseInt(m[1], 16);
|
|
84
|
+
const lo = parseInt(m[2], 16);
|
|
85
|
+
return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join('.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Is this hostname the developer's own machine?
|
|
90
|
+
*
|
|
91
|
+
* The picker is injected by the developer's Vite dev server, which Vite binds to
|
|
92
|
+
* `0.0.0.0` by default — so the page is routinely opened as
|
|
93
|
+
* `http://0.0.0.0:5173`, `http://192.168.1.5:5173`, or `http://[::1]:5173`
|
|
94
|
+
* rather than `http://localhost:5173`. All of those are still local, so they are
|
|
95
|
+
* accepted alongside `localhost` / `*.localhost` / `127.0.0.1`.
|
|
96
|
+
*
|
|
97
|
+
* Only literal addresses are accepted: a *name* (`dev.internal`, `evil.com`)
|
|
98
|
+
* never passes, which keeps the DNS-rebinding defence intact — that attack needs
|
|
99
|
+
* a name in the Host header pointed at 127.0.0.1.
|
|
100
|
+
*/
|
|
101
|
+
function isLocalHostname(hostname: string): boolean {
|
|
102
|
+
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
103
|
+
if (host === 'localhost' || host.endsWith('.localhost')) return true;
|
|
104
|
+
if (host === '::1') return true;
|
|
105
|
+
if (host.startsWith('::ffff:')) {
|
|
106
|
+
return isLocalIpv4(v4MappedToIpv4(host) ?? host.slice('::ffff:'.length));
|
|
107
|
+
}
|
|
108
|
+
return isLocalIpv4(host);
|
|
55
109
|
}
|
|
56
110
|
|
|
57
|
-
|
|
58
|
-
|
|
111
|
+
/** Validate a browser `Origin` header (`scheme://host[:port]`). */
|
|
112
|
+
function isLocalOrigin(origin: unknown): boolean {
|
|
113
|
+
if (typeof origin !== 'string' || !origin) return false;
|
|
114
|
+
let url: URL;
|
|
115
|
+
try {
|
|
116
|
+
url = new URL(origin);
|
|
117
|
+
} catch {
|
|
118
|
+
return false; // `null` (file://, sandboxed iframes) and junk land here
|
|
119
|
+
}
|
|
120
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
121
|
+
return isLocalHostname(url.hostname);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Validate a raw `Host` header (`host[:port]` or `[ipv6][:port]`). */
|
|
125
|
+
function isLocalHost(host: unknown): boolean {
|
|
126
|
+
if (typeof host !== 'string' || !host) return false;
|
|
127
|
+
const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(host);
|
|
128
|
+
return isLocalHostname(bracketed ? bracketed[1] : host.replace(/:\d+$/, ''));
|
|
59
129
|
}
|
|
60
130
|
|
|
61
131
|
function isPidAlive(pid: number): boolean {
|
|
@@ -297,6 +367,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
297
367
|
res.setHeader('access-control-allow-headers', 'content-type,x-pi-picker-token');
|
|
298
368
|
res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
|
|
299
369
|
if (req.method === 'OPTIONS') {
|
|
370
|
+
// Chrome's Private Network Access: a page served from a LAN address is a
|
|
371
|
+
// "private" context while the bridge on 127.0.0.1 is "local", so the
|
|
372
|
+
// browser preflights with this header before any fetch. Grant the
|
|
373
|
+
// permission here — the real origin gate still runs on POST /pick below.
|
|
374
|
+
if (req.headers['access-control-request-private-network'] === 'true') {
|
|
375
|
+
res.setHeader('access-control-allow-private-network', 'true');
|
|
376
|
+
}
|
|
300
377
|
res.writeHead(204);
|
|
301
378
|
res.end();
|
|
302
379
|
return;
|
|
@@ -311,9 +388,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
311
388
|
}
|
|
312
389
|
|
|
313
390
|
if (req.method === 'GET' && url === '/status') {
|
|
314
|
-
// Read-only and tokenless, so gate it on a
|
|
391
|
+
// Read-only and tokenless, so gate it on a local browser origin: reject a
|
|
315
392
|
// foreign page while still allowing no-Origin callers (curl, local scripts).
|
|
316
|
-
if (origin && !
|
|
393
|
+
if (origin && !isLocalOrigin(origin)) {
|
|
317
394
|
res.writeHead(403, { 'content-type': 'application/json' });
|
|
318
395
|
res.end(JSON.stringify({ ok: false, error: 'forbidden origin' }));
|
|
319
396
|
return;
|
|
@@ -330,12 +407,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
330
407
|
}
|
|
331
408
|
|
|
332
409
|
// SECURITY: block cross-site / DNS-rebinding requests from arbitrary pages.
|
|
333
|
-
|
|
410
|
+
// Local dev servers that are opened via a LAN IPv4 address still count as local.
|
|
411
|
+
if (!isLocalOrigin(origin)) {
|
|
334
412
|
res.writeHead(403, { 'content-type': 'application/json' });
|
|
335
413
|
res.end(JSON.stringify({ ok: false, error: 'forbidden origin' }));
|
|
336
414
|
return;
|
|
337
415
|
}
|
|
338
|
-
if (!
|
|
416
|
+
if (!isLocalHost(req.headers.host)) {
|
|
339
417
|
res.writeHead(403, { 'content-type': 'application/json' });
|
|
340
418
|
res.end(JSON.stringify({ ok: false, error: 'forbidden host' }));
|
|
341
419
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ele-picker",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Pick an element in your running page, describe the change, and send it to a pi session. Ships a Vite dev plugin (browser picker + discovery endpoint) and the matching pi bridge extension.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|