mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
consolidate cross-origin proxy and add ssrf guard
This commit is contained in:
@@ -1,9 +1,97 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
|
const dns = require("dns").promises;
|
||||||
|
const net = require("net");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// POST /api/proxy - forward a request to an external URL to bypass CORS
|
const MAX_RESPONSE_BYTES = 50 * 1024 * 1024;
|
||||||
// Used by the requestUrl shim for plugin installation, etc.
|
|
||||||
|
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) => {
|
router.post("/", async (req, res) => {
|
||||||
const { url, method, headers, body, binary } = req.body;
|
const { url, method, headers, body, binary } = req.body;
|
||||||
|
|
||||||
@@ -12,6 +100,13 @@ router.post("/", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 = {
|
const fetchOpts = {
|
||||||
method: method || "GET",
|
method: method || "GET",
|
||||||
headers: headers || {},
|
headers: headers || {},
|
||||||
@@ -26,10 +121,25 @@ router.post("/", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const upstream = await fetch(url, fetchOpts);
|
const upstream = await fetch(url, fetchOpts);
|
||||||
const respBody = Buffer.from(await upstream.arrayBuffer());
|
|
||||||
|
|
||||||
// Forward response headers, stripping hop-by-hop / encoding headers
|
const declaredLength = Number(upstream.headers.get("content-length"));
|
||||||
// since the body is already decompressed by Node's fetch
|
|
||||||
|
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([
|
const skipHeaders = new Set([
|
||||||
"content-encoding",
|
"content-encoding",
|
||||||
"transfer-encoding",
|
"transfer-encoding",
|
||||||
@@ -37,6 +147,7 @@ router.post("/", async (req, res) => {
|
|||||||
"connection",
|
"connection",
|
||||||
]);
|
]);
|
||||||
const respHeaders = {};
|
const respHeaders = {};
|
||||||
|
|
||||||
upstream.headers.forEach((val, key) => {
|
upstream.headers.forEach((val, key) => {
|
||||||
if (!skipHeaders.has(key)) {
|
if (!skipHeaders.has(key)) {
|
||||||
respHeaders[key] = val;
|
respHeaders[key] = val;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { showVaultManager } from "../ui-registry.js";
|
import { showVaultManager } from "../ui-registry.js";
|
||||||
import { vaultService } from "@ignis/services";
|
import { vaultService } from "@ignis/services";
|
||||||
import { arrayBufferToBase64, base64ToArrayBuffer } from "../util/base64.js";
|
import { proxyFetch } from "../util/proxy.js";
|
||||||
|
|
||||||
const listeners = new Map();
|
const listeners = new Map();
|
||||||
|
|
||||||
@@ -88,41 +88,19 @@ const syncHandlers = {
|
|||||||
|
|
||||||
async function handleRequestUrl(requestId, request) {
|
async function handleRequestUrl(requestId, request) {
|
||||||
try {
|
try {
|
||||||
let body = request.body;
|
const result = await proxyFetch({
|
||||||
let binary = false;
|
url: request.url,
|
||||||
|
method: request.method,
|
||||||
if (body instanceof ArrayBuffer) {
|
headers: request.headers,
|
||||||
body = arrayBufferToBase64(body);
|
body: request.body,
|
||||||
binary = true;
|
contentType: request.contentType,
|
||||||
}
|
|
||||||
|
|
||||||
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 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
|
// Electron's e.reply(requestId, data) sends on the requestId channel
|
||||||
ipcRenderer._emit(requestId, {
|
ipcRenderer._emit(requestId, {
|
||||||
status: proxyResult.status,
|
status: result.status,
|
||||||
headers: proxyResult.headers,
|
headers: result.headers,
|
||||||
body: base64ToArrayBuffer(proxyResult.body),
|
body: result.body,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ipcRenderer._emit(requestId, {
|
ipcRenderer._emit(requestId, {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
unregisterPopupWindow,
|
unregisterPopupWindow,
|
||||||
} from "./electron/remote/window.js";
|
} from "./electron/remote/window.js";
|
||||||
import { showVaultManager } from "./ui-registry.js";
|
import { showVaultManager } from "./ui-registry.js";
|
||||||
import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js";
|
|
||||||
import { isSameOrigin } from "./util/url.js";
|
import { isSameOrigin } from "./util/url.js";
|
||||||
|
import { proxyFetch } from "./util/proxy.js";
|
||||||
|
|
||||||
function installProcess() {
|
function installProcess() {
|
||||||
window.process = processShim;
|
window.process = processShim;
|
||||||
@@ -167,17 +167,15 @@ function installFetchShim() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let body = null;
|
let body = null;
|
||||||
let binary = false;
|
|
||||||
|
|
||||||
if (init?.body && method !== "GET" && method !== "HEAD") {
|
if (init?.body && method !== "GET" && method !== "HEAD") {
|
||||||
if (typeof init.body === "string") {
|
if (typeof init.body === "string") {
|
||||||
body = init.body;
|
body = init.body;
|
||||||
} else if (init.body instanceof ArrayBuffer) {
|
} else if (
|
||||||
body = arrayBufferToBase64(init.body);
|
init.body instanceof ArrayBuffer ||
|
||||||
binary = true;
|
init.body instanceof Uint8Array
|
||||||
} else if (init.body instanceof Uint8Array) {
|
) {
|
||||||
body = arrayBufferToBase64(init.body.buffer);
|
body = init.body;
|
||||||
binary = true;
|
|
||||||
} else if (typeof init.body === "object") {
|
} else if (typeof init.body === "object") {
|
||||||
body = JSON.stringify(init.body);
|
body = JSON.stringify(init.body);
|
||||||
} else {
|
} else {
|
||||||
@@ -187,23 +185,15 @@ function installFetchShim() {
|
|||||||
|
|
||||||
console.log("[shim:fetch] Proxying cross-origin:", method, url);
|
console.log("[shim:fetch] Proxying cross-origin:", method, url);
|
||||||
|
|
||||||
const proxyRes = await originalFetch("/api/proxy", {
|
let result;
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ url, method, headers, body, binary }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!proxyRes.ok) {
|
try {
|
||||||
const err = await proxyRes
|
result = await proxyFetch({ url, method, headers, body });
|
||||||
.json()
|
} catch (e) {
|
||||||
.catch(() => ({ error: "Proxy request failed" }));
|
throw new TypeError(e.message || "Failed to fetch");
|
||||||
throw new TypeError(err.error || "Failed to fetch");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await proxyRes.json();
|
return new Response(result.body, {
|
||||||
const respBody = base64ToArrayBuffer(result.body);
|
|
||||||
|
|
||||||
return new Response(respBody, {
|
|
||||||
status: result.status,
|
status: result.status,
|
||||||
headers: result.headers,
|
headers: result.headers,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Obsidian sets window.requestUrl in app.js, so we override it after app.js loads.
|
// Obsidian sets window.requestUrl in app.js, so we override it after app.js loads.
|
||||||
|
|
||||||
import { isSameOrigin } from "./util/url.js";
|
import { isSameOrigin } from "./util/url.js";
|
||||||
import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js";
|
import { proxyFetch } from "./util/proxy.js";
|
||||||
|
|
||||||
async function proxyRequestUrl(request) {
|
async function proxyRequestUrl(request) {
|
||||||
if (typeof request === "string") {
|
if (typeof request === "string") {
|
||||||
@@ -28,42 +28,14 @@ async function proxyRequestUrl(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cross-origin: route through server proxy
|
// Cross-origin: route through server proxy
|
||||||
let body = request.body;
|
const result = await proxyFetch({
|
||||||
let binary = false;
|
url: request.url,
|
||||||
|
method: request.method,
|
||||||
if (body instanceof ArrayBuffer) {
|
headers: request.headers,
|
||||||
body = arrayBufferToBase64(body);
|
body: request.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,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
return makeResponse(request, result.status, result.headers, result.body);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeResponse(request, status, headers, arrayBuf) {
|
function makeResponse(request, status, headers, arrayBuf) {
|
||||||
|
|||||||
54
packages/shim/src/util/proxy.js
Normal file
54
packages/shim/src/util/proxy.js
Normal file
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user