pi-for-excel-proxy 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Mustier
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # pi-for-excel-proxy
2
+
3
+ Local HTTPS CORS proxy helper for Pi for Excel OAuth logins.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx pi-for-excel-proxy
9
+ ```
10
+
11
+ This command:
12
+
13
+ 1. Ensures `mkcert` exists (installs via Homebrew on macOS if missing)
14
+ 2. Creates certificates in `~/.pi-for-excel/certs/` when needed
15
+ 3. Starts the proxy at `https://localhost:3003`
16
+
17
+ Then in Pi for Excel:
18
+
19
+ 1. Open `/settings`
20
+ 2. Enable **Proxy**
21
+ 3. Set URL to `https://localhost:3003`
22
+ 4. Run `/login`
23
+
24
+ ## Publishing (maintainers)
25
+
26
+ Package source lives in `pkg/proxy/`.
27
+
28
+ Before packing/publishing, `prepack` copies runtime files from repo root:
29
+
30
+ - `scripts/cors-proxy-server.mjs`
31
+ - `scripts/proxy-target-policy.mjs`
32
+
33
+ Publish from this directory:
34
+
35
+ ```bash
36
+ cd pkg/proxy
37
+ npm publish
38
+ ```
package/cli.mjs ADDED
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import process from "node:process";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const cliDir = path.dirname(fileURLToPath(import.meta.url));
11
+ const proxyScriptPath = path.join(cliDir, "scripts", "cors-proxy-server.mjs");
12
+
13
+ const homeDir = os.homedir();
14
+ const appDir = path.join(homeDir, ".pi-for-excel");
15
+ const certDir = path.join(appDir, "certs");
16
+ const keyPath = path.join(certDir, "key.pem");
17
+ const certPath = path.join(certDir, "cert.pem");
18
+
19
+ function commandExists(command) {
20
+ const whichCommand = process.platform === "win32" ? "where" : "which";
21
+ const result = spawnSync(whichCommand, [command], { stdio: "ignore" });
22
+ return result.status === 0;
23
+ }
24
+
25
+ function run(command, args, options = {}) {
26
+ const result = spawnSync(command, args, {
27
+ stdio: "inherit",
28
+ ...options,
29
+ });
30
+
31
+ if (result.error) {
32
+ console.error(`[pi-for-excel-proxy] Failed to run: ${command}`);
33
+ console.error(result.error.message);
34
+ process.exit(1);
35
+ }
36
+
37
+ if (typeof result.status === "number" && result.status !== 0) {
38
+ process.exit(result.status);
39
+ }
40
+
41
+ if (result.signal) {
42
+ console.error(`[pi-for-excel-proxy] ${command} terminated by signal ${result.signal}`);
43
+ process.exit(1);
44
+ }
45
+ }
46
+
47
+ function ensureMkcert() {
48
+ if (commandExists("mkcert")) {
49
+ return;
50
+ }
51
+
52
+ console.log("[pi-for-excel-proxy] mkcert not found.");
53
+
54
+ if (process.platform === "darwin") {
55
+ if (!commandExists("brew")) {
56
+ console.error("[pi-for-excel-proxy] Homebrew is not installed.");
57
+ console.error("[pi-for-excel-proxy] Install Homebrew first: https://brew.sh");
58
+ process.exit(1);
59
+ }
60
+
61
+ console.log("[pi-for-excel-proxy] Installing mkcert via Homebrew...");
62
+ run("brew", ["install", "mkcert"]);
63
+
64
+ if (!commandExists("mkcert")) {
65
+ console.error("[pi-for-excel-proxy] mkcert installation completed but mkcert is still unavailable in PATH.");
66
+ process.exit(1);
67
+ }
68
+
69
+ return;
70
+ }
71
+
72
+ console.error("[pi-for-excel-proxy] Please install mkcert, then run this command again.");
73
+ console.error("[pi-for-excel-proxy] Install instructions: https://github.com/FiloSottile/mkcert#installation");
74
+ process.exit(1);
75
+ }
76
+
77
+ function ensureCertificates() {
78
+ fs.mkdirSync(certDir, { recursive: true });
79
+
80
+ if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
81
+ return;
82
+ }
83
+
84
+ ensureMkcert();
85
+
86
+ console.log("[pi-for-excel-proxy] Generating local HTTPS certificates...");
87
+ run("mkcert", ["-install"]);
88
+ run("mkcert", ["-key-file", keyPath, "-cert-file", certPath, "localhost"], {
89
+ cwd: certDir,
90
+ });
91
+
92
+ if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) {
93
+ console.error("[pi-for-excel-proxy] Failed to generate TLS certificates.");
94
+ process.exit(1);
95
+ }
96
+ }
97
+
98
+ function resolveProxyConfig() {
99
+ const userArgs = process.argv.slice(2);
100
+ const hasExplicitScheme = userArgs.includes("--https") || userArgs.includes("--http");
101
+ const proxyArgs = hasExplicitScheme ? userArgs : ["--https", ...userArgs];
102
+
103
+ const usesHttpOnly = proxyArgs.includes("--http") && !proxyArgs.includes("--https");
104
+
105
+ return {
106
+ proxyArgs,
107
+ usesHttps: !usesHttpOnly,
108
+ };
109
+ }
110
+
111
+ function startProxy(proxyArgs) {
112
+ fs.mkdirSync(certDir, { recursive: true });
113
+ console.log(`[pi-for-excel-proxy] Using certificate directory: ${certDir}`);
114
+
115
+ const child = spawn(process.execPath, [proxyScriptPath, ...proxyArgs], {
116
+ cwd: certDir,
117
+ env: process.env,
118
+ stdio: "inherit",
119
+ });
120
+
121
+ let shuttingDown = false;
122
+
123
+ const forwardSignal = (signal) => {
124
+ if (shuttingDown) {
125
+ return;
126
+ }
127
+ shuttingDown = true;
128
+ if (!child.killed) {
129
+ child.kill(signal);
130
+ }
131
+ };
132
+
133
+ process.on("SIGINT", () => forwardSignal("SIGINT"));
134
+ process.on("SIGTERM", () => forwardSignal("SIGTERM"));
135
+
136
+ child.on("exit", (code, signal) => {
137
+ if (signal) {
138
+ process.kill(process.pid, signal);
139
+ return;
140
+ }
141
+ process.exit(code ?? 1);
142
+ });
143
+
144
+ child.on("error", (error) => {
145
+ console.error("[pi-for-excel-proxy] Failed to start proxy process.");
146
+ console.error(error.message);
147
+ process.exit(1);
148
+ });
149
+ }
150
+
151
+ if (!fs.existsSync(proxyScriptPath)) {
152
+ console.error("[pi-for-excel-proxy] Missing proxy runtime files.");
153
+ console.error("[pi-for-excel-proxy] Reinstall the package or run npm pack again.");
154
+ process.exit(1);
155
+ }
156
+
157
+ const proxyConfig = resolveProxyConfig();
158
+ if (proxyConfig.usesHttps) {
159
+ ensureCertificates();
160
+ }
161
+ startProxy(proxyConfig.proxyArgs);
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "pi-for-excel-proxy",
3
+ "version": "0.1.0",
4
+ "description": "One-command local HTTPS proxy helper for Pi for Excel OAuth logins.",
5
+ "type": "module",
6
+ "bin": {
7
+ "pi-for-excel-proxy": "./cli.mjs"
8
+ },
9
+ "files": [
10
+ "cli.mjs",
11
+ "scripts/*.mjs",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "prepack": "node ../../scripts/sync-proxy-package.mjs"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "license": "MIT"
22
+ }
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Minimal CORS proxy for Pi for Excel.
5
+ *
6
+ * Why this exists:
7
+ * - Some provider OAuth/token endpoints (and some LLM APIs) block browser requests via CORS.
8
+ * - In dev we rely on Vite's proxy. In production, you can run this locally and point
9
+ * Pi for Excel's proxy setting at it (default: https://localhost:3003).
10
+ *
11
+ * Usage:
12
+ * npm run proxy:https # HTTPS (recommended for Office webviews)
13
+ * npm run proxy # HTTP (may be blocked as mixed content)
14
+ *
15
+ * Proxy format:
16
+ * https://localhost:3003/?url=<target-url>
17
+ *
18
+ * Example:
19
+ * curl 'https://localhost:3003/?url=https%3A%2F%2Fexample.com'
20
+ */
21
+
22
+ import http from "node:http";
23
+ import https from "node:https";
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import { lookup as dnsLookup } from "node:dns/promises";
27
+ import { Readable } from "node:stream";
28
+
29
+ import {
30
+ evaluateTargetHostPolicy,
31
+ isIpLiteral,
32
+ normalizeHost,
33
+ parseAllowedTargetHosts,
34
+ } from "./proxy-target-policy.mjs";
35
+
36
+ const args = new Set(process.argv.slice(2));
37
+ const useHttps = args.has("--https") || process.env.HTTPS === "1" || process.env.HTTPS === "true";
38
+ const useHttp = args.has("--http");
39
+
40
+ if (useHttps && useHttp) {
41
+ console.error("[pi-for-excel] Invalid args: can't use both --https and --http");
42
+ process.exit(1);
43
+ }
44
+
45
+ const HOST = process.env.HOST || (useHttps ? "localhost" : "127.0.0.1");
46
+ const PORT = Number.parseInt(process.env.PORT || "3003", 10);
47
+
48
+ const rootDir = path.resolve(process.cwd());
49
+ const keyPath = path.join(rootDir, "key.pem");
50
+ const certPath = path.join(rootDir, "cert.pem");
51
+
52
+ const HOP_BY_HOP_HEADERS = new Set([
53
+ "connection",
54
+ "keep-alive",
55
+ "proxy-authenticate",
56
+ "proxy-authorization",
57
+ "te",
58
+ "trailer",
59
+ "transfer-encoding",
60
+ "upgrade",
61
+ ]);
62
+
63
+ // SECURITY: local CORS proxies are a common footgun. Even if bound to localhost,
64
+ // a browser tab on any origin can still call it unless we restrict CORS.
65
+ // Default allowlist matches our dev + hosted origins; override via env var.
66
+ const DEFAULT_ALLOWED_ORIGINS = new Set([
67
+ "https://localhost:3000",
68
+ "https://pi-for-excel.vercel.app",
69
+ ]);
70
+
71
+ const allowedOrigins = (() => {
72
+ const raw = process.env.ALLOWED_ORIGINS;
73
+ if (!raw) return DEFAULT_ALLOWED_ORIGINS;
74
+ const set = new Set(
75
+ raw
76
+ .split(",")
77
+ .map((s) => s.trim())
78
+ .filter(Boolean),
79
+ );
80
+ return set.size > 0 ? set : DEFAULT_ALLOWED_ORIGINS;
81
+ })();
82
+
83
+ function isAllowedOrigin(origin) {
84
+ return typeof origin === "string" && allowedOrigins.has(origin);
85
+ }
86
+
87
+ function isLoopbackAddress(addr) {
88
+ if (!addr) return false;
89
+ if (addr === "::1" || addr === "0:0:0:0:0:0:0:1") return true;
90
+ if (addr.startsWith("127.")) return true;
91
+ if (addr.startsWith("::ffff:127.")) return true;
92
+ return false;
93
+ }
94
+
95
+ function envFlag(name) {
96
+ const raw = process.env[name];
97
+ return raw === "1" || raw === "true";
98
+ }
99
+
100
+ const DEFAULT_ALLOWED_TARGET_HOSTS = new Set([
101
+ "api.anthropic.com",
102
+ "console.anthropic.com",
103
+ "github.com",
104
+ "api.github.com",
105
+ "auth.openai.com",
106
+ "api.openai.com",
107
+ "chatgpt.com",
108
+ "oauth2.googleapis.com",
109
+ "generativelanguage.googleapis.com",
110
+ "api.z.ai",
111
+ ]);
112
+
113
+ const allowAllTargetHosts = envFlag("ALLOW_ALL_TARGET_HOSTS");
114
+ const allowLoopbackTargets = envFlag("ALLOW_LOOPBACK_TARGETS");
115
+ const allowPrivateTargets = envFlag("ALLOW_PRIVATE_TARGETS");
116
+ const strictTargetResolution = envFlag("STRICT_TARGET_RESOLUTION");
117
+
118
+ const hasConfiguredAllowedTargetHosts =
119
+ typeof process.env.ALLOWED_TARGET_HOSTS === "string"
120
+ && process.env.ALLOWED_TARGET_HOSTS.trim().length > 0;
121
+
122
+ const configuredAllowedTargetHosts = hasConfiguredAllowedTargetHosts
123
+ ? parseAllowedTargetHosts(process.env.ALLOWED_TARGET_HOSTS)
124
+ : new Set();
125
+
126
+ const allowedTargetHosts = (() => {
127
+ if (allowAllTargetHosts) {
128
+ return new Set();
129
+ }
130
+
131
+ if (configuredAllowedTargetHosts.size > 0) {
132
+ return configuredAllowedTargetHosts;
133
+ }
134
+
135
+ return new Set(DEFAULT_ALLOWED_TARGET_HOSTS);
136
+ })();
137
+
138
+ const EMPTY_ALLOWED_TARGET_HOSTS = new Set();
139
+
140
+ const TARGET_POLICY_MESSAGES = {
141
+ blocked_target_invalid_host: "Invalid target host",
142
+ blocked_target_not_allowlisted:
143
+ "Target host is not allowlisted. Configure ALLOWED_TARGET_HOSTS or set ALLOW_ALL_TARGET_HOSTS=1 to disable host allowlisting.",
144
+ blocked_target_loopback: "Loopback target URLs are blocked by default. Set ALLOW_LOOPBACK_TARGETS=1 to override.",
145
+ blocked_target_private_ip: "Private/local target URLs are blocked by default. Set ALLOW_PRIVATE_TARGETS=1 to override.",
146
+ blocked_target_resolution_failed: "Target hostname could not be resolved (STRICT_TARGET_RESOLUTION=1)",
147
+ };
148
+
149
+ function isGitHubEnterpriseOAuthPathname(pathname) {
150
+ return pathname === "/login/device/code" || pathname === "/login/oauth/access_token";
151
+ }
152
+
153
+ function isGitHubEnterpriseCopilotPathname(pathname) {
154
+ return pathname.startsWith("/copilot_internal/");
155
+ }
156
+
157
+ function shouldBypassHostAllowlistForGitHubEnterprise(targetUrl) {
158
+ const hostname = normalizeHost(targetUrl.hostname);
159
+ if (!hostname || isIpLiteral(hostname)) return false;
160
+
161
+ if (isGitHubEnterpriseOAuthPathname(targetUrl.pathname)) {
162
+ return hostname !== "github.com";
163
+ }
164
+
165
+ if (isGitHubEnterpriseCopilotPathname(targetUrl.pathname)) {
166
+ if (hostname === "api.github.com" || hostname === "api.individual.githubcopilot.com") {
167
+ return false;
168
+ }
169
+
170
+ return hostname.startsWith("api.");
171
+ }
172
+
173
+ return false;
174
+ }
175
+
176
+ function setCorsHeaders(req, res) {
177
+ const origin = req.headers.origin;
178
+ if (isAllowedOrigin(origin)) {
179
+ res.setHeader("Access-Control-Allow-Origin", origin);
180
+ res.setHeader("Vary", "Origin");
181
+ }
182
+
183
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
184
+ res.setHeader(
185
+ "Access-Control-Allow-Headers",
186
+ req.headers["access-control-request-headers"] || "*",
187
+ );
188
+ res.setHeader("Access-Control-Expose-Headers", "*");
189
+ res.setHeader("Access-Control-Max-Age", "86400");
190
+ }
191
+
192
+ function rejectWithReason(res, reason) {
193
+ const msg = TARGET_POLICY_MESSAGES[reason] || "forbidden";
194
+ res.statusCode = 403;
195
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
196
+ res.end(`${reason}: ${msg}`);
197
+ }
198
+
199
+ function extractTargetUrl(rawUrl) {
200
+ // rawUrl looks like: /?url=https%3A%2F%2Fapi.example.com/path
201
+ // NOTE: some callers append path segments after the encoded baseUrl,
202
+ // so we decode everything after `url=` rather than using URLSearchParams.
203
+ const idx = rawUrl.indexOf("url=");
204
+ if (idx === -1) return null;
205
+ const encoded = rawUrl.slice(idx + 4);
206
+ const normalized = encoded.replace(/\+/g, "%20");
207
+ try {
208
+ return decodeURIComponent(normalized);
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ function buildOutboundHeaders(inHeaders) {
215
+ const out = new Headers();
216
+ for (const [key, value] of Object.entries(inHeaders)) {
217
+ if (!value) continue;
218
+ const lower = key.toLowerCase();
219
+
220
+ if (lower === "host") continue;
221
+ if (lower === "content-length") continue;
222
+ if (lower === "accept-encoding") continue;
223
+
224
+ // Strip browser-only / CORS-triggering headers (mimic server requests)
225
+ if (lower === "origin") continue;
226
+ if (lower === "referer") continue;
227
+ if (lower.startsWith("sec-fetch-")) continue;
228
+
229
+ // Anthropic uses this header to explicitly enable direct browser access.
230
+ // When proxying we want the upstream to behave like a server-to-server call.
231
+ if (lower === "anthropic-dangerous-direct-browser-access") continue;
232
+
233
+ // Never forward cookies through a generic proxy
234
+ if (lower === "cookie") continue;
235
+
236
+ if (HOP_BY_HOP_HEADERS.has(lower)) continue;
237
+
238
+ if (Array.isArray(value)) {
239
+ for (const v of value) out.append(key, v);
240
+ } else {
241
+ out.set(key, value);
242
+ }
243
+ }
244
+ return out;
245
+ }
246
+
247
+ const handler = async (req, res) => {
248
+ const remote = req.socket?.remoteAddress;
249
+ if (!isLoopbackAddress(remote)) {
250
+ res.statusCode = 403;
251
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
252
+ res.end("forbidden");
253
+ console.warn(`[proxy] blocked non-loopback client: ${remote || "unknown"}`);
254
+ return;
255
+ }
256
+
257
+ const origin = req.headers.origin;
258
+ if (!isAllowedOrigin(origin)) {
259
+ res.statusCode = 403;
260
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
261
+ res.end("forbidden");
262
+ console.warn(`[proxy] blocked request from disallowed origin: ${origin || "(none)"}`);
263
+ return;
264
+ }
265
+
266
+ setCorsHeaders(req, res);
267
+
268
+ if (req.method === "OPTIONS") {
269
+ res.statusCode = 204;
270
+ res.end();
271
+ return;
272
+ }
273
+
274
+ const rawUrl = req.url || "/";
275
+ const target = extractTargetUrl(rawUrl);
276
+ if (!target) {
277
+ res.statusCode = 400;
278
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
279
+ res.end("Missing or invalid ?url=<target-url> query parameter");
280
+ return;
281
+ }
282
+
283
+ let targetUrl;
284
+ try {
285
+ targetUrl = new URL(target);
286
+ } catch {
287
+ res.statusCode = 400;
288
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
289
+ res.end("Invalid target URL");
290
+ return;
291
+ }
292
+
293
+ if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") {
294
+ res.statusCode = 400;
295
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
296
+ res.end("Only http(s) target URLs are supported");
297
+ return;
298
+ }
299
+
300
+ const targetHost = normalizeHost(targetUrl.hostname);
301
+ const safeTarget = `${targetUrl.origin}${targetUrl.pathname}`;
302
+
303
+ let resolvedIps = [];
304
+ if (!isIpLiteral(targetHost)) {
305
+ try {
306
+ const records = await dnsLookup(targetHost, { all: true, verbatim: true });
307
+ resolvedIps = records.map((r) => r.address);
308
+ } catch (err) {
309
+ const errorText = err instanceof Error ? err.message : String(err);
310
+ if (strictTargetResolution) {
311
+ rejectWithReason(res, "blocked_target_resolution_failed");
312
+ console.warn(`[proxy] blocked target (blocked_target_resolution_failed): ${safeTarget} (${errorText})`);
313
+ return;
314
+ }
315
+ console.warn(`[proxy] DNS lookup failed for ${targetHost}: ${errorText} (continuing)`);
316
+ }
317
+ }
318
+
319
+ const bypassHostAllowlistForGitHubEnterprise =
320
+ !allowAllTargetHosts
321
+ && configuredAllowedTargetHosts.size === 0
322
+ && shouldBypassHostAllowlistForGitHubEnterprise(targetUrl);
323
+
324
+ const effectiveAllowedTargetHosts = bypassHostAllowlistForGitHubEnterprise
325
+ ? EMPTY_ALLOWED_TARGET_HOSTS
326
+ : allowedTargetHosts;
327
+
328
+ const targetPolicy = evaluateTargetHostPolicy({
329
+ hostname: targetHost,
330
+ resolvedIps,
331
+ allowLoopbackTargets,
332
+ allowPrivateTargets,
333
+ allowedHosts: effectiveAllowedTargetHosts,
334
+ });
335
+
336
+ if (!targetPolicy.allowed) {
337
+ const reason = targetPolicy.reason || "forbidden";
338
+ rejectWithReason(res, reason);
339
+ console.warn(`[proxy] blocked target (${reason}): ${safeTarget}`);
340
+ return;
341
+ }
342
+
343
+ if (bypassHostAllowlistForGitHubEnterprise) {
344
+ console.log(`[proxy] allowing GitHub enterprise endpoint outside default host allowlist: ${safeTarget}`);
345
+ }
346
+
347
+ try {
348
+ const startedAt = Date.now();
349
+ const headers = buildOutboundHeaders(req.headers);
350
+
351
+ const hasBody = req.method && !["GET", "HEAD"].includes(req.method);
352
+ const body = hasBody ? Readable.toWeb(req) : undefined;
353
+
354
+ const upstream = await fetch(targetUrl.toString(), {
355
+ method: req.method,
356
+ headers,
357
+ body,
358
+ // Required when using a stream body in Node fetch
359
+ ...(body ? { duplex: "half" } : {}),
360
+ redirect: "manual",
361
+ });
362
+
363
+ // Log without query string to avoid leaking tokens
364
+ console.log(`[proxy] ${req.method || "GET"} ${safeTarget} -> ${upstream.status} (${Date.now() - startedAt}ms)`);
365
+
366
+ res.statusCode = upstream.status;
367
+
368
+ // Copy response headers (but keep our CORS headers)
369
+ upstream.headers.forEach((value, key) => {
370
+ const lower = key.toLowerCase();
371
+ if (lower === "set-cookie") return;
372
+ if (HOP_BY_HOP_HEADERS.has(lower)) return;
373
+ // Node fetch transparently decompresses responses but keeps the original
374
+ // Content-Encoding header (e.g. "gzip"). Forwarding that header would
375
+ // make the browser try to decompress *again* and fail while reading.
376
+ if (lower === "content-encoding") return;
377
+
378
+ // Content-Length can be wrong after decompression; let Node set it.
379
+ if (lower === "content-length") return;
380
+ res.setHeader(key, value);
381
+ });
382
+
383
+ if (!upstream.body) {
384
+ res.end();
385
+ return;
386
+ }
387
+
388
+ const nodeStream = Readable.fromWeb(upstream.body);
389
+ nodeStream.on("error", () => {
390
+ try {
391
+ res.end();
392
+ } catch {
393
+ // ignore
394
+ }
395
+ });
396
+ nodeStream.pipe(res);
397
+ } catch (err) {
398
+ console.warn(`[proxy] ${req.method || "GET"} ${targetUrl.origin}${targetUrl.pathname} -> ERROR (${err instanceof Error ? err.message : String(err)})`);
399
+ res.statusCode = 502;
400
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
401
+ res.end(`Proxy error: ${err instanceof Error ? err.message : String(err)}`);
402
+ }
403
+ };
404
+
405
+ const server = (() => {
406
+ if (!useHttps) {
407
+ return http.createServer(handler);
408
+ }
409
+
410
+ if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) {
411
+ console.error("[pi-for-excel] HTTPS requested but key.pem/cert.pem not found in repo root.");
412
+ console.error("Generate them with mkcert (see README). Example: mkcert localhost");
413
+ process.exit(1);
414
+ }
415
+
416
+ return https.createServer(
417
+ {
418
+ key: fs.readFileSync(keyPath),
419
+ cert: fs.readFileSync(certPath),
420
+ },
421
+ handler,
422
+ );
423
+ })();
424
+
425
+ server.listen(PORT, HOST, () => {
426
+ const scheme = useHttps ? "https" : "http";
427
+ console.log(`[pi-for-excel] CORS proxy listening on ${scheme}://${HOST}:${PORT}`);
428
+ console.log(`[pi-for-excel] Format: ${scheme}://${HOST}:${PORT}/?url=<target-url>`);
429
+ console.log(`[pi-for-excel] Allowed origins: ${Array.from(allowedOrigins).join(", ")}`);
430
+
431
+ if (allowAllTargetHosts) {
432
+ console.log("[pi-for-excel] WARNING: target host allowlisting disabled (ALLOW_ALL_TARGET_HOSTS=1)");
433
+ } else {
434
+ const source = configuredAllowedTargetHosts.size > 0 ? "ALLOWED_TARGET_HOSTS" : "default";
435
+ console.log(`[pi-for-excel] Allowed target hosts (${source}): ${Array.from(allowedTargetHosts).join(", ")}`);
436
+
437
+ if (configuredAllowedTargetHosts.size === 0) {
438
+ console.log("[pi-for-excel] GitHub enterprise OAuth/Copilot endpoints on custom domains are allowed by path.");
439
+ }
440
+ }
441
+
442
+ if (hasConfiguredAllowedTargetHosts && configuredAllowedTargetHosts.size === 0) {
443
+ console.warn("[pi-for-excel] WARNING: ALLOWED_TARGET_HOSTS had no valid entries; using default allowlist.");
444
+ }
445
+
446
+ if (allowLoopbackTargets) {
447
+ console.log("[pi-for-excel] WARNING: loopback target blocking disabled (ALLOW_LOOPBACK_TARGETS=1)");
448
+ }
449
+
450
+ if (allowPrivateTargets) {
451
+ console.log("[pi-for-excel] WARNING: private/local target blocking disabled (ALLOW_PRIVATE_TARGETS=1)");
452
+ }
453
+
454
+ if (strictTargetResolution) {
455
+ console.log("[pi-for-excel] Strict DNS resolution enabled (STRICT_TARGET_RESOLUTION=1)");
456
+ }
457
+ });
@@ -0,0 +1,251 @@
1
+ import net from "node:net";
2
+
3
+ const LOOPBACK_IPV4 = new net.BlockList();
4
+ LOOPBACK_IPV4.addSubnet("127.0.0.0", 8, "ipv4");
5
+
6
+ const LOOPBACK_IPV6 = new net.BlockList();
7
+ LOOPBACK_IPV6.addAddress("::1", "ipv6");
8
+
9
+ const PRIVATE_LOCAL_IPV4 = new net.BlockList();
10
+ PRIVATE_LOCAL_IPV4.addSubnet("10.0.0.0", 8, "ipv4");
11
+ PRIVATE_LOCAL_IPV4.addSubnet("172.16.0.0", 12, "ipv4");
12
+ PRIVATE_LOCAL_IPV4.addSubnet("192.168.0.0", 16, "ipv4");
13
+ PRIVATE_LOCAL_IPV4.addSubnet("169.254.0.0", 16, "ipv4");
14
+ PRIVATE_LOCAL_IPV4.addSubnet("127.0.0.0", 8, "ipv4");
15
+
16
+ const PRIVATE_LOCAL_IPV6 = new net.BlockList();
17
+ PRIVATE_LOCAL_IPV6.addAddress("::1", "ipv6");
18
+ PRIVATE_LOCAL_IPV6.addSubnet("fc00::", 7, "ipv6");
19
+ PRIVATE_LOCAL_IPV6.addSubnet("fe80::", 10, "ipv6");
20
+
21
+ const IPV4_MAPPED_IPV6_RE = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
22
+
23
+ /** Normalize host/hostname strings to a canonical comparison form. */
24
+ export function normalizeHost(hostname) {
25
+ if (typeof hostname !== "string") return "";
26
+
27
+ let host = hostname.trim().toLowerCase();
28
+ if (!host) return "";
29
+
30
+ if (host.startsWith("[") && host.endsWith("]")) {
31
+ host = host.slice(1, -1);
32
+ }
33
+
34
+ // Strip IPv6 zone index (e.g. fe80::1%lo0).
35
+ const zoneIndex = host.indexOf("%");
36
+ if (zoneIndex >= 0) {
37
+ host = host.slice(0, zoneIndex);
38
+ }
39
+
40
+ return host;
41
+ }
42
+
43
+ function mappedIPv4FromIPv6(hostname) {
44
+ const host = normalizeHost(hostname);
45
+ const match = IPV4_MAPPED_IPV6_RE.exec(host);
46
+ if (!match) return null;
47
+
48
+ const candidate = match[1];
49
+ return net.isIP(candidate) === 4 ? candidate : null;
50
+ }
51
+
52
+ function checkBlockList(list, ip) {
53
+ const family = net.isIP(ip);
54
+ if (family === 4) return list.check(ip, "ipv4");
55
+ if (family === 6) return list.check(ip, "ipv6");
56
+ return false;
57
+ }
58
+
59
+ /** Parse ALLOWED_TARGET_HOSTS env var into a normalized host set. */
60
+ export function parseAllowedTargetHosts(raw) {
61
+ if (typeof raw !== "string" || raw.trim().length === 0) {
62
+ return new Set();
63
+ }
64
+
65
+ const out = new Set();
66
+ for (const entry of raw.split(",")) {
67
+ const trimmed = entry.trim();
68
+ if (!trimmed) continue;
69
+
70
+ let host = "";
71
+
72
+ if (trimmed.includes("://")) {
73
+ try {
74
+ host = normalizeHost(new URL(trimmed).hostname);
75
+ } catch {
76
+ host = "";
77
+ }
78
+ } else {
79
+ host = normalizeHost(trimmed);
80
+ }
81
+
82
+ if (host) out.add(host);
83
+ }
84
+
85
+ return out;
86
+ }
87
+
88
+ export function isIpLiteral(hostname) {
89
+ const host = normalizeHost(hostname);
90
+ if (!host) return false;
91
+ return net.isIP(host) !== 0;
92
+ }
93
+
94
+ export function isLoopbackHostname(hostname) {
95
+ const host = normalizeHost(hostname);
96
+ if (!host) return false;
97
+
98
+ if (host === "localhost") return true;
99
+
100
+ if (checkBlockList(LOOPBACK_IPV4, host)) return true;
101
+ if (checkBlockList(LOOPBACK_IPV6, host)) return true;
102
+
103
+ const mapped = mappedIPv4FromIPv6(host);
104
+ return mapped !== null && checkBlockList(LOOPBACK_IPV4, mapped);
105
+ }
106
+
107
+ /**
108
+ * True for loopback, RFC1918, and link-local addresses.
109
+ */
110
+ export function isPrivateOrLocalIp(ip) {
111
+ const host = normalizeHost(ip);
112
+ if (!host) return false;
113
+
114
+ if (checkBlockList(PRIVATE_LOCAL_IPV4, host)) return true;
115
+ if (checkBlockList(PRIVATE_LOCAL_IPV6, host)) return true;
116
+
117
+ const mapped = mappedIPv4FromIPv6(host);
118
+ return mapped !== null && checkBlockList(PRIVATE_LOCAL_IPV4, mapped);
119
+ }
120
+
121
+ /**
122
+ * Host allowlist check.
123
+ * - Empty allowlist => allow all hosts.
124
+ * - Non-empty allowlist => exact normalized host match.
125
+ */
126
+ export function isAllowedTargetHost(hostname, allowedHosts) {
127
+ const host = normalizeHost(hostname);
128
+ if (!host) return false;
129
+
130
+ if (!(allowedHosts instanceof Set) || allowedHosts.size === 0) {
131
+ return true;
132
+ }
133
+
134
+ return allowedHosts.has(host);
135
+ }
136
+
137
+ /**
138
+ * Hostname-only block decision (no DNS resolution context).
139
+ */
140
+ export function getBlockedTargetReasonForHostname(hostname, opts = {}) {
141
+ const {
142
+ allowLoopbackTargets = false,
143
+ allowPrivateTargets = false,
144
+ allowedHosts = new Set(),
145
+ } = opts;
146
+
147
+ const host = normalizeHost(hostname);
148
+ if (!host) return "blocked_target_invalid_host";
149
+
150
+ const loopback = isLoopbackHostname(host);
151
+ if (loopback && !allowLoopbackTargets) {
152
+ return "blocked_target_loopback";
153
+ }
154
+
155
+ // Preserve legacy semantics: if loopback is explicitly allowed, do not
156
+ // re-block it under private/local checks or host allowlists.
157
+ if (loopback && allowLoopbackTargets) {
158
+ return null;
159
+ }
160
+
161
+ const privateOrLocalLiteral = isIpLiteral(host) && isPrivateOrLocalIp(host);
162
+ if (!allowPrivateTargets && privateOrLocalLiteral) {
163
+ return "blocked_target_private_ip";
164
+ }
165
+
166
+ // Preserve legacy semantics: if private/local literal targets are explicitly
167
+ // allowed, do not re-block them under host allowlists.
168
+ if (allowPrivateTargets && privateOrLocalLiteral) {
169
+ return null;
170
+ }
171
+
172
+ if (!isAllowedTargetHost(host, allowedHosts)) {
173
+ return "blocked_target_not_allowlisted";
174
+ }
175
+
176
+ return null;
177
+ }
178
+
179
+ /**
180
+ * DNS-resolution-based block decision.
181
+ * Resolved IPs should come from dns.lookup(host, { all: true }).
182
+ */
183
+ export function getBlockedTargetReasonForResolvedIps(resolvedIps, opts = {}) {
184
+ const {
185
+ allowLoopbackTargets = false,
186
+ allowPrivateTargets = false,
187
+ } = opts;
188
+
189
+ if (!Array.isArray(resolvedIps) || resolvedIps.length === 0) {
190
+ return null;
191
+ }
192
+
193
+ for (const ip of resolvedIps) {
194
+ const normalized = normalizeHost(ip);
195
+ if (!normalized) continue;
196
+
197
+ const loopback = isLoopbackHostname(normalized);
198
+ if (loopback && !allowLoopbackTargets) {
199
+ return "blocked_target_loopback";
200
+ }
201
+
202
+ if (loopback && allowLoopbackTargets) {
203
+ continue;
204
+ }
205
+
206
+ if (!allowPrivateTargets && isPrivateOrLocalIp(normalized)) {
207
+ return "blocked_target_private_ip";
208
+ }
209
+ }
210
+
211
+ return null;
212
+ }
213
+
214
+ /**
215
+ * Final target policy decision used by proxy server.
216
+ */
217
+ export function evaluateTargetHostPolicy(opts = {}) {
218
+ const {
219
+ hostname,
220
+ resolvedIps = [],
221
+ allowLoopbackTargets = false,
222
+ allowPrivateTargets = false,
223
+ allowedHosts = new Set(),
224
+ } = opts;
225
+
226
+ const hostReason = getBlockedTargetReasonForHostname(hostname, {
227
+ allowLoopbackTargets,
228
+ allowPrivateTargets,
229
+ allowedHosts,
230
+ });
231
+
232
+ if (hostReason) {
233
+ return { allowed: false, reason: hostReason };
234
+ }
235
+
236
+ const dnsReason = getBlockedTargetReasonForResolvedIps(resolvedIps, {
237
+ allowLoopbackTargets,
238
+ allowPrivateTargets,
239
+ });
240
+
241
+ if (dnsReason) {
242
+ return { allowed: false, reason: dnsReason };
243
+ }
244
+
245
+ return { allowed: true };
246
+ }
247
+
248
+ /** Backward-compatible convenience helper. */
249
+ export function isBlockedTargetByHostname(hostname) {
250
+ return getBlockedTargetReasonForHostname(hostname) !== null;
251
+ }