Add SSRF guard, signer queue cap, secret echo, request timeout

This commit is contained in:
Avi 2026-08-21 16:06:18 -05:00
commit f7db29e793
2 changed files with 204 additions and 3 deletions

View file

@ -1,8 +1,10 @@
import { app, BrowserWindow, clipboard, dialog, ipcMain, protocol, shell } from 'electron';
import { lookup } from 'node:dns/promises';
import { spawn, type ChildProcess } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
import * as net from 'node:net';
import * as path from 'node:path';
const MIME: Record<string, string> = {
@ -135,6 +137,13 @@ function startBackend(): void {
});
}
/**
* Upper bound for one backend round-trip. Generous on purpose: a publish can
* wait for each relay in turn (10s connect + 15s send each). A hung backend
* still gets reaped instead of leaking promises forever.
*/
const BACKEND_TIMEOUT_MS = 120_000;
async function backendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
startBackend();
const stdin = backend?.stdin;
@ -143,11 +152,19 @@ async function backendRequest(method: string, params: Record<string, unknown>):
}
const id = nextId++;
const payload = { id, method, ...params };
let timer!: ReturnType<typeof setTimeout>;
const response = new Promise<unknown>((resolve, reject) => {
pending.set(id, { resolve, reject });
pending.set(id, {
resolve,
reject,
});
timer = setTimeout(() => {
pending.delete(id);
reject(new Error('The background service did not respond in time.'));
}, BACKEND_TIMEOUT_MS);
});
stdin.write(`${JSON.stringify(payload)}\n`);
return response;
return response.finally(() => clearTimeout(timer));
}
/**
@ -219,6 +236,66 @@ const LINK_PREVIEW_TIMEOUT_MS = 10_000;
/** Cap on how much HTML we parse for meta tags. */
const LINK_PREVIEW_MAX_BYTES = 1_000_000;
/** Whether an IPv4 address is loopback, private, or otherwise non-routable. */
function ipv4IsPrivate(ip: string): boolean {
const parts = ip.split('.').map(Number);
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
return true; // Malformed: treat as unsafe.
}
const [a, b] = parts;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168)
);
}
/** Whether an IPv6 address is loopback, link-local, unique-local, or v4-mapped private. */
function ipv6IsPrivate(ip: string): boolean {
const addr = ip.toLowerCase();
if (addr === '::' || addr === '::1') {
return true;
}
const mapped = addr.startsWith('::ffff:') ? addr.slice(7) : null;
if (mapped) {
return net.isIPv4(mapped) ? ipv4IsPrivate(mapped) : true;
}
// fc00::/7 (unique local) and fe80::/10 (link local).
return /^f[cd]/.test(addr) || /^fe[89ab]/.test(addr);
}
/** Whether `ip` points at the local machine or a private network. */
function isPrivateAddress(ip: string): boolean {
return net.isIPv4(ip) ? ipv4IsPrivate(ip) : ipv6IsPrivate(ip);
}
/**
* Resolve `url`'s host and refuse loopback/private targets, so a crafted note
* link cannot make the app probe the user's localhost or LAN ("SSRF"). Hosts
* are checked at their resolved addresses, not just by name.
*/
async function resolvesToPrivateAddress(url: URL): Promise<boolean> {
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) {
return true;
}
let addresses: string[];
if (net.isIP(host)) {
addresses = [host];
} else {
try {
addresses = (await lookup(host, { all: true, verbatim: true })).map((a) => a.address);
} catch {
return true; // Unresolvable: nothing useful to preview anyway.
}
}
return addresses.some(isPrivateAddress);
}
/** A best-effort MIME type derived from the file name. */
function mimeForPath(filePath: string): string {
switch (path.extname(filePath).toLowerCase()) {
@ -344,6 +421,9 @@ async function fetchLinkPreview(rawUrl: string): Promise<LinkPreview | null> {
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return null;
}
if (await resolvesToPrivateAddress(url)) {
return null;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), LINK_PREVIEW_TIMEOUT_MS);