Skip to content

Commit 5f74495

Browse files
committed
fix(security): guard protocol download against SSRF
The protocol download URL is user-supplied and fetched in the main process. Resolve the host and refuse private/loopback/link-local targets, and reject redirects so a public host cannot bounce to an internal address (e.g. cloud metadata). Addresses the pushed-commit security review finding.
1 parent edad7e0 commit 5f74495

2 files changed

Lines changed: 40 additions & 2 deletions

File tree

.eslintrc.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@
122122
"preload",
123123
"reduxstore",
124124
"renderer",
125-
"utils"
125+
"utils",
126+
"loopback"
126127
],
127128
"skipIfMatch": [
128129
"http(s)?://[^s]*",

src/main/protocolDownload.js

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,43 @@
11
import { app, net } from 'electron';
22
import fs from 'node:fs';
33
import path from 'node:path';
4+
import dns from 'node:dns';
5+
import { isIP } from 'node:net';
46
import { randomUUID } from 'node:crypto';
57

8+
const isPrivateIPv4 = (ip) => {
9+
const [a, b] = ip.split('.').map(Number);
10+
if (a === 0 || a === 127 || a === 10) { return true; } // unspecified, loopback, private
11+
if (a === 172 && b >= 16 && b <= 31) { return true; } // private
12+
if (a === 192 && b === 168) { return true; } // private
13+
if (a === 169 && b === 254) { return true; } // link-local
14+
return false;
15+
};
16+
17+
const isPrivateIPv6 = (ip) => {
18+
const addr = ip.toLowerCase();
19+
if (addr === '::1' || addr === '::') { return true; } // loopback, unspecified
20+
const mapped = addr.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); // IPv4-mapped
21+
if (mapped) { return isPrivateIPv4(mapped[1]); }
22+
if (/^f[cd]/.test(addr)) { return true; } // unique-local fc00::/7
23+
if (/^fe[89ab]/.test(addr)) { return true; } // link-local fe80::/10
24+
return false;
25+
};
26+
27+
const isBlockedAddress = (address) => {
28+
const kind = isIP(address);
29+
if (kind === 4) { return isPrivateIPv4(address); }
30+
if (kind === 6) { return isPrivateIPv6(address); }
31+
return true; // not a resolvable IP literal: refuse
32+
};
33+
634
// Downloads a remote protocol to a temp file. Runs in the main process, where
735
// fetch is not subject to the renderer's CORS policy. Returns the temp path,
836
// which is inside app temp so the extract step's path guard permits it.
37+
//
38+
// SSRF guard: the URL is user-supplied, so we resolve the host and refuse
39+
// private/loopback/link-local targets, and reject redirects (which could
40+
// otherwise bounce a public host to an internal address).
941
const downloadProtocol = async (url) => {
1042
let parsed;
1143
try {
@@ -17,7 +49,12 @@ const downloadProtocol = async (url) => {
1749
throw new Error(`Disallowed URL scheme: ${parsed.protocol}`);
1850
}
1951

20-
const response = await net.fetch(parsed.href);
52+
const addresses = await dns.promises.lookup(parsed.hostname, { all: true });
53+
if (addresses.length === 0 || addresses.some(({ address }) => isBlockedAddress(address))) {
54+
throw new Error('Refusing to download from a private, loopback, or link-local address.');
55+
}
56+
57+
const response = await net.fetch(parsed.href, { redirect: 'error' });
2158
if (!response.ok) {
2259
throw new Error(`Unexpected response status ${response.status}`);
2360
}

0 commit comments

Comments
 (0)