fix(desktop): pin outbound fetches to a validated address (#2359)

This commit is contained in:
Hampus
2026-09-02 17:26:32 +02:00
committed by GitHub
parent 25f4332b9e
commit de2ea99928
3 changed files with 669 additions and 101 deletions
@@ -0,0 +1,563 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Buffer} from 'node:buffer';
import dns from 'node:dns';
import http from 'node:http';
import https from 'node:https';
import {isIPv4, isIPv6, type LookupFunction} from 'node:net';
import type {Readable} from 'node:stream';
import {getAppUrl} from '@electron/common/DesktopConfig';
import {createChildLogger} from '@electron/common/Logger';
const logger = createChildLogger('DesktopOutboundHTTP');
const DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT = 32;
const DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS = 16;
const DESKTOP_OUTBOUND_HTTP_RESOLUTION_TIMEOUT_MS = 10_000;
const DESKTOP_OUTBOUND_HTTP_MAX_TARGET_URL_BYTES = 16 * 1024;
const DESKTOP_OUTBOUND_HTTP_MAX_REDIRECT_LOCATION_BYTES = 4096;
const DESKTOP_OUTBOUND_HTTP_BLOCKED_MESSAGE = 'The requested address could not be reached';
const DESKTOP_OUTBOUND_HTTP_TRANSPORT_MESSAGE = 'The request could not be completed';
const DESKTOP_OUTBOUND_HTTP_TIMEOUT_MESSAGE = 'The request timed out';
const DESKTOP_OUTBOUND_HTTP_CAPACITY_MESSAGE = 'Too many downloads are already in progress';
const IPV6_GROUP_COUNT = 8;
const DesktopOutboundBlockReason = Object.freeze({
INSECURE_TRANSPORT: 'insecure-transport',
INVALID_TARGET: 'invalid-target',
LOOKUP_HOSTNAME_MISMATCH: 'lookup-hostname-mismatch',
NON_PUBLIC_ADDRESS: 'non-public-address',
NO_USABLE_ADDRESS: 'no-usable-address',
RESOLUTION_FAILED: 'resolution-failed',
RESOLUTION_TIMEOUT: 'resolution-timeout',
} as const);
type DesktopOutboundBlockReason = (typeof DesktopOutboundBlockReason)[keyof typeof DesktopOutboundBlockReason];
const DesktopAddressRequirement = Object.freeze({
ANY: 'any',
PUBLIC: 'public',
} as const);
type DesktopAddressRequirement = (typeof DesktopAddressRequirement)[keyof typeof DesktopAddressRequirement];
interface PinnedAddress {
readonly address: string;
readonly family: 4 | 6;
}
export interface DesktopOutboundGETRequest {
readonly context: string;
readonly timeoutMs: number;
readonly url: URL;
}
export interface DesktopOutboundHTTPMessage {
readonly headers: http.IncomingHttpHeaders;
readonly message: http.IncomingMessage;
readonly status: number;
readonly url: URL;
}
interface BoundedMessageRead {
readonly declaredBytes: number | null;
readonly description: string;
readonly maxBytes: number;
readonly maxChunks: number;
readonly message: Readable;
}
export class DesktopOutboundHTTPBlockedError extends Error {
public constructor() {
super(DESKTOP_OUTBOUND_HTTP_BLOCKED_MESSAGE);
this.name = 'DesktopOutboundHTTPBlockedError';
}
}
export class DesktopOutboundHTTPTransportError extends Error {
public constructor() {
super(DESKTOP_OUTBOUND_HTTP_TRANSPORT_MESSAGE);
this.name = 'DesktopOutboundHTTPTransportError';
}
}
export class DesktopOutboundHTTPTimeoutError extends Error {
public constructor() {
super(DESKTOP_OUTBOUND_HTTP_TIMEOUT_MESSAGE);
this.name = 'DesktopOutboundHTTPTimeoutError';
}
}
export class DesktopOutboundHTTPCapacityError extends Error {
public constructor() {
super(DESKTOP_OUTBOUND_HTTP_CAPACITY_MESSAGE);
this.name = 'DesktopOutboundHTTPCapacityError';
}
}
export class BoundedMessageByteLimitError extends RangeError {
public constructor(description: string, maxBytes: number) {
super(`${description} exceeds ${maxBytes} bytes`);
this.name = 'BoundedMessageByteLimitError';
}
}
export class BoundedMessageChunkLimitError extends RangeError {
public constructor(description: string, maxChunks: number) {
super(`${description} exceeds ${maxChunks} response chunks`);
this.name = 'BoundedMessageChunkLimitError';
}
}
class InvalidContentLengthError extends TypeError {
public constructor(description: string) {
super(`${description} has an invalid Content-Length header`);
this.name = 'InvalidContentLengthError';
}
}
class PinnedLookupHostnameMismatchError extends Error {
public constructor() {
super(DESKTOP_OUTBOUND_HTTP_BLOCKED_MESSAGE);
this.name = 'PinnedLookupHostnameMismatchError';
}
}
function blocked(
reason: DesktopOutboundBlockReason,
context: string,
hostname: string,
): DesktopOutboundHTTPBlockedError {
logger.warn('Blocked outbound request', {context, hostname, reason});
return new DesktopOutboundHTTPBlockedError();
}
function stripIPv6ZoneIdentifier(value: string): string {
const zoneIndex = value.indexOf('%');
if (zoneIndex === -1) {
return value;
}
const addressPart = value.slice(0, zoneIndex);
return addressPart.includes(':') ? addressPart : value;
}
function normalizeIPv6(value: string): string {
try {
const hostname = new URL(`http://[${value}]`).hostname;
return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
} catch {
return value;
}
}
function parseIPAddress(value: string): PinnedAddress | null {
const trimmed = value.trim();
const unbracketed = trimmed.startsWith('[') && trimmed.endsWith(']') ? trimmed.slice(1, -1) : trimmed;
const unzoned = stripIPv6ZoneIdentifier(unbracketed);
if (isIPv4(unzoned)) {
return {address: unzoned, family: 4};
}
if (isIPv6(unzoned)) {
return {address: normalizeIPv6(unzoned), family: 6};
}
return null;
}
function parseIPv4Octets(address: string): Array<number> | null {
const parts = address.split('.');
if (parts.length !== 4) {
return null;
}
const octets = parts.map((part) => (/^\d{1,3}$/u.test(part) ? Number.parseInt(part, 10) : Number.NaN));
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
return null;
}
return octets;
}
function ipv4Value(octets: ReadonlyArray<number>): number {
return ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
}
function isIPv4InCIDR(value: number, base: number, prefixLength: number): boolean {
const mask = prefixLength === 0 ? 0 : (0xffffffff << (32 - prefixLength)) >>> 0;
return (value & mask) >>> 0 === (base & mask) >>> 0;
}
const IPV4_NON_PUBLIC_RANGES: ReadonlyArray<readonly [base: number, prefixLength: number]> = Object.freeze([
[0x00000000, 8],
[0x0a000000, 8],
[0x64400000, 10],
[0x7f000000, 8],
[0xa9fe0000, 16],
[0xac100000, 12],
[0xc0000000, 24],
[0xc0000200, 24],
[0xc0a80000, 16],
[0xc6120000, 15],
[0xc6336400, 24],
[0xcb007100, 24],
[0xe0000000, 4],
[0xf0000000, 4],
]);
function isPublicIPv4Address(address: string): boolean {
const octets = parseIPv4Octets(address);
if (octets == null) {
return false;
}
const value = ipv4Value(octets);
return !IPV4_NON_PUBLIC_RANGES.some(([base, prefixLength]) => isIPv4InCIDR(value, base, prefixLength));
}
function expandIPv6Groups(address: string): Array<string> {
const halves = address.split('::');
if (halves.length === 2) {
const left = halves[0].length > 0 ? halves[0].split(':') : [];
const right = halves[1].length > 0 ? halves[1].split(':') : [];
const missing = Math.max(IPV6_GROUP_COUNT - left.length - right.length, 0);
return [...left, ...Array<string>(missing).fill('0'), ...right].map((group) => group.padStart(4, '0'));
}
return address.split(':').map((group) => group.padStart(4, '0'));
}
function ipv4FromMappedIPv6(groups: ReadonlyArray<string>): string | null {
const isMapped =
groups[0] === '0000' &&
groups[1] === '0000' &&
groups[2] === '0000' &&
groups[3] === '0000' &&
groups[4] === '0000' &&
groups[5] === 'ffff';
if (!isMapped) {
return null;
}
const high = Number.parseInt(groups[6], 16);
const low = Number.parseInt(groups[7], 16);
return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
}
function isPublicIPv6Address(address: string): boolean {
const groups = expandIPv6Groups(address);
if (groups.length !== IPV6_GROUP_COUNT) {
return false;
}
const mapped = ipv4FromMappedIPv6(groups);
if (mapped != null) {
return isPublicIPv4Address(mapped);
}
const first = Number.parseInt(groups[0], 16);
const second = Number.parseInt(groups[1], 16);
const last = Number.parseInt(groups[7], 16);
if (groups.slice(0, 7).every((group) => group === '0000') && (last === 0 || last === 1)) {
return false;
}
if ((first & 0xe000) !== 0x2000) {
return false;
}
if ((first & 0xffc0) === 0xfe80) {
return false;
}
if ((first & 0xfe00) === 0xfc00) {
return false;
}
if ((first & 0xff00) === 0xff00) {
return false;
}
return !(first === 0x2001 && second === 0x0db8);
}
export function isPublicPinnedAddress(pinned: PinnedAddress): boolean {
return pinned.family === 4 ? isPublicIPv4Address(pinned.address) : isPublicIPv6Address(pinned.address);
}
export function parseDesktopHTTPTarget(value: string): URL | null {
if (Buffer.byteLength(value, 'utf8') > DESKTOP_OUTBOUND_HTTP_MAX_TARGET_URL_BYTES) {
return null;
}
let url: URL;
try {
url = new URL(value);
} catch {
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return null;
}
if (url.username.length > 0 || url.password.length > 0 || url.hostname.length === 0 || url.port === '0') {
return null;
}
return url;
}
export function parseDesktopRedirectTarget(base: URL, location: string | Array<string> | undefined): URL | null {
if (typeof location !== 'string' || location.length === 0) {
return null;
}
if (Buffer.byteLength(location, 'utf8') > DESKTOP_OUTBOUND_HTTP_MAX_REDIRECT_LOCATION_BYTES) {
return null;
}
let resolved: string;
try {
resolved = new URL(location, base).toString();
} catch {
return null;
}
return parseDesktopHTTPTarget(resolved);
}
function normalizeLookupHostname(hostname: string): string {
let value = hostname.trim().toLowerCase();
if (value.startsWith('[') && value.endsWith(']')) {
value = value.slice(1, -1);
}
if (value.length > 1 && value.endsWith('.')) {
value = value.slice(0, -1);
}
return value;
}
function createPinnedHostLookup(hostname: string, pinned: PinnedAddress): LookupFunction {
const expected = normalizeLookupHostname(hostname);
return (requestedHostname, options, callback) => {
if (normalizeLookupHostname(requestedHostname) !== expected) {
callback(new PinnedLookupHostnameMismatchError(), '', undefined);
return;
}
if (options.all === true) {
(callback as unknown as (error: null, addresses: ReadonlyArray<PinnedAddress>) => void)(null, [pinned]);
return;
}
callback(null, pinned.address, pinned.family);
};
}
function parseContentLengthHeader(value: string | undefined, description: string): number | null {
if (value == null) {
return null;
}
const normalized = value.trim();
if (!/^\d+$/u.test(normalized)) {
throw new InvalidContentLengthError(description);
}
const parsed = Number.parseInt(normalized, 10);
if (!Number.isSafeInteger(parsed)) {
throw new InvalidContentLengthError(description);
}
return parsed;
}
export function readMessageContentLength(message: DesktopOutboundHTTPMessage, description: string): number | null {
const raw = message.headers['content-length'];
if (Array.isArray(raw)) {
throw new InvalidContentLengthError(description);
}
return parseContentLengthHeader(raw, description);
}
export async function readBoundedMessage({
declaredBytes,
description,
maxBytes,
maxChunks,
message,
}: BoundedMessageRead): Promise<Buffer> {
if (declaredBytes != null && declaredBytes > maxBytes) {
message.destroy();
throw new BoundedMessageByteLimitError(description, maxBytes);
}
const chunks: Array<Buffer> = [];
let totalBytes = 0;
let chunkCount = 0;
try {
for await (const chunk of message) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
chunkCount += 1;
if (chunkCount > maxChunks) {
throw new BoundedMessageChunkLimitError(description, maxChunks);
}
totalBytes += buffer.byteLength;
if (totalBytes > maxBytes) {
throw new BoundedMessageByteLimitError(description, maxBytes);
}
chunks.push(buffer);
}
} catch (error) {
message.destroy();
throw error;
}
return Buffer.concat(chunks, totalBytes);
}
async function lookupAllAddresses(hostname: string): Promise<ReadonlyArray<string>> {
const records = await dns.promises.lookup(hostname, {all: true, verbatim: true});
return records.map((record) => record.address);
}
export class DesktopOutboundHTTP {
private readonly httpAgent = new http.Agent({keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS});
private readonly httpsAgent = new https.Agent({keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS});
private addressRequirementOperation: Promise<DesktopAddressRequirement> | null = null;
private inFlight = 0;
public async get(request: DesktopOutboundGETRequest): Promise<DesktopOutboundHTTPMessage> {
if (this.inFlight >= DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT) {
throw new DesktopOutboundHTTPCapacityError();
}
this.inFlight += 1;
let released = false;
const release = (): void => {
if (released) {
return;
}
released = true;
this.inFlight -= 1;
};
try {
const requirement = await this.addressRequirement();
if (requirement === DesktopAddressRequirement.PUBLIC && request.url.protocol !== 'https:') {
throw blocked(DesktopOutboundBlockReason.INSECURE_TRANSPORT, request.context, request.url.hostname);
}
const pinned = await this.pinAddress(request.url.hostname, requirement, request.context);
return await this.issue(request, pinned, release);
} catch (error) {
release();
throw error;
}
}
private addressRequirement(): Promise<DesktopAddressRequirement> {
this.addressRequirementOperation ??= this.resolveAddressRequirement();
return this.addressRequirementOperation;
}
private async resolveAddressRequirement(): Promise<DesktopAddressRequirement> {
const appOrigin = parseDesktopHTTPTarget(getAppUrl());
if (appOrigin == null) {
return DesktopAddressRequirement.PUBLIC;
}
const literal = parseIPAddress(appOrigin.hostname);
if (literal != null) {
return isPublicPinnedAddress(literal) ? DesktopAddressRequirement.PUBLIC : DesktopAddressRequirement.ANY;
}
let addresses: ReadonlyArray<string>;
try {
addresses = await this.resolveHost(appOrigin.hostname);
} catch {
logger.warn('App origin did not resolve; requiring publicly routable outbound addresses');
return DesktopAddressRequirement.PUBLIC;
}
const candidates = addresses.map((address) => parseIPAddress(address)).filter((value) => value != null);
if (candidates.length === 0 || candidates.every((candidate) => isPublicPinnedAddress(candidate))) {
return DesktopAddressRequirement.PUBLIC;
}
return DesktopAddressRequirement.ANY;
}
private async pinAddress(
hostname: string,
requirement: DesktopAddressRequirement,
context: string,
): Promise<PinnedAddress> {
const literal = parseIPAddress(hostname);
if (literal != null) {
if (requirement === DesktopAddressRequirement.PUBLIC && !isPublicPinnedAddress(literal)) {
throw blocked(DesktopOutboundBlockReason.NON_PUBLIC_ADDRESS, context, hostname);
}
return literal;
}
let addresses: ReadonlyArray<string>;
try {
addresses = await this.resolveHost(hostname);
} catch (error) {
const reason =
error instanceof DesktopOutboundHTTPTimeoutError
? DesktopOutboundBlockReason.RESOLUTION_TIMEOUT
: DesktopOutboundBlockReason.RESOLUTION_FAILED;
throw blocked(reason, context, hostname);
}
const candidates = addresses.map((address) => parseIPAddress(address)).filter((value) => value != null);
if (candidates.length === 0) {
throw blocked(DesktopOutboundBlockReason.NO_USABLE_ADDRESS, context, hostname);
}
if (
requirement === DesktopAddressRequirement.PUBLIC &&
candidates.some((candidate) => !isPublicPinnedAddress(candidate))
) {
throw blocked(DesktopOutboundBlockReason.NON_PUBLIC_ADDRESS, context, hostname);
}
return candidates[0];
}
private async resolveHost(hostname: string): Promise<ReadonlyArray<string>> {
return await Promise.race([
lookupAllAddresses(hostname),
new Promise<never>((_resolve, reject) => {
const timer = setTimeout(
() => reject(new DesktopOutboundHTTPTimeoutError()),
DESKTOP_OUTBOUND_HTTP_RESOLUTION_TIMEOUT_MS,
);
timer.unref();
}),
]);
}
private issue(
request: DesktopOutboundGETRequest,
pinned: PinnedAddress,
release: () => void,
): Promise<DesktopOutboundHTTPMessage> {
return new Promise<DesktopOutboundHTTPMessage>((resolve, reject) => {
const secure = request.url.protocol === 'https:';
const transport = secure ? https : http;
const clientRequest = transport.request(request.url, {
agent: secure ? this.httpsAgent : this.httpAgent,
lookup: createPinnedHostLookup(request.url.hostname, pinned),
method: 'GET',
});
const deadline = setTimeout(() => {
clientRequest.destroy(new DesktopOutboundHTTPTimeoutError());
}, request.timeoutMs);
deadline.unref();
let settled = false;
const settle = (): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(deadline);
release();
};
clientRequest.on('error', (error) => {
settle();
if (error instanceof DesktopOutboundHTTPTimeoutError) {
reject(error);
return;
}
logger.warn('Outbound request failed', {context: request.context, hostname: request.url.hostname, error});
reject(new DesktopOutboundHTTPTransportError());
});
clientRequest.on('response', (message) => {
message.on('end', settle);
message.on('close', settle);
message.on('error', settle);
resolve({
headers: message.headers,
message,
status: message.statusCode ?? 0,
url: request.url,
});
});
clientRequest.end();
});
}
}
let sharedOutboundHTTP: DesktopOutboundHTTP | null = null;
export function getDesktopOutboundHTTP(): DesktopOutboundHTTP {
sharedOutboundHTTP ??= new DesktopOutboundHTTP();
return sharedOutboundHTTP;
}
+54 -35
View File
@@ -1,70 +1,89 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import {pipeline} from 'node:stream/promises';
import {
type DesktopOutboundHTTPMessage,
getDesktopOutboundHTTP,
parseDesktopHTTPTarget,
parseDesktopRedirectTarget,
} from '@electron/main/DesktopOutboundHTTP';
const MAX_DOWNLOAD_REDIRECTS = 5;
const DOWNLOAD_TIMEOUT_MS = 30000;
const DOWNLOAD_DEADLINE_MS = 600_000;
const DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024;
const DOWNLOAD_CONTEXT = 'File download';
function parseHttpUrl(url: string): URL {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Download URL must use http or https');
}
return parsed;
}
function requestUrl(url: URL): Promise<http.IncomingMessage> {
const transport = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const request = transport.get(url, resolve);
request.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
request.destroy(new Error('Download timed out'));
});
request.on('error', reject);
});
interface DownloadFileOptions {
maxBytes?: number;
}
async function removePartialDownload(destPath: string): Promise<void> {
await fs.promises.unlink(destPath).catch(() => {});
}
async function downloadFileWithRedirects(url: URL, destPath: string, redirects: number): Promise<void> {
const response = await requestUrl(url);
const statusCode = response.statusCode ?? 0;
if (statusCode >= 300 && statusCode < 400) {
response.resume();
const location = response.headers.location;
if (!location) {
throw new Error(`HTTP ${statusCode} redirect missing Location header`);
async function writeCappedResponse(
response: DesktopOutboundHTTPMessage['message'],
destPath: string,
maxBytes: number,
): Promise<void> {
let received = 0;
response.on('data', (chunk: Buffer) => {
received += chunk.length;
if (received > maxBytes) {
response.destroy(new Error(`Download exceeds ${maxBytes} bytes`));
}
});
await pipeline(response, fs.createWriteStream(destPath));
}
async function downloadFileWithRedirects(
url: URL,
destPath: string,
redirects: number,
maxBytes: number,
): Promise<void> {
const message = await getDesktopOutboundHTTP().get({
context: DOWNLOAD_CONTEXT,
timeoutMs: DOWNLOAD_DEADLINE_MS,
url,
});
const statusCode = message.status;
if (statusCode >= 300 && statusCode < 400) {
message.message.destroy();
if (redirects >= MAX_DOWNLOAD_REDIRECTS) {
throw new Error('Too many download redirects');
}
const nextUrl = parseHttpUrl(new URL(location, url).toString());
await downloadFileWithRedirects(nextUrl, destPath, redirects + 1);
const nextUrl = parseDesktopRedirectTarget(message.url, message.headers.location);
if (nextUrl == null) {
throw new Error(`HTTP ${statusCode} redirect target is unusable`);
}
await downloadFileWithRedirects(nextUrl, destPath, redirects + 1, maxBytes);
return;
}
if (statusCode === 204 || statusCode === 205) {
response.resume();
message.message.destroy();
await fs.promises.writeFile(destPath, new Uint8Array());
return;
}
if (statusCode < 200 || statusCode >= 300) {
response.resume();
message.message.destroy();
throw new Error(`HTTP ${statusCode}`);
}
try {
await pipeline(response, fs.createWriteStream(destPath));
await writeCappedResponse(message.message, destPath, maxBytes);
} catch (error) {
await removePartialDownload(destPath);
throw error;
}
}
export async function downloadFile(url: string, destPath: string): Promise<void> {
export async function downloadFile(url: string, destPath: string, options: DownloadFileOptions = {}): Promise<void> {
const target = parseDesktopHTTPTarget(url);
if (target == null) {
throw new Error('Download URL must use http or https');
}
const maxBytes = options.maxBytes ?? DOWNLOAD_MAX_BYTES;
await removePartialDownload(destPath);
await downloadFileWithRedirects(parseHttpUrl(url), destPath, 0);
await downloadFileWithRedirects(target, destPath, 0, maxBytes);
}
+52 -66
View File
@@ -2,16 +2,25 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import path from 'node:path';
import {createChildLogger} from '@electron/common/Logger';
import {
getDesktopOutboundHTTP,
parseDesktopHTTPTarget,
parseDesktopRedirectTarget,
readBoundedMessage,
readMessageContentLength,
} from '@electron/main/DesktopOutboundHTTP';
import {app, nativeImage} from 'electron';
const logger = createChildLogger('NotificationIcon');
const NOTIFICATION_ICON_DOWNLOAD_TIMEOUT_MS = 10000;
const NOTIFICATION_ICON_MAX_BYTES = 3 * 1024 * 1024;
const NOTIFICATION_ICON_CACHE_MAX_FILES = 512;
const NOTIFICATION_ICON_MAX_CHUNKS = 4096;
const NOTIFICATION_ICON_MAX_REDIRECTS = 5;
const NOTIFICATION_ICON_CONTEXT = 'Notification icon download';
const NOTIFICATION_ICON_REDIRECT_STATUS_CODES: ReadonlySet<number> = new Set([301, 302, 303, 307, 308]);
type ResolvedNotificationIcon = NonNullable<Electron.NotificationConstructorOptions['icon']>;
@@ -146,7 +155,7 @@ export async function resolveNotificationIcon(source: string): Promise<ResolvedN
const buffer = await downloadToBuffer(source, {
maxBytes: NOTIFICATION_ICON_MAX_BYTES,
timeoutMs: NOTIFICATION_ICON_DOWNLOAD_TIMEOUT_MS,
redirectsRemaining: 5,
redirectsRemaining: NOTIFICATION_ICON_MAX_REDIRECTS,
});
const image = decodeNotificationIcon(source, buffer);
return image ? resolveDecodedNotificationIcon(source, image) : null;
@@ -159,71 +168,48 @@ export async function resolveNotificationIcon(source: string): Promise<ResolvedN
}
return resolveDecodedNotificationIcon(source, image);
}
return source;
logger.warn('Rejected non-URL notification icon source', {source: describeIconSource(source)});
return null;
}
function downloadToBuffer(url: string, options: DownloadOptions): Promise<Buffer> {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const protocol = parsedUrl.protocol === 'https:' ? https : http;
let settled = false;
const finish = (error: Error | null, buffer?: Buffer): void => {
if (settled) return;
settled = true;
if (error) {
reject(error);
} else {
resolve(buffer ?? Buffer.alloc(0));
}
};
const request = protocol.get(parsedUrl, (response) => {
const statusCode = response.statusCode ?? 0;
if ([301, 302, 303, 307, 308].includes(statusCode)) {
const location = response.headers.location;
response.resume();
if (!location) {
finish(new Error(`Notification icon redirect missing Location header (${statusCode})`));
return;
}
if (options.redirectsRemaining <= 0) {
finish(new Error('Notification icon download exceeded redirect limit'));
return;
}
const redirectUrl = new URL(location, parsedUrl).toString();
downloadToBuffer(redirectUrl, {...options, redirectsRemaining: options.redirectsRemaining - 1})
.then(resolve)
.catch(reject);
settled = true;
return;
}
if (statusCode !== 200) {
response.resume();
finish(new Error(`Notification icon download failed with HTTP ${statusCode}`));
return;
}
const contentLengthRaw = response.headers['content-length'];
const contentLength = Array.isArray(contentLengthRaw) ? Number(contentLengthRaw[0]) : Number(contentLengthRaw);
if (Number.isFinite(contentLength) && contentLength > options.maxBytes) {
response.resume();
finish(new Error(`Notification icon exceeds ${options.maxBytes} bytes`));
return;
}
const chunks: Array<Buffer> = [];
let totalBytes = 0;
response.on('data', (chunk: Buffer) => {
totalBytes += chunk.length;
if (totalBytes > options.maxBytes) {
request.destroy(new Error(`Notification icon exceeds ${options.maxBytes} bytes`));
return;
}
chunks.push(chunk);
});
response.on('end', () => finish(null, Buffer.concat(chunks)));
response.on('error', finish);
async function downloadToBuffer(url: string, options: DownloadOptions): Promise<Buffer> {
const initialTarget = parseDesktopHTTPTarget(url);
if (initialTarget == null) {
throw new Error('Notification icon URL must use http or https');
}
const outboundHTTP = getDesktopOutboundHTTP();
let target = initialTarget;
let redirectsRemaining = options.redirectsRemaining;
for (;;) {
const message = await outboundHTTP.get({
context: NOTIFICATION_ICON_CONTEXT,
timeoutMs: options.timeoutMs,
url: target,
});
request.setTimeout(options.timeoutMs, () => {
request.destroy(new Error(`Notification icon download timed out after ${options.timeoutMs}ms`));
const statusCode = message.status;
if (NOTIFICATION_ICON_REDIRECT_STATUS_CODES.has(statusCode)) {
message.message.destroy();
if (redirectsRemaining <= 0) {
throw new Error('Notification icon download exceeded redirect limit');
}
const next = parseDesktopRedirectTarget(message.url, message.headers.location);
if (next == null) {
throw new Error(`Notification icon redirect target is unusable (${statusCode})`);
}
target = next;
redirectsRemaining -= 1;
continue;
}
if (statusCode !== 200) {
message.message.destroy();
throw new Error(`Notification icon download failed with HTTP ${statusCode}`);
}
return await readBoundedMessage({
declaredBytes: readMessageContentLength(message, NOTIFICATION_ICON_CONTEXT),
description: NOTIFICATION_ICON_CONTEXT,
maxBytes: options.maxBytes,
maxChunks: NOTIFICATION_ICON_MAX_CHUNKS,
message: message.message,
});
request.on('error', finish);
});
}
}