fix(desktop): honour the system proxy in main-process requests (#3270)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 03:04:55 +02:00
1 parent 6de6be2358
commit 3071bc7525
16 files changed
+541 -18

No files matched your search

@@ -9,9 +9,10 @@ import {
isDesktopNativeGatewayTransportAvailable, isDesktopNativeGatewayTransportAvailable,
} from '@app/features/gateway/transport/GatewayWireTransport'; } from '@app/features/gateway/transport/GatewayWireTransport';
import {isDesktopLocalAppDocument} from '@app/features/platform/DesktopLocalAppRuntime'; import {isDesktopLocalAppDocument} from '@app/features/platform/DesktopLocalAppRuntime';
import type { import {
NativeGatewayTransportAPI, NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE,
NativeGatewayTransportEvent, type NativeGatewayTransportAPI,
type NativeGatewayTransportEvent,
} from '@fluxer/desktop_ipc/src/GatewayTransportContract'; } from '@fluxer/desktop_ipc/src/GatewayTransportContract';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
@@ -431,7 +432,7 @@ describe('native transport fallback', () => {
test('native creates the shell refuses count toward the fallback', async () => { test('native creates the shell refuses count toward the fallback', async () => {
const stub = createNativeGatewayTransportStub({ const stub = createNativeGatewayTransportStub({
create: async () => { create: async () => {
throw new Error('The native gateway transport cannot reach a gateway that resolves through a proxy'); throw new Error('The native gateway transport addon is unavailable');
}, },
}); });
installElectronAPI(stub.api); installElectronAPI(stub.api);
@@ -452,6 +453,28 @@ describe('native transport fallback', () => {
); );
}); });
test('a gateway the shell routes through a proxy switches new transports to the browser at once', async () => {
const stub = createNativeGatewayTransportStub({
create: async () => {
throw new Error(
`Error invoking remote method 'native-gateway-transport:create': Error: ${NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE}`,
);
},
});
installElectronAPI(stub.api);
const transport = createGatewayWireTransport('wss://gateway.example/', {binaryType: 'arraybuffer'});
expect(transport.kind).toBe(GatewayWireTransportKind.DESKTOP_NATIVE);
transport.on('error', () => undefined);
transport.start();
await flush();
const next = createGatewayWireTransport('wss://gateway.example/', {binaryType: 'arraybuffer'});
expect(next.kind).toBe(GatewayWireTransportKind.BROWSER);
next.start();
expect(FakeWebSocket.instances.map((socket) => socket.url)).toEqual(['wss://gateway.example/']);
});
test('an open between failures resets the count', async () => { test('an open between failures resets the count', async () => {
const stub = createNativeGatewayTransportStub(); const stub = createNativeGatewayTransportStub();
installElectronAPI(stub.api); installElectronAPI(stub.api);
@@ -6,6 +6,7 @@ import {Logger} from '@app/features/platform/utils/AppLogger';
import {randomUuid} from '@app/features/platform/utils/RandomUuid'; import {randomUuid} from '@app/features/platform/utils/RandomUuid';
import {getElectronAPI} from '@app/features/ui/utils/NativeUtils'; import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
import { import {
NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE,
type NativeGatewayTransportAPI, type NativeGatewayTransportAPI,
type NativeGatewayTransportCloseRequest, type NativeGatewayTransportCloseRequest,
type NativeGatewayTransportEvent, type NativeGatewayTransportEvent,
@@ -433,7 +434,11 @@ class DesktopNativeGatewayWireTransport extends BaseGatewayWireTransport {
private failOpen(error: unknown): void { private failOpen(error: unknown): void {
if (this.disposed) return; if (this.disposed) return;
if (describeError(error).includes(NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE)) {
switchProxiedGatewayToBrowserTransport();
} else {
recordNativeGatewayFailureWithoutOpen(); recordNativeGatewayFailureWithoutOpen();
}
this.abandonNativeConnection(); this.abandonNativeConnection();
this.emitTransportFailure(error); this.emitTransportFailure(error);
} }
@@ -600,6 +605,13 @@ function recordNativeGatewayFailureWithoutOpen(): void {
} }
} }
function switchProxiedGatewayToBrowserTransport(): void {
const state = getRouterState();
if (isNativeGatewayFallbackActive(state)) return;
state.nativeFailuresWithoutOpen = NATIVE_GATEWAY_FAILURES_BEFORE_BROWSER_FALLBACK;
log.warn('Desktop native gateway transport cannot use the configured proxy, using the browser transport');
}
function createDesktopConnectionId(): string { function createDesktopConnectionId(): string {
return `${DESKTOP_CONNECTION_ID_PREFIX}${getRouterState().rendererRouterId}:${randomUuid()}`; return `${DESKTOP_CONNECTION_ID_PREFIX}${getRouterState().rendererRouterId}:${randomUuid()}`;
} }
@@ -223,6 +223,7 @@ describe('AppImageUpdate in-place replacement', () => {
const progress = []; const progress = [];
const staged = await appImageUpdate.stageAppImageUpdate({ const staged = await appImageUpdate.stageAppImageUpdate({
fetchImpl: fetch,
target: resolved.target, target: resolved.target,
url: `${baseUrl}/appimage`, url: `${baseUrl}/appimage`,
expectedSha256: NEW_SHA256, expectedSha256: NEW_SHA256,
@@ -249,6 +250,7 @@ describe('AppImageUpdate in-place replacement', () => {
const resolved = appImageUpdate.resolveAppImageTarget(); const resolved = appImageUpdate.resolveAppImageTarget();
const staged = await appImageUpdate.stageAppImageUpdate({ const staged = await appImageUpdate.stageAppImageUpdate({
fetchImpl: fetch,
target: resolved.target, target: resolved.target,
url: `${baseUrl}/appimage`, url: `${baseUrl}/appimage`,
expectedSha256: NEW_SHA256, expectedSha256: NEW_SHA256,
@@ -266,6 +268,7 @@ describe('AppImageUpdate in-place replacement', () => {
await assert.rejects( await assert.rejects(
appImageUpdate.stageAppImageUpdate({ appImageUpdate.stageAppImageUpdate({
fetchImpl: fetch,
target: resolved.target, target: resolved.target,
url: `${baseUrl}/corrupt`, url: `${baseUrl}/corrupt`,
expectedSha256: NEW_SHA256, expectedSha256: NEW_SHA256,
@@ -289,6 +292,7 @@ describe('AppImageUpdate in-place replacement', () => {
await assert.rejects( await assert.rejects(
appImageUpdate.stageAppImageUpdate({ appImageUpdate.stageAppImageUpdate({
fetchImpl: fetch,
target: resolved.target, target: resolved.target,
url: `${baseUrl}/appimage`, url: `${baseUrl}/appimage`,
expectedSha256: '', expectedSha256: '',
@@ -307,6 +311,7 @@ describe('AppImageUpdate in-place replacement', () => {
await assert.rejects( await assert.rejects(
appImageUpdate.stageAppImageUpdate({ appImageUpdate.stageAppImageUpdate({
fetchImpl: fetch,
target: resolved.target, target: resolved.target,
url: `${baseUrl}/nothing-here`, url: `${baseUrl}/nothing-here`,
expectedSha256: NEW_SHA256, expectedSha256: NEW_SHA256,
@@ -346,6 +351,7 @@ describe('AppImageUpdate in-place replacement', () => {
const resolved = appImageUpdate.resolveAppImageTarget(); const resolved = appImageUpdate.resolveAppImageTarget();
const staged = await appImageUpdate.stageAppImageUpdate({ const staged = await appImageUpdate.stageAppImageUpdate({
fetchImpl: fetch,
target: resolved.target, target: resolved.target,
url: `${baseUrl}/appimage`, url: `${baseUrl}/appimage`,
expectedSha256: NEW_SHA256, expectedSha256: NEW_SHA256,
@@ -387,6 +393,7 @@ describe('AppImageUpdate in-place replacement', () => {
await assert.rejects( await assert.rejects(
appImageUpdate.stageAppImageUpdate({ appImageUpdate.stageAppImageUpdate({
fetchImpl: fetch,
target: resolved.target, target: resolved.target,
url: `${baseUrl}/appimage`, url: `${baseUrl}/appimage`,
expectedSha256: NEW_SHA256, expectedSha256: NEW_SHA256,
@@ -428,6 +435,7 @@ describe('AppImageUpdate in-place replacement', () => {
const resolved = appImageUpdate.resolveAppImageTarget(); const resolved = appImageUpdate.resolveAppImageTarget();
const staged = await appImageUpdate.stageAppImageUpdate({ const staged = await appImageUpdate.stageAppImageUpdate({
fetchImpl: fetch,
target: resolved.target, target: resolved.target,
url: `${baseUrl}/appimage`, url: `${baseUrl}/appimage`,
expectedSha256: NEW_SHA256, expectedSha256: NEW_SHA256,
@@ -465,6 +473,7 @@ describe('AppImageUpdate in-place replacement', () => {
await assert.rejects( await assert.rejects(
appImageUpdate.stageAppImageUpdate({ appImageUpdate.stageAppImageUpdate({
fetchImpl: fetch,
target: resolved.target, target: resolved.target,
url: `${baseUrl}/appimage`, url: `${baseUrl}/appimage`,
expectedSha256: NEW_SHA256, expectedSha256: NEW_SHA256,
@@ -597,6 +606,7 @@ describe('AppImageUpdate in-place replacement', () => {
const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath});
const resolved = appImageUpdate.resolveAppImageTarget(); const resolved = appImageUpdate.resolveAppImageTarget();
const staged = await appImageUpdate.stageAppImageUpdate({ const staged = await appImageUpdate.stageAppImageUpdate({
fetchImpl: fetch,
target: resolved.target, target: resolved.target,
url: `${baseUrl}/appimage`, url: `${baseUrl}/appimage`,
expectedSha256: NEW_SHA256, expectedSha256: NEW_SHA256,
@@ -619,6 +629,7 @@ describe('AppImageUpdate abandoned staging cleanup', () => {
const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath});
const resolved = appImageUpdate.resolveAppImageTarget(); const resolved = appImageUpdate.resolveAppImageTarget();
const abandoned = await appImageUpdate.stageAppImageUpdate({ const abandoned = await appImageUpdate.stageAppImageUpdate({
fetchImpl: fetch,
target: resolved.target, target: resolved.target,
url: `${baseUrl}/appimage`, url: `${baseUrl}/appimage`,
expectedSha256: NEW_SHA256, expectedSha256: NEW_SHA256,
@@ -641,6 +652,7 @@ describe('AppImageUpdate abandoned staging cleanup', () => {
const resolved = appImageUpdate.resolveAppImageTarget(); const resolved = appImageUpdate.resolveAppImageTarget();
const sibling = appImageUpdate.resolveAppImageTarget(siblingPath); const sibling = appImageUpdate.resolveAppImageTarget(siblingPath);
const siblingStaged = await appImageUpdate.stageAppImageUpdate({ const siblingStaged = await appImageUpdate.stageAppImageUpdate({
fetchImpl: fetch,
target: sibling.target, target: sibling.target,
url: `${baseUrl}/appimage`, url: `${baseUrl}/appimage`,
expectedSha256: NEW_SHA256, expectedSha256: NEW_SHA256,
+2 -3
View File
@@ -166,14 +166,13 @@ export async function stageAppImageUpdate(options: {
url: string; url: string;
expectedSha256: string; expectedSha256: string;
onProgress?: (progress: AppImageDownloadProgress) => void; onProgress?: (progress: AppImageDownloadProgress) => void;
fetchImpl?: typeof fetch; fetchImpl: typeof fetch;
}): Promise<StagedAppImageUpdate> { }): Promise<StagedAppImageUpdate> {
const expected = options.expectedSha256.trim().toLowerCase(); const expected = options.expectedSha256.trim().toLowerCase();
if (!SHA256_PATTERN.test(expected)) { if (!SHA256_PATTERN.test(expected)) {
throw new Error('Update checksum is missing or malformed.'); throw new Error('Update checksum is missing or malformed.');
} }
const fetchImpl = options.fetchImpl ?? fetch; const response = await options.fetchImpl(options.url, {cache: 'no-store', redirect: 'follow'});
const response = await fetchImpl(options.url, {cache: 'no-store', redirect: 'follow'});
if (!response.ok || response.body == null) { if (!response.ok || response.body == null) {
throw new Error(`Update download failed: ${response.status}`); throw new Error(`Update download failed: ${response.status}`);
} }
@@ -726,6 +726,116 @@ describe('DesktopSelectedInstanceClient', () => {
}); });
}); });
describe('DesktopOutboundHTTP system proxy routes', () => {
function proxiedOutboundHTTP() {
const sent = [];
const resolved = [];
const outboundHTTP = new DesktopOutboundHTTP({
resolveHostAddresses: async (hostname) => {
resolved.push(hostname);
throw Object.assign(new Error(`getaddrinfo ENOTFOUND ${hostname}`), {syscall: 'getaddrinfo'});
},
resolveProxy: async (url) =>
url.startsWith('https://direct.example') ? 'DIRECT' : 'PROXY 127.0.0.1:7890; DIRECT',
sendThroughSession: async (request) => {
sent.push({method: request.method, url: request.url.href, headers: request.headers});
return {
headers: {'content-type': 'image/png'},
message: Readable.from([PNG_BYTES]),
status: 200,
statusText: 'OK',
};
},
});
return {outboundHTTP, resolved, sent};
}
test('an instance behind the system proxy is anchored and fetched without a local lookup', async () => {
const {outboundHTTP, resolved, sent} = proxiedOutboundHTTP();
const requirement = await outboundHTTP.registerAnchoredOrigins({
anchorOrigin: 'https://api.blocked.example',
origins: ['https://media.blocked.example'],
});
assert.equal(requirement, 'public');
const message = await outboundHTTP.request({
body: null,
expectedOrigin: 'https://media.blocked.example',
headers: {Accept: 'image/png'},
method: 'GET',
originTrust: DesktopOriginTrust.REGISTERED,
serviceName: 'test',
signal: null,
timeoutMs: 500,
url: 'https://media.blocked.example/avatar.png',
});
assert.deepEqual(
await readBoundedMessage({
declaredBytes: null,
description: 'proxied',
maxBytes: 64,
maxChunks: 4,
message: message.message,
}),
PNG_BYTES,
);
assert.deepEqual(resolved, []);
assert.deepEqual(sent, [
{method: 'GET', url: 'https://media.blocked.example/avatar.png', headers: {Accept: 'image/png'}},
]);
outboundHTTP.cleanup();
});
test('a proxied download goes through the session and a direct one keeps the pinned lookup', async () => {
const {outboundHTTP, resolved, sent} = proxiedOutboundHTTP();
const message = await outboundHTTP.get({
context: 'test',
timeoutMs: 500,
url: new URL('https://cdn.blocked.example/icon.png'),
});
message.message.resume();
await assert.rejects(
outboundHTTP.get({context: 'test', timeoutMs: 500, url: new URL('https://direct.example/icon.png')}),
/could not be reached/,
);
assert.deepEqual(
sent.map((entry) => entry.url),
['https://cdn.blocked.example/icon.png'],
);
assert.deepEqual(resolved, ['direct.example']);
outboundHTTP.cleanup();
});
test('a proxied route keeps the cleartext and private address rules', async () => {
const {outboundHTTP, sent} = proxiedOutboundHTTP();
await outboundHTTP.registerAnchoredOrigins({
anchorOrigin: 'https://api.blocked.example',
origins: ['http://10.0.0.1'],
});
const request = (url, originTrust = DesktopOriginTrust.BOUND) => ({
body: null,
expectedOrigin: new URL(url).origin,
headers: null,
method: 'GET',
originTrust,
serviceName: 'test',
signal: null,
timeoutMs: 500,
url,
});
await assert.rejects(outboundHTTP.request(request('http://plain.blocked.example/x')), /requires https/);
await assert.rejects(
outboundHTTP.request(request('http://10.0.0.1/x', DesktopOriginTrust.REGISTERED)),
/cannot authorize the privately resolving origin/,
);
await assert.rejects(
outboundHTTP.get({context: 'test', timeoutMs: 500, url: new URL('https://10.0.0.1/x')}),
/could not be reached/,
);
assert.deepEqual(sent, []);
outboundHTTP.cleanup();
});
});
describe('desktop instance launch surfaces', () => { describe('desktop instance launch surfaces', () => {
test('--fluxer-instance is a development-only API endpoint flag', () => { test('--fluxer-instance is a development-only API endpoint flag', () => {
const originalDefaultApp = process.defaultApp; const originalDefaultApp = process.defaultApp;
+156 -1
View File
@@ -8,6 +8,13 @@ import {isIPv4, isIPv6, type LookupFunction} from 'node:net';
import {Readable, Transform} from 'node:stream'; import {Readable, Transform} from 'node:stream';
import type {ReadableStream as NodeReadableStream} from 'node:stream/web'; import type {ReadableStream as NodeReadableStream} from 'node:stream/web';
import {createChildLogger} from '@electron/common/Logger'; import {createChildLogger} from '@electron/common/Logger';
import {
type DesktopProxyResolver,
type DesktopSessionHTTPSender,
isDirectProxyRoute,
resolveDesktopSessionProxy,
sendThroughDesktopSession,
} from '@electron/main/DesktopSessionHTTP';
import {normalizeHTTPNetworkOrigin} from '@fluxer/instance_bootstrap/src/NetworkOrigin'; import {normalizeHTTPNetworkOrigin} from '@fluxer/instance_bootstrap/src/NetworkOrigin';
const logger = createChildLogger('DesktopOutboundHTTP'); const logger = createChildLogger('DesktopOutboundHTTP');
@@ -120,6 +127,8 @@ interface DesktopAnchoredOriginRegistration {
interface DesktopOutboundHTTPOptions { interface DesktopOutboundHTTPOptions {
readonly resolveHostAddresses?: DesktopHostAddressResolver; readonly resolveHostAddresses?: DesktopHostAddressResolver;
readonly resolveProxy?: DesktopProxyResolver;
readonly sendThroughSession?: DesktopSessionHTTPSender;
} }
interface DesktopOutboundGETRequest { interface DesktopOutboundGETRequest {
@@ -130,7 +139,7 @@ interface DesktopOutboundGETRequest {
export interface DesktopOutboundHTTPMessage { export interface DesktopOutboundHTTPMessage {
readonly headers: http.IncomingHttpHeaders; readonly headers: http.IncomingHttpHeaders;
readonly message: http.IncomingMessage; readonly message: Readable;
readonly status: number; readonly status: number;
readonly statusText: string; readonly statusText: string;
readonly url: URL; readonly url: URL;
@@ -432,6 +441,11 @@ function isPublicPinnedAddress(pinned: PinnedAddress): boolean {
return pinned.family === 4 ? isPublicIPv4Address(pinned.address) : isPublicIPv6Address(pinned.address); return pinned.family === 4 ? isPublicIPv4Address(pinned.address) : isPublicIPv6Address(pinned.address);
} }
function proxiedHostScope(hostname: string): DesktopOriginAddressScope {
const literal = parseIPAddress(hostname);
return literal == null ? DesktopOriginAddressScope.PUBLIC : addressScope(literal);
}
export function requireDesktopHTTPOrigin(value: string): string { export function requireDesktopHTTPOrigin(value: string): string {
const normalized = normalizeHTTPNetworkOrigin(value); const normalized = normalizeHTTPNetworkOrigin(value);
if (normalized == null) { if (normalized == null) {
@@ -687,6 +701,17 @@ function createRequestBodyLimit(maximumBytes: number): Transform {
}); });
} }
interface DesktopProxiedSend {
readonly body: Uint8Array | Readable | null;
readonly headers: Readonly<Record<string, string>>;
readonly method: string;
readonly release: () => void;
readonly signal: AbortSignal | null;
readonly target: URL;
readonly timeout: () => Error;
readonly timeoutMs: number;
}
export class DesktopOutboundHTTP { export class DesktopOutboundHTTP {
private readonly httpAgent = new http.Agent({keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS}); 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 readonly httpsAgent = new https.Agent({keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS});
@@ -703,6 +728,8 @@ export class DesktopOutboundHTTP {
private readonly registeredRequirements = new Map<string, DesktopAddressRequirement>(); private readonly registeredRequirements = new Map<string, DesktopAddressRequirement>();
private readonly originRequestsInFlightByService = new Map<string, number>(); private readonly originRequestsInFlightByService = new Map<string, number>();
private readonly resolveHostAddresses: DesktopHostAddressResolver; private readonly resolveHostAddresses: DesktopHostAddressResolver;
private readonly resolveProxy: DesktopProxyResolver;
private readonly sendThroughSession: DesktopSessionHTTPSender;
private inFlight = 0; private inFlight = 0;
private originRequestsInFlight = 0; private originRequestsInFlight = 0;
private activeResolutions = 0; private activeResolutions = 0;
@@ -710,6 +737,8 @@ export class DesktopOutboundHTTP {
public constructor(options: DesktopOutboundHTTPOptions = {}) { public constructor(options: DesktopOutboundHTTPOptions = {}) {
this.resolveHostAddresses = options.resolveHostAddresses ?? lookupAllAddresses; this.resolveHostAddresses = options.resolveHostAddresses ?? lookupAllAddresses;
this.resolveProxy = options.resolveProxy ?? resolveDesktopSessionProxy;
this.sendThroughSession = options.sendThroughSession ?? sendThroughDesktopSession;
} }
public async get(request: DesktopOutboundGETRequest): Promise<DesktopOutboundHTTPMessage> { public async get(request: DesktopOutboundGETRequest): Promise<DesktopOutboundHTTPMessage> {
@@ -730,6 +759,15 @@ export class DesktopOutboundHTTP {
if (requirement === DesktopAddressRequirement.PUBLIC && request.url.protocol !== 'https:') { if (requirement === DesktopAddressRequirement.PUBLIC && request.url.protocol !== 'https:') {
throw blocked(DesktopOutboundBlockReason.INSECURE_TRANSPORT, request.context, request.url.hostname); throw blocked(DesktopOutboundBlockReason.INSECURE_TRANSPORT, request.context, request.url.hostname);
} }
if (!(await this.routesDirect(request.url))) {
if (
requirement === DesktopAddressRequirement.PUBLIC &&
proxiedHostScope(request.url.hostname) !== DesktopOriginAddressScope.PUBLIC
) {
throw blocked(DesktopOutboundBlockReason.NON_PUBLIC_ADDRESS, request.context, request.url.hostname);
}
return await this.issueProxied(request, release);
}
const pinned = await this.pinAddress(request.url.hostname, requirement, request.context); const pinned = await this.pinAddress(request.url.hostname, requirement, request.context);
return await this.issue(request, pinned, release); return await this.issue(request, pinned, release);
} catch (error) { } catch (error) {
@@ -779,6 +817,11 @@ export class DesktopOutboundHTTP {
canonicalAnchor: string, canonicalAnchor: string,
unresolvedAnchorRequirement: DesktopAddressRequirement | null, unresolvedAnchorRequirement: DesktopAddressRequirement | null,
): Promise<DesktopAddressRequirement> { ): Promise<DesktopAddressRequirement> {
if (!(await this.routesDirect(new URL(canonicalAnchor)))) {
return proxiedHostScope(new URL(canonicalAnchor).hostname) === DesktopOriginAddressScope.PUBLIC
? DesktopAddressRequirement.PUBLIC
: DesktopAddressRequirement.ANY;
}
let anchor: DesktopOriginAddressBinding; let anchor: DesktopOriginAddressBinding;
try { try {
anchor = await this.ensureBinding(canonicalAnchor, DesktopAddressRequirement.ANY); anchor = await this.ensureBinding(canonicalAnchor, DesktopAddressRequirement.ANY);
@@ -825,6 +868,17 @@ export class DesktopOutboundHTTP {
throw new DesktopOutboundHTTPRequestBodyLimitError(maximumRequestBodyBytes); throw new DesktopOutboundHTTPRequestBodyLimitError(maximumRequestBodyBytes);
} }
const requirement = this.requirementFor(origin, request.originTrust); const requirement = this.requirementFor(origin, request.originTrust);
if (!(await this.routesDirect(target))) {
const scope = proxiedHostScope(target.hostname);
if (requirement === DesktopAddressRequirement.PUBLIC && scope !== DesktopOriginAddressScope.PUBLIC) {
throw new DesktopOutboundHTTPPublicAddressRequiredError(origin);
}
if (scope === DesktopOriginAddressScope.PUBLIC && target.protocol !== 'https:') {
throw new DesktopOutboundHTTPInsecureTransportError(origin);
}
this.requireAdmission();
return await this.issueProxiedOriginRequest(request, target);
}
const binding = await this.ensureBinding(origin, requirement); const binding = await this.ensureBinding(origin, requirement);
if (binding.scope === DesktopOriginAddressScope.PUBLIC && target.protocol !== 'https:') { if (binding.scope === DesktopOriginAddressScope.PUBLIC && target.protocol !== 'https:') {
throw new DesktopOutboundHTTPInsecureTransportError(origin); throw new DesktopOutboundHTTPInsecureTransportError(origin);
@@ -860,6 +914,107 @@ export class DesktopOutboundHTTP {
this.originRequestsInFlightByService.clear(); this.originRequestsInFlightByService.clear();
} }
private async routesDirect(url: URL): Promise<boolean> {
return isDirectProxyRoute(await this.resolveProxy(url.href));
}
private issueProxied(request: DesktopOutboundGETRequest, release: () => void): Promise<DesktopOutboundHTTPMessage> {
return this.sendProxied({
body: null,
headers: {},
method: 'GET',
signal: null,
target: request.url,
timeout: () => new DesktopOutboundHTTPTimeoutError(),
timeoutMs: request.timeoutMs,
release,
}).catch((error: unknown) => {
if (error instanceof DesktopOutboundHTTPTimeoutError) {
throw error;
}
logger.warn('Outbound request failed', {context: request.context, hostname: request.url.hostname, error});
throw new DesktopOutboundHTTPTransportError();
});
}
private issueProxiedOriginRequest(
request: DesktopOutboundHTTPRequest,
target: URL,
): Promise<DesktopOutboundHTTPMessage> {
this.acquireSlot(request.serviceName);
let released = false;
const release = (): void => {
if (released) {
return;
}
released = true;
this.releaseSlot(request.serviceName);
};
return this.sendProxied({
body: this.proxiedRequestBody(request),
headers: requestHeaders(request.headers),
method: request.method,
signal: request.signal,
target,
timeout: () => new DesktopOutboundHTTPRequestTimeoutError(target.toString(), request.timeoutMs),
timeoutMs: request.timeoutMs,
release,
}).catch((error: unknown) => {
if (request.signal?.aborted === true) {
throw new DesktopOutboundHTTPRequestAbortedError(target.toString());
}
throw error;
});
}
private async sendProxied({
body,
headers,
method,
signal,
target,
timeout,
timeoutMs,
release,
}: DesktopProxiedSend): Promise<DesktopOutboundHTTPMessage> {
const deadline = new AbortController();
const timer = setTimeout(() => deadline.abort(timeout()), timeoutMs);
timer.unref();
const settle = (): void => {
clearTimeout(timer);
release();
};
try {
const response = await this.sendThroughSession({
body,
headers,
method,
signal: signal == null ? deadline.signal : AbortSignal.any([deadline.signal, signal]),
url: target,
});
response.message.on('end', settle);
response.message.on('close', settle);
response.message.on('error', settle);
return {...response, url: target};
} catch (error) {
settle();
throw error;
}
}
private proxiedRequestBody(request: DesktopOutboundHTTPRequest): Uint8Array | Readable | null {
const body = request.body;
if (body == null || body instanceof Uint8Array) {
return body;
}
const limit = createRequestBodyLimit(
request.maximumRequestBodyBytes ?? DESKTOP_OUTBOUND_HTTP_MAX_REQUEST_BODY_BYTES,
);
const source = Readable.fromWeb(body as unknown as NodeReadableStream<Uint8Array>);
source.on('error', (error: Error) => limit.destroy(error));
return source.pipe(limit);
}
private async pinAddress( private async pinAddress(
hostname: string, hostname: string,
requirement: DesktopAddressRequirement, requirement: DesktopAddressRequirement,
@@ -0,0 +1,193 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Buffer} from 'node:buffer';
import type http from 'node:http';
import {Readable, type Writable} from 'node:stream';
import {net, session} from 'electron';
const CHROMIUM_REFUSED_REQUEST_HEADERS: ReadonlySet<string> = new Set([
'connection',
'content-length',
'cookie2',
'expect',
'host',
'keep-alive',
'referer',
'te',
'trailer',
'transfer-encoding',
'upgrade',
]);
const DIRECT_PROXY_ROUTE = 'DIRECT';
export interface DesktopSessionHTTPRequest {
readonly body: Uint8Array | Readable | null;
readonly headers: Readonly<Record<string, string>>;
readonly method: string;
readonly signal: AbortSignal;
readonly url: URL;
}
export interface DesktopSessionHTTPResponse {
readonly headers: http.IncomingHttpHeaders;
readonly message: Readable;
readonly status: number;
readonly statusText: string;
}
export type DesktopSessionHTTPSender = (request: DesktopSessionHTTPRequest) => Promise<DesktopSessionHTTPResponse>;
export type DesktopProxyResolver = (url: string) => Promise<string>;
class DesktopSessionHTTPAbortedError extends Error {
public constructor(url: string) {
super(`Desktop session request to ${url} was aborted`);
this.name = 'DesktopSessionHTTPAbortedError';
}
}
export function isDirectProxyRoute(route: string): boolean {
const first = route.split(';', 1)[0] ?? '';
return first.trim().toUpperCase() === DIRECT_PROXY_ROUTE;
}
export function resolveDesktopSessionProxy(url: string): Promise<string> {
return session.defaultSession.resolveProxy(url);
}
function isRefusedRequestHeader(name: string): boolean {
const normalized = name.toLowerCase();
return CHROMIUM_REFUSED_REQUEST_HEADERS.has(normalized) || normalized.startsWith('proxy-');
}
function decodedResponseHeaders(headers: Record<string, string | Array<string>>): http.IncomingHttpHeaders {
const result: http.IncomingHttpHeaders = {};
const decoded = headers['content-encoding'] != null;
for (const [name, value] of Object.entries(headers)) {
const normalized = name.toLowerCase();
if (decoded && (normalized === 'content-encoding' || normalized === 'content-length')) {
continue;
}
if (normalized === 'set-cookie') {
result['set-cookie'] = Array.isArray(value) ? value : [value];
continue;
}
result[normalized] = Array.isArray(value) ? value.join(', ') : value;
}
return result;
}
export function sendThroughDesktopSession(request: DesktopSessionHTTPRequest): Promise<DesktopSessionHTTPResponse> {
return new Promise<DesktopSessionHTTPResponse>((resolve, reject) => {
if (request.signal.aborted) {
reject(request.signal.reason);
return;
}
const clientRequest = net.request({
bypassCustomProtocolHandlers: true,
cache: 'no-store',
credentials: 'omit',
method: request.method,
redirect: 'manual',
session: session.defaultSession,
url: request.url.href,
useSessionCookies: false,
});
let responded = false;
let finished = false;
let body: Readable | null = null;
const fail = (error: unknown): void => {
if (finished) {
return;
}
finished = true;
request.signal.removeEventListener('abort', onAbort);
if (body == null) {
reject(error);
return;
}
body.destroy(error instanceof Error ? error : new DesktopSessionHTTPAbortedError(request.url.href));
};
const finish = (): void => {
finished = true;
request.signal.removeEventListener('abort', onAbort);
};
const onAbort = (): void => {
clientRequest.abort();
fail(request.signal.reason);
};
request.signal.addEventListener('abort', onAbort, {once: true});
for (const [name, value] of Object.entries(request.headers)) {
if (!isRefusedRequestHeader(name)) {
clientRequest.setHeader(name, value);
}
}
clientRequest.on('redirect', (status, _method, _location, headers) => {
responded = true;
finish();
clientRequest.abort();
resolve({
headers: decodedResponseHeaders(headers),
message: Readable.from([]),
status,
statusText: '',
});
});
clientRequest.on('response', (incoming) => {
responded = true;
const source = incoming as unknown as Readable;
const message = new Readable({
read() {
source.resume();
},
destroy(error, callback) {
if (!finished) {
finish();
clientRequest.abort();
}
callback(error);
},
});
body = message;
source.on('data', (chunk: Buffer) => {
if (!message.push(chunk)) {
source.pause();
}
});
source.on('end', () => {
finish();
message.push(null);
});
source.on('error', fail);
incoming.on('aborted', () => fail(new DesktopSessionHTTPAbortedError(request.url.href)));
resolve({
headers: decodedResponseHeaders(incoming.headers),
message,
status: incoming.statusCode,
statusText: incoming.statusMessage,
});
});
clientRequest.on('error', fail);
clientRequest.on('abort', () => {
if (!responded) {
fail(new DesktopSessionHTTPAbortedError(request.url.href));
}
});
const requestBody = request.body;
if (requestBody == null) {
clientRequest.end();
return;
}
if (requestBody instanceof Uint8Array) {
clientRequest.end(Buffer.from(requestBody));
return;
}
clientRequest.chunkedEncoding = true;
requestBody.on('error', (error: Error) => {
clientRequest.abort();
fail(error);
});
requestBody.pipe(clientRequest as unknown as Writable);
});
}
@@ -82,6 +82,13 @@ async function loadFileDownloads({registerOrigin = true} = {}) {
createChildLogger: () => ({debug() {}, info() {}, warn() {}, error() {}}), createChildLogger: () => ({debug() {}, info() {}, warn() {}, error() {}}),
}, },
'@fluxer/instance_bootstrap/src/NetworkOrigin': {normalizeHTTPNetworkOrigin: (value) => new URL(value).origin}, '@fluxer/instance_bootstrap/src/NetworkOrigin': {normalizeHTTPNetworkOrigin: (value) => new URL(value).origin},
'@electron/main/DesktopSessionHTTP': {
isDirectProxyRoute: (route) => route === 'DIRECT',
resolveDesktopSessionProxy: async () => 'DIRECT',
sendThroughDesktopSession: async () => {
throw new Error('a direct route never reaches the session');
},
},
}; };
const sandbox = { const sandbox = {
console, console,
@@ -414,7 +414,7 @@ function contentEncoding(incoming: http.IncomingHttpHeaders): string | null {
return normalized.length === 0 ? null : normalized; return normalized.length === 0 ? null : normalized;
} }
function decodedMessageStream(message: http.IncomingMessage, encoding: string | null): Readable { function decodedMessageStream(message: Readable, encoding: string | null): Readable {
if (encoding == null || encoding === 'identity') { if (encoding == null || encoding === 'identity') {
return message; return message;
} }
@@ -153,7 +153,8 @@ export function installElectronStub(extra = {}) {
} }
} }
const nativeTheme = {themeSource: 'system'}; const nativeTheme = {themeSource: 'system'};
state.electron = {nativeTheme, webContents, webFrameMain, ...extra, app}; const session = {defaultSession: {resolveProxy: async () => 'DIRECT'}};
state.electron = {nativeTheme, session, webContents, webFrameMain, ...extra, app};
return registry; return registry;
} }
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {getDesktopOutboundHTTP} from '@electron/main/DesktopOutboundHTTP'; import {getDesktopOutboundHTTP} from '@electron/main/DesktopOutboundHTTP';
import {isDirectProxyRoute} from '@electron/main/DesktopSessionHTTP';
import type {GatewayOriginRegistry} from '@electron/main/GatewayOriginRegistry'; import type {GatewayOriginRegistry} from '@electron/main/GatewayOriginRegistry';
import { import {
createGatewaySocketBoundary, createGatewaySocketBoundary,
@@ -28,6 +29,7 @@ import {
NATIVE_GATEWAY_TRANSPORT_CHANNELS, NATIVE_GATEWAY_TRANSPORT_CHANNELS,
NATIVE_GATEWAY_TRANSPORT_EVENT_CHANNEL, NATIVE_GATEWAY_TRANSPORT_EVENT_CHANNEL,
NATIVE_GATEWAY_TRANSPORT_MAX_CONNECTIONS_PER_DOCUMENT, NATIVE_GATEWAY_TRANSPORT_MAX_CONNECTIONS_PER_DOCUMENT,
NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE,
type NativeGatewayTransportCreateResult, type NativeGatewayTransportCreateResult,
type NativeGatewayTransportEvent, type NativeGatewayTransportEvent,
NativeGatewayTransportEventKind, NativeGatewayTransportEventKind,
@@ -61,7 +63,7 @@ class NativeGatewayTransportUnavailableError extends Error {
class NativeGatewayTransportProxiedError extends Error { class NativeGatewayTransportProxiedError extends Error {
public constructor() { public constructor() {
super('The native gateway transport cannot reach a gateway that resolves through a proxy'); super(NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE);
this.name = 'NativeGatewayTransportProxiedError'; this.name = 'NativeGatewayTransportProxiedError';
} }
} }
@@ -263,7 +265,7 @@ export class DesktopNativeGatewayTransport {
private async requireDirectRoute(event: RendererDocumentIpcEvent, url: string): Promise<void> { private async requireDirectRoute(event: RendererDocumentIpcEvent, url: string): Promise<void> {
const route = await this.dependencies.resolveProxy(event, url.replace(/^ws/u, 'http')); const route = await this.dependencies.resolveProxy(event, url.replace(/^ws/u, 'http'));
if (route.trim() !== 'DIRECT') { if (!isDirectProxyRoute(route)) {
throw new NativeGatewayTransportProxiedError(); throw new NativeGatewayTransportProxiedError();
} }
} }
+4 -2
View File
@@ -10,6 +10,7 @@ import {
stageAppImageUpdate, stageAppImageUpdate,
} from '@electron/main/AppImageUpdate'; } from '@electron/main/AppImageUpdate';
import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath'; import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath';
import {moduleNetworkFetch} from '@electron/main/ModuleNetworkFetch';
import {compareModuleVersions, parseModuleVersion} from '@electron/main/ModuleVersion'; import {compareModuleVersions, parseModuleVersion} from '@electron/main/ModuleVersion';
import {getUpdateBaseUrl} from '@electron/main/ShellDownloadFormats'; import {getUpdateBaseUrl} from '@electron/main/ShellDownloadFormats';
import type {ShellUpdateCapability, ShellUpdatePlan} from '@electron/main/ShellUpdateCapability'; import type {ShellUpdateCapability, ShellUpdatePlan} from '@electron/main/ShellUpdateCapability';
@@ -18,7 +19,7 @@ import {
readVelopackApplyAttempt, readVelopackApplyAttempt,
recordVelopackApplyAttempt, recordVelopackApplyAttempt,
} from '@electron/main/UpdaterApplyState'; } from '@electron/main/UpdaterApplyState';
import {app, autoUpdater} from 'electron'; import {app, autoUpdater, net} from 'electron';
import type {UpdateInfo, VelopackAsset} from 'velopack'; import type {UpdateInfo, VelopackAsset} from 'velopack';
const requireModule = createRequire(import.meta.url); const requireModule = createRequire(import.meta.url);
@@ -148,7 +149,7 @@ async function runVelopackSelfUpdate(control: SelfUpdateControl, hooks: ShellSel
} }
async function fetchPublishedAppImage(): Promise<{version: string; sha256: string | null}> { async function fetchPublishedAppImage(): Promise<{version: string; sha256: string | null}> {
const response = await fetch(`${getUpdateBaseUrl()}/latest`, { const response = await net.fetch(`${getUpdateBaseUrl()}/latest`, {
cache: 'no-store', cache: 'no-store',
headers: {Accept: 'application/json', 'Cache-Control': 'no-cache'}, headers: {Accept: 'application/json', 'Cache-Control': 'no-cache'},
}); });
@@ -199,6 +200,7 @@ async function runAppImageSelfUpdate(
target, target,
url: `${getUpdateBaseUrl()}/${published.version}/appimage`, url: `${getUpdateBaseUrl()}/${published.version}/appimage`,
expectedSha256: published.sha256, expectedSha256: published.sha256,
fetchImpl: moduleNetworkFetch,
onProgress: ({transferred, total}) => { onProgress: ({transferred, total}) => {
if (control.isSettled() || total <= 0) return; if (control.isSettled() || total <= 0) return;
hooks.onDownloading(Math.min(100, (transferred / total) * 100)); hooks.onDownloading(Math.min(100, (transferred / total) * 100));
+2 -2
View File
@@ -14,7 +14,7 @@ import {
DICTIONARY_SOURCES, DICTIONARY_SOURCES,
type SpellcheckDictionaryCatalogEntry, type SpellcheckDictionaryCatalogEntry,
} from '@electron/main/SpellcheckDictionaries'; } from '@electron/main/SpellcheckDictionaries';
import {app, ipcMain, type Session, type WebContents} from 'electron'; import {app, ipcMain, net, type Session, type WebContents} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import {franc} from 'franc-min'; import {franc} from 'franc-min';
import {type Hunspell, type HunspellFactory, loadModule} from 'hunspell-asm'; import {type Hunspell, type HunspellFactory, loadModule} from 'hunspell-asm';
@@ -280,7 +280,7 @@ const downloadDictionaryFile = async (
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DICTIONARY_DOWNLOAD_TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), DICTIONARY_DOWNLOAD_TIMEOUT_MS);
try { try {
const response = await fetch(dictionaryFileUrl(dict, fileName), { const response = await net.fetch(dictionaryFileUrl(dict, fileName), {
headers: { headers: {
Accept: 'application/octet-stream,*/*;q=0.8', Accept: 'application/octet-stream,*/*;q=0.8',
}, },
+2
View File
@@ -143,6 +143,7 @@ function loadUpdater({
'@electron/common/DesktopIdentity': {DESKTOP_ARTIFACT_PRODUCT_NAME: 'Fluxer-Canary'}, '@electron/common/DesktopIdentity': {DESKTOP_ARTIFACT_PRODUCT_NAME: 'Fluxer-Canary'},
'@electron/common/UserDataPath': {isPortableMode: () => false}, '@electron/common/UserDataPath': {isPortableMode: () => false},
'@electron/main/DesktopTray': {destroyDesktopTray() {}}, '@electron/main/DesktopTray': {destroyDesktopTray() {}},
'@electron/main/ModuleNetworkFetch': {moduleNetworkFetch: (input, init) => sandbox.fetch(input, init)},
'@electron/main/LinuxSandbox': {isFlatpakRuntime: () => false}, '@electron/main/LinuxSandbox': {isFlatpakRuntime: () => false},
'@electron/main/Troubleshooting': { '@electron/main/Troubleshooting': {
relaunchAndExit() { relaunchAndExit() {
@@ -164,6 +165,7 @@ function loadUpdater({
exit() {}, exit() {},
}, },
autoUpdater: {on() {}}, autoUpdater: {on() {}},
net: {fetch: (input, init) => sandbox.fetch(input, init)},
ipcMain: { ipcMain: {
handle(channel, handler) { handle(channel, handler) {
handlers.set(channel, handler); handlers.set(channel, handler);
+4 -2
View File
@@ -13,6 +13,7 @@ import {
sweepAbandonedAppImageUpdates, sweepAbandonedAppImageUpdates,
} from '@electron/main/AppImageUpdate'; } from '@electron/main/AppImageUpdate';
import {destroyDesktopTray} from '@electron/main/DesktopTray'; import {destroyDesktopTray} from '@electron/main/DesktopTray';
import {moduleNetworkFetch} from '@electron/main/ModuleNetworkFetch';
import {MANUAL_DESKTOP_FORMATS, type ManualDesktopFormat} from '@electron/main/ShellDownloadFormats'; import {MANUAL_DESKTOP_FORMATS, type ManualDesktopFormat} from '@electron/main/ShellDownloadFormats';
import {resolveShellUpdatePlan, ShellUpdateCapability} from '@electron/main/ShellUpdateCapability'; import {resolveShellUpdatePlan, ShellUpdateCapability} from '@electron/main/ShellUpdateCapability';
import {relaunchAndExit} from '@electron/main/Troubleshooting'; import {relaunchAndExit} from '@electron/main/Troubleshooting';
@@ -33,7 +34,7 @@ import {
type UpdaterDownloadOption, type UpdaterDownloadOption,
} from '@electron/main/UpdaterDownloads'; } from '@electron/main/UpdaterDownloads';
import {setQuitting} from '@electron/main/Window'; import {setQuitting} from '@electron/main/Window';
import {app, autoUpdater, type BrowserWindow, ipcMain} from 'electron'; import {app, autoUpdater, type BrowserWindow, ipcMain, net} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import type {UpdateInfo, VelopackAsset} from 'velopack'; import type {UpdateInfo, VelopackAsset} from 'velopack';
@@ -552,7 +553,7 @@ async function fetchManualLatest(options: {forceRefresh?: boolean} = {}): Promis
if (!options.forceRefresh && manualLatestCache && now - manualLatestCache.at < MANUAL_CACHE_TTL_MS) { if (!options.forceRefresh && manualLatestCache && now - manualLatestCache.at < MANUAL_CACHE_TTL_MS) {
return manualLatestCache.info; return manualLatestCache.info;
} }
const response = await fetch(`${UPDATE_BASE_URL}/latest`, { const response = await net.fetch(`${UPDATE_BASE_URL}/latest`, {
cache: 'no-store', cache: 'no-store',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@@ -628,6 +629,7 @@ async function downloadAppImageUpdate(
target, target,
url, url,
expectedSha256, expectedSha256,
fetchImpl: moduleNetworkFetch,
onProgress: ({transferred, total}) => { onProgress: ({transferred, total}) => {
const now = Date.now(); const now = Date.now();
const dtMs = now - lastSampleAt; const dtMs = now - lastSampleAt;
@@ -74,6 +74,9 @@ export const NATIVE_GATEWAY_TRANSPORT_CHANNELS = Object.freeze({
isAvailable: 'native-gateway-transport:is-available', isAvailable: 'native-gateway-transport:is-available',
} as const); } as const);
export const NATIVE_GATEWAY_TRANSPORT_PROXIED_MESSAGE =
'The native gateway transport cannot reach a gateway that resolves through a proxy';
export const NATIVE_GATEWAY_TRANSPORT_EVENT_CHANNEL = 'native-gateway-transport:event'; export const NATIVE_GATEWAY_TRANSPORT_EVENT_CHANNEL = 'native-gateway-transport:event';
export const NATIVE_GATEWAY_TRANSPORT_AVAILABLE_RENDERER_ARG = '--fluxer-native-gateway-available=1'; export const NATIVE_GATEWAY_TRANSPORT_AVAILABLE_RENDERER_ARG = '--fluxer-native-gateway-available=1';