@underactive/pi-topping-web-tools 0.1.0

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.
@@ -0,0 +1,162 @@
1
+ /**
2
+ * URL validation and host permission gating for web_browser.
3
+ *
4
+ * Security model mirrors pi-fetch-markdown-tool: preapproved host allowlist plus
5
+ * per-session user confirmation for other hosts. The allowlist lives in the
6
+ * shared src/permissions.ts module.
7
+ */
8
+
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { isPreapprovedHost } from "../permissions.ts";
11
+
12
+ export const MAX_URL_LENGTH = 2000;
13
+
14
+ export function validateURL(url: string): boolean {
15
+ if (url.length > MAX_URL_LENGTH) {
16
+ return false;
17
+ }
18
+
19
+ let parsed: URL;
20
+ try {
21
+ parsed = new URL(url);
22
+ } catch {
23
+ return false;
24
+ }
25
+
26
+ if (parsed.username || parsed.password) {
27
+ return false;
28
+ }
29
+
30
+ if (parsed.protocol === "file:") {
31
+ return true;
32
+ }
33
+
34
+ const hostname = parsed.hostname;
35
+ const parts = hostname.split(".");
36
+ if (parts.length < 2) {
37
+ const isLocalhost = hostname === "localhost";
38
+ const isIpv6Loopback = hostname === "::1" || hostname === "[::1]";
39
+ if (!isLocalhost && !isIpv6Loopback) {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
45
+ }
46
+
47
+ export function isLocalOrPrivateHost(hostname: string): boolean {
48
+ const host =
49
+ hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
50
+
51
+ if (host === "localhost" || host === "::1") {
52
+ return true;
53
+ }
54
+ if (host.endsWith(".local")) {
55
+ return true;
56
+ }
57
+ if (/^(fc|fd|fe80:)/i.test(host) || /^::ffff:7f/i.test(host)) {
58
+ return true;
59
+ }
60
+
61
+ if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) {
62
+ return false;
63
+ }
64
+
65
+ const octets = host.split(".").map((part) => Number.parseInt(part, 10));
66
+ if (octets.some((n) => n > 255)) {
67
+ return false;
68
+ }
69
+
70
+ const [a, b] = octets;
71
+ if (a === 127) return true;
72
+ if (a === 0) return true;
73
+ if (a === 10) return true;
74
+ if (a === 172 && b >= 16 && b <= 31) return true;
75
+ if (a === 192 && b === 168) return true;
76
+ if (a === 169 && b === 254) return true;
77
+
78
+ return false;
79
+ }
80
+
81
+ export function upgradeHttpToHttps(url: string): string {
82
+ try {
83
+ const parsed = new URL(url);
84
+ if (parsed.protocol === "http:" && !isLocalOrPrivateHost(parsed.hostname)) {
85
+ parsed.protocol = "https:";
86
+ return parsed.toString();
87
+ }
88
+ } catch {
89
+ // fall through
90
+ }
91
+ return url;
92
+ }
93
+
94
+ export function permissionKey(url: string): string {
95
+ try {
96
+ const parsed = new URL(url);
97
+ if (parsed.protocol === "file:") {
98
+ return `file://${parsed.pathname}`;
99
+ }
100
+ return `${parsed.protocol}//${parsed.host}`;
101
+ } catch {
102
+ return url;
103
+ }
104
+ }
105
+
106
+ export type PermissionResult = "allow" | `block:${string}`;
107
+
108
+ export async function checkUrlPermission(
109
+ url: string,
110
+ ctx: ExtensionContext,
111
+ sessionPermissions: Map<string, "allow" | "deny">,
112
+ ): Promise<PermissionResult> {
113
+ if (!validateURL(url)) {
114
+ return "block:Invalid URL";
115
+ }
116
+
117
+ let hostname: string;
118
+ let pathname: string;
119
+ let protocol: string;
120
+ try {
121
+ const parsed = new URL(url);
122
+ hostname = parsed.hostname;
123
+ pathname = parsed.pathname;
124
+ protocol = parsed.protocol;
125
+ } catch {
126
+ return "block:Invalid URL";
127
+ }
128
+
129
+ if (protocol !== "file:" && isPreapprovedHost(hostname, pathname)) {
130
+ return "allow";
131
+ }
132
+
133
+ const key = permissionKey(url);
134
+ const sessionDecision = sessionPermissions.get(key);
135
+ if (sessionDecision === "allow") {
136
+ return "allow";
137
+ }
138
+ if (sessionDecision === "deny") {
139
+ return `block:Denied by user for ${key}`;
140
+ }
141
+
142
+ if (!ctx.hasUI) {
143
+ return `block:web_browser to ${key} blocked (no UI for confirmation)`;
144
+ }
145
+
146
+ const label = protocol === "file:" ? `Allow web_browser to local file ${pathname}?` : `Allow web_browser to ${hostname}?`;
147
+ const choice = await ctx.ui.select(label, ["Allow once", "Allow for this session", "Deny"]);
148
+
149
+ if (choice === "Allow for this session") {
150
+ sessionPermissions.set(key, "allow");
151
+ return "allow";
152
+ }
153
+ if (choice === "Deny") {
154
+ sessionPermissions.set(key, "deny");
155
+ return `block:Denied by user for ${key}`;
156
+ }
157
+ if (choice === "Allow once") {
158
+ return "allow";
159
+ }
160
+
161
+ return `block:Denied by user for ${key}`;
162
+ }