[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
514
electron/bridges/aiBridge/providerHandlers.cjs
Normal file
514
electron/bridges/aiBridge/providerHandlers.cjs
Normal file
@@ -0,0 +1,514 @@
|
||||
/* eslint-disable no-undef */
|
||||
const { existsSync } = require("node:fs");
|
||||
|
||||
function registerProviderHandlers(ctx) {
|
||||
with (ctx) {
|
||||
ipcMain.handle("netcatty:ai:user-skills:status", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const status = await scanUserSkills(electronModule?.app);
|
||||
return { ok: true, ...toPublicUserSkillsStatus(status) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:user-skills:open", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const status = await scanUserSkills(electronModule?.app);
|
||||
const openResult = await electronModule?.shell?.openPath?.(status.directoryPath);
|
||||
return {
|
||||
ok: !openResult,
|
||||
error: openResult || undefined,
|
||||
...toPublicUserSkillsStatus(status),
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:user-skills:build-context", async (event, { prompt, selectedSkillSlugs }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const { context, status } = await buildUserSkillsContext(electronModule?.app, prompt, selectedSkillSlugs);
|
||||
return { ok: true, context, status: toPublicUserSkillsStatus(status) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:skills-cli:invocation", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const invocation = getSkillsCliInvocation();
|
||||
return {
|
||||
ok: true,
|
||||
skillPath: existsSync(NETCATTY_TOOL_SKILL_PATH) ? NETCATTY_TOOL_SKILL_PATH : null,
|
||||
commandPrefix: invocation.commandPrefix,
|
||||
launcherPath: invocation.launcherPath,
|
||||
usesLauncher: invocation.usesLauncher,
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
// ── Provider config sync (renderer → main, keys stay encrypted) ──
|
||||
ipcMain.handle("netcatty:ai:sync-providers", async (event, { providers }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false };
|
||||
if (Array.isArray(providers)) {
|
||||
providerConfigs = providers;
|
||||
rebuildProviderFetchHosts();
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// ── Web search config sync (renderer → main, for fetch allowlist + key decryption) ──
|
||||
ipcMain.handle("netcatty:ai:sync-web-search", async (event, { apiHost, apiKey }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false };
|
||||
webSearchApiHost = typeof apiHost === "string" ? apiHost : null;
|
||||
webSearchApiKeyEncrypted = typeof apiKey === "string" ? apiKey : null;
|
||||
rebuildProviderFetchHosts();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/**
|
||||
* Inject the decrypted web search API key into request headers.
|
||||
* Replaces __WEB_SEARCH_KEY__ placeholder, similar to __IPC_SECURED__ for providers.
|
||||
*/
|
||||
function injectWebSearchKeyIntoHeaders(headers) {
|
||||
if (!webSearchApiKeyEncrypted || !headers) return headers;
|
||||
const realKey = decryptApiKeyValue(webSearchApiKeyEncrypted);
|
||||
if (!realKey) return headers;
|
||||
const patched = {};
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
patched[k] = typeof v === "string" ? v.replace(WEB_SEARCH_KEY_PLACEHOLDER, realKey) : v;
|
||||
}
|
||||
return patched;
|
||||
}
|
||||
|
||||
// Temporarily add a host to the fetch allowlist (used by settings model listing).
|
||||
// Entries are auto-removed after 30 seconds unless they belong to a synced provider.
|
||||
const TEMP_ALLOWLIST_TTL = 30_000;
|
||||
// Track temporarily added entries so cleanup can distinguish them from synced ones
|
||||
const tempAllowedHosts = new Set();
|
||||
const tempAllowedPorts = new Set();
|
||||
// Track temporarily added HTTP hosts (for rebuild restoration)
|
||||
const tempHttpHosts = new Set();
|
||||
// Track active expiry timers per host to avoid duplicate/premature expiry
|
||||
const hostExpiryTimers = new Map();
|
||||
|
||||
/** Check if a host is owned by a currently synced provider config */
|
||||
function isHostInProviderConfigs(host) {
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try { if (new URL(config.baseURL).hostname === host) return true; } catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Check if a host is owned by a provider config that uses http:// */
|
||||
function isHttpHostInProviderConfigs(host) {
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try {
|
||||
const p = new URL(config.baseURL);
|
||||
if (p.hostname === host && p.protocol === "http:") return true;
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Check if a localhost port is owned by a currently synced provider config */
|
||||
function isPortInProviderConfigs(port) {
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try {
|
||||
const p = new URL(config.baseURL);
|
||||
if ((p.hostname === "localhost" || p.hostname === "127.0.0.1") &&
|
||||
Number(p.port || (p.protocol === "https:" ? 443 : 80)) === port) return true;
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ipcMain.handle("netcatty:ai:allowlist:add-host", async (event, { baseURL }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
if (typeof baseURL !== "string") return { ok: false, error: "baseURL must be a string" };
|
||||
try {
|
||||
const parsed = new URL(baseURL);
|
||||
const host = parsed.hostname;
|
||||
if (host === "localhost" || host === "127.0.0.1") {
|
||||
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
|
||||
if (!ALLOWED_LOCALHOST_PORTS.has(port)) {
|
||||
ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
tempAllowedPorts.add(port);
|
||||
setTimeout(() => {
|
||||
// Only remove if still temporary (not built-in and not synced by a provider)
|
||||
if (!BUILTIN_LOCALHOST_PORTS.includes(port) && !isPortInProviderConfigs(port)) {
|
||||
ALLOWED_LOCALHOST_PORTS.delete(port);
|
||||
}
|
||||
tempAllowedPorts.delete(port);
|
||||
}, TEMP_ALLOWLIST_TTL);
|
||||
}
|
||||
} else {
|
||||
const isNewHost = !providerFetchHosts.has(host);
|
||||
if (isNewHost) {
|
||||
providerFetchHosts.add(host);
|
||||
}
|
||||
// Always track in tempAllowedHosts so rebuild can restore to providerFetchHosts
|
||||
// even if the original persistent source (e.g. HTTPS provider) is removed mid-TTL
|
||||
tempAllowedHosts.add(host);
|
||||
if (parsed.protocol === "http:") {
|
||||
providerHttpHosts.add(host);
|
||||
if (!isHttpHostInProviderConfigs(host)) tempHttpHosts.add(host);
|
||||
}
|
||||
// Always (re-)schedule expiry timer to clean up temp entries
|
||||
const existing = hostExpiryTimers.get(host);
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => {
|
||||
hostExpiryTimers.delete(host);
|
||||
// Check if host is still needed by a provider config or web search
|
||||
const isWebSearchHost = webSearchApiHost && (() => {
|
||||
try { return new URL(webSearchApiHost).hostname === host; } catch { return false; }
|
||||
})();
|
||||
if (!isHostInProviderConfigs(host) && !isWebSearchHost) {
|
||||
providerFetchHosts.delete(host);
|
||||
providerHttpHosts.delete(host);
|
||||
} else if (!isHttpHostInProviderConfigs(host)) {
|
||||
providerHttpHosts.delete(host);
|
||||
}
|
||||
tempAllowedHosts.delete(host);
|
||||
tempHttpHosts.delete(host);
|
||||
}, TEMP_ALLOWLIST_TTL);
|
||||
hostExpiryTimers.set(host, timer);
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: "Invalid URL" };
|
||||
}
|
||||
});
|
||||
|
||||
// URL allowlist: only permit requests to known AI provider domains + HTTPS
|
||||
const BUILTIN_FETCH_HOSTS = new Set([
|
||||
"api.openai.com",
|
||||
"api.anthropic.com",
|
||||
"generativelanguage.googleapis.com",
|
||||
"openrouter.ai",
|
||||
// Web search providers
|
||||
"api.tavily.com",
|
||||
"api.exa.ai",
|
||||
"api.bochaai.com",
|
||||
"open.bigmodel.cn",
|
||||
]);
|
||||
// Dynamically populated from configured provider baseURLs
|
||||
const providerFetchHosts = new Set();
|
||||
// Subset of providerFetchHosts where the provider baseURL explicitly uses http://
|
||||
const providerHttpHosts = new Set();
|
||||
|
||||
/**
|
||||
* Rebuild the dynamic host allowlist from the current providerConfigs.
|
||||
* Called whenever providers are synced from the renderer.
|
||||
*/
|
||||
function rebuildProviderFetchHosts() {
|
||||
providerFetchHosts.clear();
|
||||
providerHttpHosts.clear();
|
||||
// Reset localhost ports to built-in defaults, then add provider-configured ones
|
||||
ALLOWED_LOCALHOST_PORTS.clear();
|
||||
for (const port of BUILTIN_LOCALHOST_PORTS) ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
// Re-add any still-active temporary entries so a sync doesn't wipe them
|
||||
for (const host of tempAllowedHosts) providerFetchHosts.add(host);
|
||||
for (const host of tempHttpHosts) providerHttpHosts.add(host);
|
||||
for (const port of tempAllowedPorts) ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try {
|
||||
const parsed = new URL(config.baseURL);
|
||||
const host = parsed.hostname;
|
||||
// Skip localhost — handled separately via port allowlist
|
||||
if (host === "localhost" || host === "127.0.0.1") {
|
||||
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
|
||||
ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
} else {
|
||||
providerFetchHosts.add(host);
|
||||
if (parsed.protocol === "http:") providerHttpHosts.add(host);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL in config — skip
|
||||
}
|
||||
}
|
||||
// Add web search apiHost if configured (e.g. SearXNG self-hosted instance)
|
||||
if (webSearchApiHost) {
|
||||
try {
|
||||
const parsed = new URL(webSearchApiHost);
|
||||
const host = parsed.hostname;
|
||||
if (host === "localhost" || host === "127.0.0.1") {
|
||||
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
|
||||
ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
} else {
|
||||
providerFetchHosts.add(host);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed localhost ports to prevent SSRF (Issue #9)
|
||||
const BUILTIN_LOCALHOST_PORTS = [
|
||||
11434, // Ollama default
|
||||
1234, // LM Studio default
|
||||
3000, // Common local dev
|
||||
3001, // Common local dev
|
||||
5000, // Common local dev
|
||||
5001, // Common local dev
|
||||
8000, // Common local dev
|
||||
8080, // Common local dev
|
||||
8888, // Common local dev
|
||||
];
|
||||
const ALLOWED_LOCALHOST_PORTS = new Set(BUILTIN_LOCALHOST_PORTS);
|
||||
// RFC1918 / link-local / loopback / IPv6 private ranges — used by SSRF guard
|
||||
function isPrivateIp(ip) {
|
||||
if (!ip) return false;
|
||||
// Strip IPv6 brackets that URL.hostname may include
|
||||
const cleaned = ip.replace(/^\[|\]$/g, "");
|
||||
if (cleaned === "::1" || cleaned === "0.0.0.0" || cleaned === "::") return true;
|
||||
// IPv6 private ranges: fc00::/7 (unique local), fe80::/10 (link-local), ::ffff:127.x (mapped loopback)
|
||||
const lower = cleaned.toLowerCase();
|
||||
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // fc00::/7
|
||||
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true; // fe80::/10
|
||||
if (lower.startsWith("::ffff:")) {
|
||||
// IPv4-mapped IPv6 — extract IPv4 portion and check
|
||||
const v4 = lower.slice(7);
|
||||
return isPrivateIp(v4);
|
||||
}
|
||||
// IPv4
|
||||
const parts = cleaned.split(".");
|
||||
if (parts.length === 4 && parts.every(p => /^\d+$/.test(p))) {
|
||||
const [a, b] = parts.map(Number);
|
||||
if (a === 10) return true; // 10.0.0.0/8
|
||||
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
|
||||
if (a === 192 && b === 168) return true; // 192.168.0.0/16
|
||||
if (a === 127) return true; // 127.0.0.0/8
|
||||
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
|
||||
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT (Tailscale etc.)
|
||||
if (a === 0) return true; // 0.0.0.0/8
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateHost(hostname) {
|
||||
if (hostname === "localhost") return true;
|
||||
// metadata endpoints (AWS, GCP, Azure)
|
||||
if (hostname === "metadata.google.internal") return true;
|
||||
return isPrivateIp(hostname);
|
||||
}
|
||||
|
||||
function isAllowedFetchUrl(urlString, skipHostCheck) {
|
||||
try {
|
||||
const parsed = new URL(urlString);
|
||||
// Always block private/internal hosts when skipHostCheck is set (SSRF protection)
|
||||
if (skipHostCheck) {
|
||||
if (isPrivateHost(parsed.hostname)) return false;
|
||||
// Require HTTPS for skipHostCheck requests
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
return true;
|
||||
}
|
||||
// Allow localhost/127.0.0.1 only on known ports (e.g. Ollama) — normal fetch path only
|
||||
if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") {
|
||||
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
|
||||
return ALLOWED_LOCALHOST_PORTS.has(port);
|
||||
}
|
||||
// Only allow http: and https: schemes for remote hosts
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
|
||||
// For HTTP, only allow providers explicitly configured with http:// or the web search apiHost
|
||||
if (parsed.protocol === "http:") {
|
||||
const isProviderHost = providerHttpHosts.has(parsed.hostname);
|
||||
let isWebSearchHost = false;
|
||||
if (webSearchApiHost) {
|
||||
try { isWebSearchHost = new URL(webSearchApiHost).hostname === parsed.hostname; } catch { }
|
||||
}
|
||||
if (!isProviderHost && !isWebSearchHost) return false;
|
||||
}
|
||||
// Check built-in + provider-configured host allowlist
|
||||
if (BUILTIN_FETCH_HOSTS.has(parsed.hostname)) return true;
|
||||
if (providerFetchHosts.has(parsed.hostname)) return true;
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Start a streaming chat request (proxied through main process)
|
||||
ipcMain.handle("netcatty:ai:chat:stream", async (event, {
|
||||
requestId,
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
providerId,
|
||||
idleTimeoutMs,
|
||||
}) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
try {
|
||||
// Inject real API key if providerId is given (replaces placeholder in headers/URL)
|
||||
const patched = injectApiKeyIntoRequest(url, headers, providerId);
|
||||
const resolvedUrl = patched.url;
|
||||
const resolvedHeaders = patched.headers;
|
||||
|
||||
// Validate URL: only allow HTTP(S) schemes
|
||||
try {
|
||||
const parsed = new URL(resolvedUrl);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return { ok: false, error: "Only HTTP(S) URLs are allowed" };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, error: "Invalid URL" };
|
||||
}
|
||||
|
||||
// Check URL against allowed hosts (same as netcatty:ai:fetch)
|
||||
if (!isAllowedFetchUrl(resolvedUrl)) {
|
||||
return { ok: false, error: "URL host is not in the allowed list" };
|
||||
}
|
||||
|
||||
const skipTLS = shouldSkipTLSVerify(providerId);
|
||||
const { statusCode, statusText } = await streamRequest(
|
||||
resolvedUrl,
|
||||
{ method: "POST", headers: resolvedHeaders, body, idleTimeoutMs },
|
||||
event,
|
||||
requestId,
|
||||
skipTLS,
|
||||
);
|
||||
return { ok: true, statusCode, statusText };
|
||||
} catch (err) {
|
||||
if (err?.name === "AbortError") {
|
||||
return { ok: false, aborted: true, error: "Aborted" };
|
||||
}
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel an active stream
|
||||
ipcMain.handle("netcatty:ai:chat:cancel", async (event, { requestId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const controller = activeStreams.get(requestId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
activeStreams.delete(requestId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Non-streaming request (for model listing, validation, etc.)
|
||||
ipcMain.handle("netcatty:ai:fetch", async (event, { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify }) => {
|
||||
// Validate IPC sender — settings window needs this for model listing
|
||||
if (!validateSenderOrSettings(event)) {
|
||||
return { ok: false, status: 0, data: "", error: "Unauthorized IPC sender" };
|
||||
}
|
||||
|
||||
// Inject real API key if providerId is given (replaces placeholder in headers/URL)
|
||||
const patched = injectApiKeyIntoRequest(url, headers, providerId);
|
||||
const resolvedUrl = patched.url;
|
||||
// Also inject web search API key if placeholder is present
|
||||
const resolvedHeaders = injectWebSearchKeyIntoHeaders(patched.headers);
|
||||
|
||||
// Validate URL: block non-HTTP(S) schemes and internal network access
|
||||
try {
|
||||
const parsed = new URL(resolvedUrl);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return { ok: false, status: 0, data: "", error: "Only HTTP(S) URLs are allowed" };
|
||||
}
|
||||
// Block file:// and other dangerous schemes (already covered above)
|
||||
} catch {
|
||||
return { ok: false, status: 0, data: "", error: "Invalid URL" };
|
||||
}
|
||||
|
||||
// Check URL against allowed hosts; skipHostCheck allows public HTTPS but still blocks private/internal
|
||||
if (!isAllowedFetchUrl(resolvedUrl, !!skipHostCheck)) {
|
||||
return { ok: false, status: 0, data: "", error: "URL host is not in the allowed list" };
|
||||
}
|
||||
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB safety limit
|
||||
const MAX_REDIRECTS = followRedirects ? 5 : 0;
|
||||
|
||||
async function doFetch(fetchUrl, redirectsLeft) {
|
||||
// ctx.require is bound from aiBridge.cjs, so this path is relative to that file.
|
||||
const { resolveOutboundHttpAgent } = require("./httpNetworkProxyAgent.cjs");
|
||||
const skipTLS = Boolean(skipTLSVerify || shouldSkipTLSVerify(providerId));
|
||||
let proxyAgent;
|
||||
try {
|
||||
proxyAgent = await resolveOutboundHttpAgent(fetchUrl, {
|
||||
session: electronModule?.session?.defaultSession,
|
||||
rejectUnauthorized: skipTLS ? false : undefined,
|
||||
});
|
||||
} catch {
|
||||
proxyAgent = undefined;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const parsedUrl = new URL(fetchUrl);
|
||||
const isHttps = parsedUrl.protocol === "https:";
|
||||
const lib = isHttps ? https : http;
|
||||
|
||||
const fetchOpts = {
|
||||
method: method || "GET",
|
||||
headers: withContentLength(resolvedHeaders || {}, body),
|
||||
timeout: 30000,
|
||||
};
|
||||
if (skipTLS && isHttps) fetchOpts.rejectUnauthorized = false;
|
||||
if (proxyAgent) fetchOpts.agent = proxyAgent;
|
||||
const req = lib.request(parsedUrl, fetchOpts,
|
||||
(res) => {
|
||||
// Handle redirects
|
||||
if (redirectsLeft > 0 && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
const location = new URL(res.headers.location, fetchUrl).href;
|
||||
res.resume(); // drain the response
|
||||
// Revalidate the redirect target hostname (blocks localhost/metadata etc.)
|
||||
if (!isAllowedFetchUrl(location, !!skipHostCheck)) {
|
||||
resolve({ ok: false, status: 0, data: "", error: "Redirect target is not allowed" });
|
||||
return;
|
||||
}
|
||||
resolve(doFetch(location, redirectsLeft - 1));
|
||||
return;
|
||||
}
|
||||
let data = "";
|
||||
let totalSize = 0;
|
||||
res.on("data", (chunk) => {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_RESPONSE_SIZE) {
|
||||
req.destroy();
|
||||
resolve({ ok: false, status: 0, data: "", error: "Response body exceeded maximum size (10MB)" });
|
||||
return;
|
||||
}
|
||||
data += chunk.toString();
|
||||
});
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
status: res.statusCode,
|
||||
data,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on("error", (err) => {
|
||||
resolve({ ok: false, status: 0, data: "", error: err.message });
|
||||
});
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
resolve({ ok: false, status: 0, data: "", error: "Request timeout" });
|
||||
});
|
||||
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
return doFetch(resolvedUrl, MAX_REDIRECTS);
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { registerProviderHandlers };
|
||||
Reference in New Issue
Block a user