From 44bb01f16273bc47bf4de3d4de7cdb0ba7cb4b7d Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Fri, 5 Jun 2026 23:56:59 +0200 Subject: [PATCH] consolidate cross-origin proxy and add ssrf guard --- apps/ignis-server/server/routes/proxy.js | 121 ++++++++++++++++++++- packages/shim/src/electron/ipc-renderer.js | 42 ++----- packages/shim/src/globals.js | 34 ++---- packages/shim/src/request-url.js | 42 ++----- packages/shim/src/util/proxy.js | 54 +++++++++ 5 files changed, 199 insertions(+), 94 deletions(-) create mode 100644 packages/shim/src/util/proxy.js diff --git a/apps/ignis-server/server/routes/proxy.js b/apps/ignis-server/server/routes/proxy.js index 57eba76..a55f19b 100644 --- a/apps/ignis-server/server/routes/proxy.js +++ b/apps/ignis-server/server/routes/proxy.js @@ -1,9 +1,97 @@ const express = require("express"); +const dns = require("dns").promises; +const net = require("net"); const router = express.Router(); -// POST /api/proxy - forward a request to an external URL to bypass CORS -// Used by the requestUrl shim for plugin installation, etc. +const MAX_RESPONSE_BYTES = 50 * 1024 * 1024; + +function isPrivateIp(ip) { + const type = net.isIP(ip); + + if (type === 4) { + const o = ip.split(".").map(Number); + + return ( + o[0] === 0 || + o[0] === 10 || + o[0] === 127 || + (o[0] === 169 && o[1] === 254) || + (o[0] === 172 && o[1] >= 16 && o[1] <= 31) || + (o[0] === 192 && o[1] === 168) || + (o[0] === 100 && o[1] >= 64 && o[1] <= 127) + ); + } + + if (type === 6) { + const a = ip.toLowerCase(); + + if (a === "::1" || a === "::") { + return true; + } + + if (/^fe[89ab]/.test(a) || a.startsWith("fc") || a.startsWith("fd")) { + return true; + } + + const mapped = a.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + + if (mapped) { + return isPrivateIp(mapped[1]); + } + + return false; + } + + return false; +} + +function httpError(status, message) { + const e = new Error(message); + e.statusCode = status; + return e; +} + +// Reject non-http(s) schemes and hosts that resolve to a private or link-local address. +async function assertPublicUrl(urlStr) { + let parsed; + + try { + parsed = new URL(urlStr); + } catch { + throw httpError(400, "Invalid URL"); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw httpError(400, "Only http and https URLs are allowed"); + } + + const host = parsed.hostname; + + if (net.isIP(host)) { + if (isPrivateIp(host)) { + throw httpError(403, "Host not allowed"); + } + + return; + } + + let addrs; + + try { + addrs = await dns.lookup(host, { all: true }); + } catch { + throw httpError(502, "DNS resolution failed"); + } + + for (const a of addrs) { + if (isPrivateIp(a.address)) { + throw httpError(403, "Host resolves to a private address"); + } + } +} + +// POST /api/proxy - forward a request to an external URL to bypass CORS. router.post("/", async (req, res) => { const { url, method, headers, body, binary } = req.body; @@ -12,6 +100,13 @@ router.post("/", async (req, res) => { } try { + await assertPublicUrl(url); + } catch (e) { + return res.status(e.statusCode || 400).json({ error: e.message }); + } + + try { + // Forward the caller's headers as-is. const fetchOpts = { method: method || "GET", headers: headers || {}, @@ -26,10 +121,25 @@ router.post("/", async (req, res) => { } const upstream = await fetch(url, fetchOpts); - const respBody = Buffer.from(await upstream.arrayBuffer()); - // Forward response headers, stripping hop-by-hop / encoding headers - // since the body is already decompressed by Node's fetch + const declaredLength = Number(upstream.headers.get("content-length")); + + if ( + Number.isFinite(declaredLength) && + declaredLength > MAX_RESPONSE_BYTES + ) { + return res.status(413).json({ error: "Upstream response too large" }); + } + + const respArrayBuf = await upstream.arrayBuffer(); + + if (respArrayBuf.byteLength > MAX_RESPONSE_BYTES) { + return res.status(413).json({ error: "Upstream response too large" }); + } + + const respBody = Buffer.from(respArrayBuf); + + // Strip hop-by-hop / encoding headers since the body is already decompressed. const skipHeaders = new Set([ "content-encoding", "transfer-encoding", @@ -37,6 +147,7 @@ router.post("/", async (req, res) => { "connection", ]); const respHeaders = {}; + upstream.headers.forEach((val, key) => { if (!skipHeaders.has(key)) { respHeaders[key] = val; diff --git a/packages/shim/src/electron/ipc-renderer.js b/packages/shim/src/electron/ipc-renderer.js index 57b198c..2326bd6 100644 --- a/packages/shim/src/electron/ipc-renderer.js +++ b/packages/shim/src/electron/ipc-renderer.js @@ -1,6 +1,6 @@ import { showVaultManager } from "../ui-registry.js"; import { vaultService } from "@ignis/services"; -import { arrayBufferToBase64, base64ToArrayBuffer } from "../util/base64.js"; +import { proxyFetch } from "../util/proxy.js"; const listeners = new Map(); @@ -88,41 +88,19 @@ const syncHandlers = { async function handleRequestUrl(requestId, request) { try { - let body = request.body; - let binary = false; - - if (body instanceof ArrayBuffer) { - body = arrayBufferToBase64(body); - binary = true; - } - - const res = await fetch("/api/proxy", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - url: request.url, - method: request.method || "GET", - headers: request.headers || {}, - contentType: request.contentType, - body, - binary, - }), + const result = await proxyFetch({ + url: request.url, + method: request.method, + headers: request.headers, + body: request.body, + contentType: request.contentType, }); - const proxyResult = await res.json(); - - if (!res.ok) { - ipcRenderer._emit(requestId, { - error: proxyResult.error || "Proxy request failed", - }); - return; - } - // Electron's e.reply(requestId, data) sends on the requestId channel ipcRenderer._emit(requestId, { - status: proxyResult.status, - headers: proxyResult.headers, - body: base64ToArrayBuffer(proxyResult.body), + status: result.status, + headers: result.headers, + body: result.body, }); } catch (e) { ipcRenderer._emit(requestId, { diff --git a/packages/shim/src/globals.js b/packages/shim/src/globals.js index ffc04f7..7a74b80 100644 --- a/packages/shim/src/globals.js +++ b/packages/shim/src/globals.js @@ -4,8 +4,8 @@ import { unregisterPopupWindow, } from "./electron/remote/window.js"; import { showVaultManager } from "./ui-registry.js"; -import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js"; import { isSameOrigin } from "./util/url.js"; +import { proxyFetch } from "./util/proxy.js"; function installProcess() { window.process = processShim; @@ -167,17 +167,15 @@ function installFetchShim() { } let body = null; - let binary = false; if (init?.body && method !== "GET" && method !== "HEAD") { if (typeof init.body === "string") { body = init.body; - } else if (init.body instanceof ArrayBuffer) { - body = arrayBufferToBase64(init.body); - binary = true; - } else if (init.body instanceof Uint8Array) { - body = arrayBufferToBase64(init.body.buffer); - binary = true; + } else if ( + init.body instanceof ArrayBuffer || + init.body instanceof Uint8Array + ) { + body = init.body; } else if (typeof init.body === "object") { body = JSON.stringify(init.body); } else { @@ -187,23 +185,15 @@ function installFetchShim() { console.log("[shim:fetch] Proxying cross-origin:", method, url); - const proxyRes = await originalFetch("/api/proxy", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url, method, headers, body, binary }), - }); + let result; - if (!proxyRes.ok) { - const err = await proxyRes - .json() - .catch(() => ({ error: "Proxy request failed" })); - throw new TypeError(err.error || "Failed to fetch"); + try { + result = await proxyFetch({ url, method, headers, body }); + } catch (e) { + throw new TypeError(e.message || "Failed to fetch"); } - const result = await proxyRes.json(); - const respBody = base64ToArrayBuffer(result.body); - - return new Response(respBody, { + return new Response(result.body, { status: result.status, headers: result.headers, }); diff --git a/packages/shim/src/request-url.js b/packages/shim/src/request-url.js index 39f304e..5c60c4e 100644 --- a/packages/shim/src/request-url.js +++ b/packages/shim/src/request-url.js @@ -2,7 +2,7 @@ // Obsidian sets window.requestUrl in app.js, so we override it after app.js loads. import { isSameOrigin } from "./util/url.js"; -import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js"; +import { proxyFetch } from "./util/proxy.js"; async function proxyRequestUrl(request) { if (typeof request === "string") { @@ -28,42 +28,14 @@ async function proxyRequestUrl(request) { } // Cross-origin: route through server proxy - let body = request.body; - let binary = false; - - if (body instanceof ArrayBuffer) { - body = arrayBufferToBase64(body); - binary = true; - } - - const res = await fetch("/api/proxy", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - url: request.url, - method: request.method || "GET", - headers: request.headers || {}, - body, - binary, - }), + const result = await proxyFetch({ + url: request.url, + method: request.method, + headers: request.headers, + body: request.body, }); - if (!res.ok) { - const err = await res - .json() - .catch(() => ({ error: "Proxy request failed" })); - throw new Error(err.error); - } - - const proxyResult = await res.json(); - const arrayBuf = base64ToArrayBuffer(proxyResult.body); - - return makeResponse( - request, - proxyResult.status, - proxyResult.headers, - arrayBuf, - ); + return makeResponse(request, result.status, result.headers, result.body); } function makeResponse(request, status, headers, arrayBuf) { diff --git a/packages/shim/src/util/proxy.js b/packages/shim/src/util/proxy.js new file mode 100644 index 0000000..df5d030 --- /dev/null +++ b/packages/shim/src/util/proxy.js @@ -0,0 +1,54 @@ +// Single round-trip through the server's /api/proxy endpoint for cross-origin requests. +// Encodes a binary request body to base64, returns the upstream response with its body as an ArrayBuffer. +// Throws an Error carrying the server's message on failure. + +import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js"; + +export async function proxyFetch({ url, method, headers, body, contentType }) { + let encodedBody = null; + let binary = false; + + if (body instanceof ArrayBuffer) { + encodedBody = arrayBufferToBase64(body); + binary = true; + } else if (body instanceof Uint8Array) { + encodedBody = arrayBufferToBase64(body.buffer); + binary = true; + } else if (body != null) { + encodedBody = body; + } + + const payload = { + url, + method: method || "GET", + headers: headers || {}, + body: encodedBody, + binary, + }; + + if (contentType !== undefined) { + payload.contentType = contentType; + } + + // Use native fetch to avoid an unnecessary call through the shim. proxy is already same origin. + const nativeFetch = window.__originalFetch || fetch; + + const res = await nativeFetch("/api/proxy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || "Proxy request failed"); + } + + const result = await res.json(); + + return { + status: result.status, + headers: result.headers, + body: base64ToArrayBuffer(result.body), + }; +}