mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
feat(desktop): bundled renderer, modules and instance accounts (#3254)
This commit is contained in:
1 parent
c748c8af4e
commit
7c5fa2180a
1275 files changed
+215074
-81246
No files matched your search
@@ -0,0 +1,339 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import {DESKTOP_APP_ORIGIN} from '@electron/common/Constants';
|
||||
import {isLocalAppRendererDocumentURL, isLocalAppURL} from '@electron/main/LocalAppURL';
|
||||
import {webContents as electronWebContents, webFrameMain as electronWebFrameMain} from 'electron';
|
||||
|
||||
export const DESKTOP_PROTOCOL_AUTHORIZATION_HEADER = 'X-Fluxer-Desktop-Protocol-Authorization';
|
||||
|
||||
const DESKTOP_PROTOCOL_AUTHORIZATION_HEADER_LOWER = DESKTOP_PROTOCOL_AUTHORIZATION_HEADER.toLowerCase();
|
||||
const AUTHORIZATION_SECRET_BYTES = 32;
|
||||
const MAIN_FRAME_RESOURCE_TYPE = 'mainFrame';
|
||||
const BLANK_FRAME_URLS: ReadonlySet<string> = new Set(['', 'about:blank']);
|
||||
|
||||
interface LocalAppRequestSnapshot {
|
||||
readonly url: string;
|
||||
readonly resourceType: string;
|
||||
readonly frame: Electron.WebFrameMain | null;
|
||||
readonly webContents: Electron.WebContents | null;
|
||||
readonly webContentsId: number | null;
|
||||
}
|
||||
|
||||
class InvalidLocalAppRequestDetailsError extends TypeError {
|
||||
constructor() {
|
||||
super('Electron request details have invalid or inconsistent fields');
|
||||
this.name = 'InvalidLocalAppRequestDetailsError';
|
||||
}
|
||||
}
|
||||
|
||||
class InvalidLocalAppWebContentsError extends TypeError {
|
||||
constructor() {
|
||||
super('Local app authorization requires a live Electron WebContents reference');
|
||||
this.name = 'InvalidLocalAppWebContentsError';
|
||||
}
|
||||
}
|
||||
|
||||
export class DesktopLocalAppAuthorization {
|
||||
private readonly authorizationSecret = randomBytes(AUTHORIZATION_SECRET_BYTES).toString('hex');
|
||||
private readonly authorizedWebContents = new Map<number, Electron.WebContents>();
|
||||
|
||||
authorize(webContents: Electron.WebContents): void {
|
||||
if (!isWebContentsReference(webContents) || webContents.isDestroyed()) {
|
||||
throw new InvalidLocalAppWebContentsError();
|
||||
}
|
||||
const webContentsId = webContents.id;
|
||||
const registered = this.authorizedWebContents.get(webContentsId);
|
||||
if (registered != null) {
|
||||
if (registered !== webContents) {
|
||||
throw new InvalidLocalAppWebContentsError();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.authorizedWebContents.set(webContentsId, webContents);
|
||||
webContents.once('destroyed', () => {
|
||||
if (this.authorizedWebContents.get(webContentsId) === webContents) {
|
||||
this.authorizedWebContents.delete(webContentsId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hasValidRequestAuthorization(request: Request): boolean {
|
||||
return request.headers.get(DESKTOP_PROTOCOL_AUTHORIZATION_HEADER) === this.authorizationSecret;
|
||||
}
|
||||
|
||||
applyRequestHeaders(details: unknown, headers: Record<string, string>): Record<string, string> {
|
||||
const next = withoutAuthorizationHeader(headers);
|
||||
if (!this.canAuthorizeRequest(details)) {
|
||||
return next;
|
||||
}
|
||||
next[DESKTOP_PROTOCOL_AUTHORIZATION_HEADER] = this.authorizationSecret;
|
||||
return next;
|
||||
}
|
||||
|
||||
private canAuthorizeRequest(details: unknown): boolean {
|
||||
if (!isLocalAppURL(readRequestURL(details))) {
|
||||
return false;
|
||||
}
|
||||
let snapshot: LocalAppRequestSnapshot;
|
||||
try {
|
||||
snapshot = readRequestSnapshot(details);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const owner = this.resolveAuthorizedWebContents(snapshot);
|
||||
if (owner == null) {
|
||||
return false;
|
||||
}
|
||||
const frame = snapshot.frame;
|
||||
if (frame == null || !isFrameOwnedByWebContents(frame, owner)) {
|
||||
return false;
|
||||
}
|
||||
if (isLocalAppMainFrameBootstrap(snapshot, frame, owner)) {
|
||||
return true;
|
||||
}
|
||||
return isTrustedLocalAppFrame(frame);
|
||||
}
|
||||
|
||||
private resolveAuthorizedWebContents(snapshot: LocalAppRequestSnapshot): Electron.WebContents | null {
|
||||
const webContentsId = snapshot.webContentsId;
|
||||
if (webContentsId == null) {
|
||||
return null;
|
||||
}
|
||||
const registered = this.authorizedWebContents.get(webContentsId);
|
||||
if (registered == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (registered.isDestroyed() || registered.id !== webContentsId) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (snapshot.webContents != null && snapshot.webContents !== registered) {
|
||||
return null;
|
||||
}
|
||||
return registered;
|
||||
}
|
||||
}
|
||||
|
||||
let authorization: DesktopLocalAppAuthorization | null = null;
|
||||
|
||||
export function getDesktopLocalAppAuthorization(): DesktopLocalAppAuthorization {
|
||||
authorization ??= new DesktopLocalAppAuthorization();
|
||||
return authorization;
|
||||
}
|
||||
|
||||
function isLocalAppMainFrameBootstrap(
|
||||
snapshot: LocalAppRequestSnapshot,
|
||||
frame: Electron.WebFrameMain,
|
||||
owner: Electron.WebContents,
|
||||
): boolean {
|
||||
if (snapshot.resourceType !== MAIN_FRAME_RESOURCE_TYPE) {
|
||||
return false;
|
||||
}
|
||||
if (!isLocalAppRendererDocumentURL(snapshot.url)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (frame.parent != null || frame.top !== frame) {
|
||||
return false;
|
||||
}
|
||||
return owner.mainFrame === frame;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isTrustedLocalAppFrame(frame: Electron.WebFrameMain): boolean {
|
||||
try {
|
||||
const frameURL: unknown = frame.url;
|
||||
if (typeof frameURL !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (isLocalAppRendererDocumentURL(frameURL)) {
|
||||
return true;
|
||||
}
|
||||
if (!BLANK_FRAME_URLS.has(frameURL)) {
|
||||
return false;
|
||||
}
|
||||
return frame.origin === DESKTOP_APP_ORIGIN;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isFrameOwnedByWebContents(frame: Electron.WebFrameMain, owner: Electron.WebContents): boolean {
|
||||
try {
|
||||
if (frame.isDestroyed() || frame.detached) {
|
||||
return false;
|
||||
}
|
||||
return electronWebContents.fromFrame(frame) === owner;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function withoutAuthorizationHeader(headers: Record<string, string>): Record<string, string> {
|
||||
const next: Record<string, string> = Object.create(null) as Record<string, string>;
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (name.toLowerCase() !== DESKTOP_PROTOCOL_AUTHORIZATION_HEADER_LOWER) {
|
||||
next[name] = value;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function readObjectRecord(value: unknown): Readonly<Record<string, unknown>> | null {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return null;
|
||||
}
|
||||
return value as Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function readRequestURL(details: unknown): string | null {
|
||||
const record = readObjectRecord(details);
|
||||
if (record == null || typeof record.url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return record.url;
|
||||
}
|
||||
|
||||
function isWebContentsReference(value: unknown): value is Electron.WebContents {
|
||||
const record = readObjectRecord(value);
|
||||
if (record == null) {
|
||||
return false;
|
||||
}
|
||||
let id: unknown;
|
||||
try {
|
||||
id = record.id;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) {
|
||||
return false;
|
||||
}
|
||||
let resolved: unknown;
|
||||
try {
|
||||
resolved = electronWebContents.fromId(id);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return resolved === value;
|
||||
}
|
||||
|
||||
function isWebFrameMainReference(value: unknown): value is Electron.WebFrameMain {
|
||||
const record = readObjectRecord(value);
|
||||
if (record == null) {
|
||||
return false;
|
||||
}
|
||||
let processId: unknown;
|
||||
let routingId: unknown;
|
||||
try {
|
||||
processId = record.processId;
|
||||
routingId = record.routingId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (typeof processId !== 'number' || !Number.isSafeInteger(processId) || processId < 0) {
|
||||
return false;
|
||||
}
|
||||
if (typeof routingId !== 'number' || !Number.isSafeInteger(routingId) || routingId < 0) {
|
||||
return false;
|
||||
}
|
||||
let resolved: unknown;
|
||||
try {
|
||||
resolved = electronWebFrameMain.fromId(processId, routingId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return resolved === value;
|
||||
}
|
||||
|
||||
function readRequestSnapshot(details: unknown): LocalAppRequestSnapshot {
|
||||
const record = readObjectRecord(details);
|
||||
if (record == null) {
|
||||
throw new InvalidLocalAppRequestDetailsError();
|
||||
}
|
||||
const url = record.url;
|
||||
const resourceType = record.resourceType;
|
||||
if (typeof url !== 'string' || typeof resourceType !== 'string') {
|
||||
throw new InvalidLocalAppRequestDetailsError();
|
||||
}
|
||||
const frame = readOptionalWebFrameMain(record.frame);
|
||||
let webContents = readOptionalWebContents(record.webContents);
|
||||
let webContentsId = readOptionalWebContentsId(record.webContentsId);
|
||||
if (webContents != null) {
|
||||
const ownerId = readWebContentsId(webContents);
|
||||
if (webContentsId != null && webContentsId !== ownerId) {
|
||||
throw new InvalidLocalAppRequestDetailsError();
|
||||
}
|
||||
webContentsId = ownerId;
|
||||
}
|
||||
if (frame != null) {
|
||||
let frameWebContents: unknown;
|
||||
try {
|
||||
frameWebContents = electronWebContents.fromFrame(frame);
|
||||
} catch {
|
||||
throw new InvalidLocalAppRequestDetailsError();
|
||||
}
|
||||
if (!isWebContentsReference(frameWebContents)) {
|
||||
throw new InvalidLocalAppRequestDetailsError();
|
||||
}
|
||||
const frameWebContentsId = readWebContentsId(frameWebContents);
|
||||
if (webContents != null && webContents !== frameWebContents) {
|
||||
throw new InvalidLocalAppRequestDetailsError();
|
||||
}
|
||||
if (webContentsId != null && webContentsId !== frameWebContentsId) {
|
||||
throw new InvalidLocalAppRequestDetailsError();
|
||||
}
|
||||
webContents = frameWebContents;
|
||||
webContentsId = frameWebContentsId;
|
||||
}
|
||||
return Object.freeze({url, resourceType, frame, webContents, webContentsId});
|
||||
}
|
||||
|
||||
function readOptionalWebContents(value: unknown): Electron.WebContents | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (!isWebContentsReference(value)) {
|
||||
throw new InvalidLocalAppRequestDetailsError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readOptionalWebFrameMain(value: unknown): Electron.WebFrameMain | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (!isWebFrameMainReference(value)) {
|
||||
throw new InvalidLocalAppRequestDetailsError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readOptionalWebContentsId(value: unknown): number | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new InvalidLocalAppRequestDetailsError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readWebContentsId(webContents: Electron.WebContents): number {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = webContents.id;
|
||||
} catch {
|
||||
throw new InvalidLocalAppRequestDetailsError();
|
||||
}
|
||||
const webContentsId = readOptionalWebContentsId(value);
|
||||
if (webContentsId == null) {
|
||||
throw new InvalidLocalAppRequestDetailsError();
|
||||
}
|
||||
return webContentsId;
|
||||
}
|
||||
Reference in new issue
Block a user