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 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-loopback browser origins get `403`, so an arbitrary website cannot probe session metadata.
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
 
@@ -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: http://localhost:<port> | http://127.0.0.1:<port>
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 loopback-origin
26
- * page may read it, but non-loopback browser origins are rejected so an arbitrary
27
- * website cannot probe session metadata.
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
- function isLoopbackOrigin(origin: unknown): boolean {
54
- return typeof origin === 'string' && /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
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
- function isLoopbackHost(host: unknown): boolean {
58
- return typeof host === 'string' && /^(localhost|127\.0\.0\.1)(:\d+)?$/.test(host);
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 loopback browser origin: reject 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 && !isLoopbackOrigin(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
- if (!isLoopbackOrigin(origin)) {
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 (!isLoopbackHost(req.headers.host)) {
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.1",
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",