mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(desktop): pin outbound fetches to a validated address (#2359)
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,70 +1,89 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import http from 'node:http';
|
|
||||||
import https from 'node:https';
|
|
||||||
import {pipeline} from 'node:stream/promises';
|
import {pipeline} from 'node:stream/promises';
|
||||||
|
import {
|
||||||
|
type DesktopOutboundHTTPMessage,
|
||||||
|
getDesktopOutboundHTTP,
|
||||||
|
parseDesktopHTTPTarget,
|
||||||
|
parseDesktopRedirectTarget,
|
||||||
|
} from '@electron/main/DesktopOutboundHTTP';
|
||||||
|
|
||||||
const MAX_DOWNLOAD_REDIRECTS = 5;
|
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 {
|
interface DownloadFileOptions {
|
||||||
const parsed = new URL(url);
|
maxBytes?: number;
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removePartialDownload(destPath: string): Promise<void> {
|
async function removePartialDownload(destPath: string): Promise<void> {
|
||||||
await fs.promises.unlink(destPath).catch(() => {});
|
await fs.promises.unlink(destPath).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFileWithRedirects(url: URL, destPath: string, redirects: number): Promise<void> {
|
async function writeCappedResponse(
|
||||||
const response = await requestUrl(url);
|
response: DesktopOutboundHTTPMessage['message'],
|
||||||
const statusCode = response.statusCode ?? 0;
|
destPath: string,
|
||||||
if (statusCode >= 300 && statusCode < 400) {
|
maxBytes: number,
|
||||||
response.resume();
|
): Promise<void> {
|
||||||
const location = response.headers.location;
|
let received = 0;
|
||||||
if (!location) {
|
response.on('data', (chunk: Buffer) => {
|
||||||
throw new Error(`HTTP ${statusCode} redirect missing Location header`);
|
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) {
|
if (redirects >= MAX_DOWNLOAD_REDIRECTS) {
|
||||||
throw new Error('Too many download redirects');
|
throw new Error('Too many download redirects');
|
||||||
}
|
}
|
||||||
const nextUrl = parseHttpUrl(new URL(location, url).toString());
|
const nextUrl = parseDesktopRedirectTarget(message.url, message.headers.location);
|
||||||
await downloadFileWithRedirects(nextUrl, destPath, redirects + 1);
|
if (nextUrl == null) {
|
||||||
|
throw new Error(`HTTP ${statusCode} redirect target is unusable`);
|
||||||
|
}
|
||||||
|
await downloadFileWithRedirects(nextUrl, destPath, redirects + 1, maxBytes);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (statusCode === 204 || statusCode === 205) {
|
if (statusCode === 204 || statusCode === 205) {
|
||||||
response.resume();
|
message.message.destroy();
|
||||||
await fs.promises.writeFile(destPath, new Uint8Array());
|
await fs.promises.writeFile(destPath, new Uint8Array());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (statusCode < 200 || statusCode >= 300) {
|
if (statusCode < 200 || statusCode >= 300) {
|
||||||
response.resume();
|
message.message.destroy();
|
||||||
throw new Error(`HTTP ${statusCode}`);
|
throw new Error(`HTTP ${statusCode}`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await pipeline(response, fs.createWriteStream(destPath));
|
await writeCappedResponse(message.message, destPath, maxBytes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await removePartialDownload(destPath);
|
await removePartialDownload(destPath);
|
||||||
throw error;
|
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 removePartialDownload(destPath);
|
||||||
await downloadFileWithRedirects(parseHttpUrl(url), destPath, 0);
|
await downloadFileWithRedirects(target, destPath, 0, maxBytes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,25 @@
|
|||||||
|
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import http from 'node:http';
|
|
||||||
import https from 'node:https';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {createChildLogger} from '@electron/common/Logger';
|
import {createChildLogger} from '@electron/common/Logger';
|
||||||
|
import {
|
||||||
|
getDesktopOutboundHTTP,
|
||||||
|
parseDesktopHTTPTarget,
|
||||||
|
parseDesktopRedirectTarget,
|
||||||
|
readBoundedMessage,
|
||||||
|
readMessageContentLength,
|
||||||
|
} from '@electron/main/DesktopOutboundHTTP';
|
||||||
import {app, nativeImage} from 'electron';
|
import {app, nativeImage} from 'electron';
|
||||||
|
|
||||||
const logger = createChildLogger('NotificationIcon');
|
const logger = createChildLogger('NotificationIcon');
|
||||||
const NOTIFICATION_ICON_DOWNLOAD_TIMEOUT_MS = 10000;
|
const NOTIFICATION_ICON_DOWNLOAD_TIMEOUT_MS = 10000;
|
||||||
const NOTIFICATION_ICON_MAX_BYTES = 3 * 1024 * 1024;
|
const NOTIFICATION_ICON_MAX_BYTES = 3 * 1024 * 1024;
|
||||||
const NOTIFICATION_ICON_CACHE_MAX_FILES = 512;
|
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']>;
|
type ResolvedNotificationIcon = NonNullable<Electron.NotificationConstructorOptions['icon']>;
|
||||||
|
|
||||||
@@ -146,7 +155,7 @@ export async function resolveNotificationIcon(source: string): Promise<ResolvedN
|
|||||||
const buffer = await downloadToBuffer(source, {
|
const buffer = await downloadToBuffer(source, {
|
||||||
maxBytes: NOTIFICATION_ICON_MAX_BYTES,
|
maxBytes: NOTIFICATION_ICON_MAX_BYTES,
|
||||||
timeoutMs: NOTIFICATION_ICON_DOWNLOAD_TIMEOUT_MS,
|
timeoutMs: NOTIFICATION_ICON_DOWNLOAD_TIMEOUT_MS,
|
||||||
redirectsRemaining: 5,
|
redirectsRemaining: NOTIFICATION_ICON_MAX_REDIRECTS,
|
||||||
});
|
});
|
||||||
const image = decodeNotificationIcon(source, buffer);
|
const image = decodeNotificationIcon(source, buffer);
|
||||||
return image ? resolveDecodedNotificationIcon(source, image) : null;
|
return image ? resolveDecodedNotificationIcon(source, image) : null;
|
||||||
@@ -159,71 +168,48 @@ export async function resolveNotificationIcon(source: string): Promise<ResolvedN
|
|||||||
}
|
}
|
||||||
return resolveDecodedNotificationIcon(source, image);
|
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> {
|
async function downloadToBuffer(url: string, options: DownloadOptions): Promise<Buffer> {
|
||||||
return new Promise((resolve, reject) => {
|
const initialTarget = parseDesktopHTTPTarget(url);
|
||||||
const parsedUrl = new URL(url);
|
if (initialTarget == null) {
|
||||||
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
throw new Error('Notification icon URL must use http or https');
|
||||||
let settled = false;
|
}
|
||||||
const finish = (error: Error | null, buffer?: Buffer): void => {
|
const outboundHTTP = getDesktopOutboundHTTP();
|
||||||
if (settled) return;
|
let target = initialTarget;
|
||||||
settled = true;
|
let redirectsRemaining = options.redirectsRemaining;
|
||||||
if (error) {
|
for (;;) {
|
||||||
reject(error);
|
const message = await outboundHTTP.get({
|
||||||
} else {
|
context: NOTIFICATION_ICON_CONTEXT,
|
||||||
resolve(buffer ?? Buffer.alloc(0));
|
timeoutMs: options.timeoutMs,
|
||||||
}
|
url: target,
|
||||||
};
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
request.setTimeout(options.timeoutMs, () => {
|
const statusCode = message.status;
|
||||||
request.destroy(new Error(`Notification icon download timed out after ${options.timeoutMs}ms`));
|
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);
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user