Add SSRF guard, signer queue cap, secret echo, request timeout
This commit is contained in:
parent
d90b6e53d4
commit
f7db29e793
2 changed files with 204 additions and 3 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue