mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +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,245 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {STABLE_APP_URL} from '@electron/common/Constants';
|
||||
import {isPlainRecord} from '@electron/common/PlainRecord';
|
||||
|
||||
const SUB_FRAME_RESOURCE_TYPE = 'subFrame';
|
||||
|
||||
const REFERER_REQUEST_HEADER = 'Referer';
|
||||
const REFERER_HEADER_NAME_LOWERCASE = 'referer';
|
||||
const YOUTUBE_EMBED_FRAME_ORIGIN = 'https://www.youtube.com';
|
||||
const YOUTUBE_EMBED_FRAME_PATH_PREFIX = '/embed/';
|
||||
|
||||
interface DesktopRuntimeSecurityLogger {
|
||||
error: (...args: Array<unknown>) => void;
|
||||
}
|
||||
|
||||
interface DesktopLocalAppRequestAuthorization {
|
||||
readonly applyRequestHeaders: (details: unknown, headers: Record<string, string>) => Record<string, string>;
|
||||
}
|
||||
|
||||
interface DesktopRuntimeSecurityDependencies {
|
||||
readonly logger: DesktopRuntimeSecurityLogger;
|
||||
readonly localAppAuthorization?: DesktopLocalAppRequestAuthorization;
|
||||
}
|
||||
|
||||
class DesktopRuntimeSecurityClosedError extends Error {
|
||||
constructor() {
|
||||
super('Desktop runtime security policy is closed');
|
||||
this.name = 'DesktopRuntimeSecurityClosedError';
|
||||
}
|
||||
}
|
||||
|
||||
class InvalidWebRequestDetailsError extends TypeError {
|
||||
constructor() {
|
||||
super('Electron web request details have an invalid shape');
|
||||
this.name = 'InvalidWebRequestDetailsError';
|
||||
}
|
||||
}
|
||||
|
||||
class InvalidWebRequestCallbackError extends TypeError {
|
||||
constructor() {
|
||||
super('Electron web request callback must be a function');
|
||||
this.name = 'InvalidWebRequestCallbackError';
|
||||
}
|
||||
}
|
||||
|
||||
export class DesktopRuntimeSecurity {
|
||||
private readonly logger: DesktopRuntimeSecurityLogger;
|
||||
private readonly localAppAuthorization: DesktopLocalAppRequestAuthorization | null;
|
||||
private readonly installedSessions = new Set<Electron.Session>();
|
||||
private acceptingChanges = true;
|
||||
|
||||
constructor(dependencies: DesktopRuntimeSecurityDependencies) {
|
||||
this.logger = dependencies.logger;
|
||||
this.localAppAuthorization = dependencies.localAppAuthorization ?? null;
|
||||
}
|
||||
|
||||
install(session: Electron.Session): void {
|
||||
this.requireAcceptingChanges();
|
||||
if (this.installedSessions.has(session)) {
|
||||
return;
|
||||
}
|
||||
this.installedSessions.add(session);
|
||||
try {
|
||||
session.webRequest.onBeforeSendHeaders((details: unknown, callback: unknown) => {
|
||||
this.settleRequestHeaders(details, callback);
|
||||
});
|
||||
} catch (installationError) {
|
||||
this.uninstall(session);
|
||||
throw installationError;
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.acceptingChanges = false;
|
||||
for (const session of this.installedSessions) {
|
||||
this.uninstall(session);
|
||||
}
|
||||
}
|
||||
|
||||
private uninstall(session: Electron.Session): void {
|
||||
this.installedSessions.delete(session);
|
||||
try {
|
||||
session.webRequest.onBeforeSendHeaders(null);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to remove desktop runtime security listeners', error);
|
||||
}
|
||||
}
|
||||
|
||||
private requireAcceptingChanges(): void {
|
||||
if (!this.acceptingChanges) {
|
||||
throw new DesktopRuntimeSecurityClosedError();
|
||||
}
|
||||
}
|
||||
|
||||
private settleRequestHeaders(detailsValue: unknown, callbackValue: unknown): void {
|
||||
let settlement: Record<string, unknown> = {};
|
||||
try {
|
||||
settlement = this.rewriteRequestHeaders(detailsValue);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to rewrite desktop runtime security request headers', error);
|
||||
settlement = {};
|
||||
}
|
||||
try {
|
||||
invokeCallback(callbackValue, settlement);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to settle desktop runtime security request headers', error);
|
||||
}
|
||||
}
|
||||
|
||||
private rewriteRequestHeaders(detailsValue: unknown): Record<string, unknown> {
|
||||
return injectEmbedFrameReferrer(detailsValue, this.authorizeLocalAppRequest(detailsValue));
|
||||
}
|
||||
|
||||
private authorizeLocalAppRequest(detailsValue: unknown): Record<string, unknown> {
|
||||
const authorization = this.localAppAuthorization;
|
||||
if (authorization == null) {
|
||||
return {};
|
||||
}
|
||||
const details = readWebRequestDetails(detailsValue);
|
||||
const headers = readRequestHeaderRecord(details.requestHeadersValue);
|
||||
if (headers == null) {
|
||||
return {};
|
||||
}
|
||||
const next = authorization.applyRequestHeaders(detailsValue, headers);
|
||||
if (haveEqualHeaderRecords(headers, next)) {
|
||||
return {};
|
||||
}
|
||||
return {requestHeaders: next};
|
||||
}
|
||||
}
|
||||
|
||||
function injectEmbedFrameReferrer(detailsValue: unknown, settlement: Record<string, unknown>): Record<string, unknown> {
|
||||
const details = readWebRequestDetails(detailsValue);
|
||||
if (details.resourceType !== SUB_FRAME_RESOURCE_TYPE || !isYouTubeEmbedFrameURL(details.url)) {
|
||||
return settlement;
|
||||
}
|
||||
const headers = readRequestHeaderRecord(settlement.requestHeaders ?? details.requestHeadersValue);
|
||||
if (headers == null) {
|
||||
return settlement;
|
||||
}
|
||||
const next: Record<string, string> = Object.create(null) as Record<string, string>;
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (name.toLowerCase() !== REFERER_HEADER_NAME_LOWERCASE) {
|
||||
next[name] = value;
|
||||
}
|
||||
}
|
||||
next[REFERER_REQUEST_HEADER] = embedFrameReferrer();
|
||||
return {...settlement, requestHeaders: next};
|
||||
}
|
||||
|
||||
function embedFrameReferrer(): string {
|
||||
return STABLE_APP_URL.endsWith('/') ? STABLE_APP_URL : `${STABLE_APP_URL}/`;
|
||||
}
|
||||
|
||||
function isYouTubeEmbedFrameURL(value: string): boolean {
|
||||
const url = parseURL(value);
|
||||
if (url == null || originOf(value) !== YOUTUBE_EMBED_FRAME_ORIGIN) {
|
||||
return false;
|
||||
}
|
||||
return url.pathname.startsWith(YOUTUBE_EMBED_FRAME_PATH_PREFIX);
|
||||
}
|
||||
|
||||
function originOf(value: string): string | null {
|
||||
const url = parseURL(value);
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
return `${url.protocol}//${url.host}`;
|
||||
}
|
||||
|
||||
function parseURL(value: string): URL | null {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
return url.host.length === 0 ? null : url;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface WebRequestDetails {
|
||||
readonly resourceType: string;
|
||||
readonly url: string;
|
||||
readonly requestHeadersValue: unknown;
|
||||
}
|
||||
|
||||
function readWebRequestDetails(value: unknown): WebRequestDetails {
|
||||
if (!isPlainRecord(value)) {
|
||||
throw new InvalidWebRequestDetailsError();
|
||||
}
|
||||
const resourceType = value.resourceType;
|
||||
const url = value.url;
|
||||
if (typeof resourceType !== 'string' || typeof url !== 'string') {
|
||||
throw new InvalidWebRequestDetailsError();
|
||||
}
|
||||
return {
|
||||
resourceType,
|
||||
url,
|
||||
requestHeadersValue: value.requestHeaders ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function isHeaderName(name: unknown): name is string {
|
||||
return typeof name === 'string' && name.length > 0 && /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name);
|
||||
}
|
||||
|
||||
function readRequestHeaderRecord(value: unknown): Record<string, string> | null {
|
||||
if (!isPlainRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const headers: Record<string, string> = Object.create(null) as Record<string, string>;
|
||||
for (const [name, headerValue] of Object.entries(value)) {
|
||||
if (!isHeaderName(name) || typeof headerValue !== 'string') {
|
||||
return null;
|
||||
}
|
||||
headers[name] = headerValue;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function haveEqualHeaderRecords(left: Record<string, string>, right: Record<string, string>): boolean {
|
||||
const leftNames = Object.keys(left);
|
||||
if (leftNames.length !== Object.keys(right).length) {
|
||||
return false;
|
||||
}
|
||||
for (const name of leftNames) {
|
||||
if (!Object.hasOwn(right, name) || right[name] !== left[name]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function invokeCallback(callbackValue: unknown, settlement: Record<string, unknown>): void {
|
||||
if (typeof callbackValue !== 'function') {
|
||||
throw new InvalidWebRequestCallbackError();
|
||||
}
|
||||
Reflect.apply(callbackValue, null, [settlement]);
|
||||
}
|
||||
Reference in new issue
Block a user