fix: fuck cloudflare

This commit is contained in:
Xymorot 2021-01-17 19:40:24 +01:00
parent 523bc7e75e
commit 31945cac08
17 changed files with 304 additions and 162 deletions

View File

@ -8,7 +8,6 @@ import { NhentaiApi } from '../modules/nhentai/nhentai-api';
import '../modules/nhentai/nhentai-ipc-controller';
import { NhentaiAppWindow } from '../modules/nhentai/nhentai-app-window';
import { NhentaiSourceGetter } from '../modules/nhentai/nhentai-source-getter';
import { SessionHelper } from '../modules/session/session-helper';
import { Store } from '../modules/store/store';
import BindingToSyntax = interfaces.BindingToSyntax;
@ -33,8 +32,6 @@ container.bind('dialog').to(Dialog);
container.bind('store').to(Store);
container.bind('session-helper').to(SessionHelper);
container.bind('nhentai-app-window').to(NhentaiAppWindow);
container.bind('nhentai-api').to(NhentaiApi);
container.bind('nhentai-source-getter').to(NhentaiSourceGetter);

View File

@ -2,7 +2,17 @@ import { BrowserWindow } from 'electron';
interface AppWindowInterface {
window: BrowserWindow | null;
/**
* throws an Error when the window is null
*/
getWindow(): BrowserWindow;
open(): Promise<void>;
close(force?: boolean): void;
isClosed(): boolean;
askForUserInteraction(): void;
}

View File

@ -2,7 +2,7 @@ import { app, BrowserWindow, Event, LoadFileOptions, LoadURLOptions, NewWindowWe
import os from 'os';
import path from 'path';
import { isDev } from '../../core/env';
import type { SessionHelperInterface } from '../session/session-helper-interface';
import { completeContentSecurityPolicy, setWindowCsp } from '../session/session-util';
import type { AppWindowInterface } from './app-window-interface';
import { WindowClosedError } from './window-closed-error';
import BrowserWindowConstructorOptions = Electron.BrowserWindowConstructorOptions;
@ -38,22 +38,14 @@ export abstract class AppWindow implements AppWindowInterface {
protected readonly logger: LoggerInterface;
protected readonly sessionHelper: SessionHelperInterface;
protected options: BrowserWindowConstructorOptions;
protected uri: string;
protected abstract loadOptions: LoadFileOptions | LoadURLOptions;
protected constructor(
logger: LoggerInterface,
sessionHelper: SessionHelperInterface,
uri: string,
options: BrowserWindowConstructorOptions = {}
) {
protected constructor(logger: LoggerInterface, uri: string, options: BrowserWindowConstructorOptions = {}) {
this.logger = logger;
this.sessionHelper = sessionHelper;
this.options = { ...defaultOptions, ...options };
this.uri = uri;
}
@ -62,10 +54,17 @@ export abstract class AppWindow implements AppWindowInterface {
return this._window;
}
public getWindow(): BrowserWindow {
if (!this._window) {
throw new WindowClosedError();
}
return this._window;
}
public open(): Promise<void> {
this._window = new BrowserWindow(this.options);
this.sessionHelper.setCsp(this._window, this.getCsp());
setWindowCsp(this._window, completeContentSecurityPolicy(this.getCsp()));
this._window.on('closed', () => {
this.onClosed();
@ -94,6 +93,15 @@ export abstract class AppWindow implements AppWindowInterface {
return !this._window;
}
public askForUserInteraction(): void {
if (!this.getWindow().isFocused()) {
this.getWindow().on('focus', () => {
this.getWindow().flashFrame(false);
});
this.getWindow().flashFrame(true);
}
}
protected getCsp(): Session.ContentSecurityPolicy {
return {};
}
@ -111,11 +119,8 @@ export abstract class AppWindow implements AppWindowInterface {
protected getInnerHtml(selector: string): Promise<string> {
return new Promise<string>((resolve) => {
if (!this._window) {
throw new WindowClosedError();
}
this._window.webContents
.executeJavaScript(`document.querySelector('${selector}').innerHTML`)
this.getWindow()
.webContents.executeJavaScript(`document.querySelector('${selector}').innerHTML`)
.then((innerHtml) => {
resolve(innerHtml);
})

View File

@ -1,5 +1,4 @@
import type { BrowserWindow, BrowserWindowConstructorOptions, LoadFileOptions } from 'electron';
import type { SessionHelperInterface } from '../session/session-helper-interface';
import { AppWindow } from './app-window';
export abstract class FileAppWindow extends AppWindow {
@ -7,12 +6,11 @@ export abstract class FileAppWindow extends AppWindow {
protected constructor(
logger: LoggerInterface,
sessionHelper: SessionHelperInterface,
uri: string,
options: BrowserWindowConstructorOptions = {},
loadOptions: LoadFileOptions = {}
) {
super(logger, sessionHelper, uri, options);
super(logger, uri, options);
this.loadOptions = loadOptions;
}

View File

@ -1,15 +1,11 @@
import { injectable } from 'inversify';
import { inject } from '../../core/inject';
import type { SessionHelperInterface } from '../session/session-helper-interface';
import { FileAppWindow } from './file-app-window';
@injectable()
export class MainAppWindow extends FileAppWindow {
public constructor(
@inject('logger') logger: LoggerInterface,
@inject('session-helper') sessionHelper: SessionHelperInterface
) {
super(logger, sessionHelper, 'frontend/index.html', {
public constructor(@inject('logger') logger: LoggerInterface) {
super(logger, 'frontend/index.html', {
webPreferences: {
nodeIntegration: true,
},

View File

@ -1,6 +1,5 @@
import type { BrowserWindowConstructorOptions, LoadURLOptions } from 'electron';
import { SimpleMutex } from '../mutex/simple-mutex';
import type { SessionHelperInterface } from '../session/session-helper-interface';
import type { SiteAppWindowInterface } from './site-app-window-interface';
import { UrlAppWindow } from './url-app-window';
@ -13,12 +12,11 @@ export abstract class SiteAppWindow extends UrlAppWindow implements SiteAppWindo
protected constructor(
logger: LoggerInterface,
sessionHelper: SessionHelperInterface,
uri: string,
options: BrowserWindowConstructorOptions = {},
loadOptions: LoadURLOptions = {}
) {
super(logger, sessionHelper, uri, options, loadOptions);
super(logger, uri, options, loadOptions);
this.windowLock = new SimpleMutex();
}

View File

@ -1,8 +1,21 @@
import type { LoadURLOptions } from 'electron';
import type { LoadURLOptions, WebContents } from 'electron';
import type { AppWindowInterface } from './app-window-interface';
interface UrlAppWindowInterface extends AppWindowInterface {
downloadUrlSafe(url: string, savePath: string, options?: LoadURLOptions): Promise<void>;
loadUrlSafe(url: string, options?: LoadURLOptions): Promise<void>;
/**
* safely loads a url, reloading on fail
*
* this functions also waits if the server returns 429
*
* @param url - the url to load
* @param readyCheck - a function to check if the site is ready to be consumed
* @param options - load url options to forward to electron
*/
loadUrlSafe(
url: string,
readyCheck?: (webContents: WebContents) => Promise<boolean>,
options?: LoadURLOptions
): Promise<void>;
}

View File

@ -1,14 +1,15 @@
import type { BrowserWindow, BrowserWindowConstructorOptions, LoadURLOptions } from 'electron';
import type { WebContents } from 'electron';
import type { BrowserWindowConstructorOptions, LoadURLOptions } from 'electron';
import { promisify } from 'util';
import type { SessionHelperInterface } from '../session/session-helper-interface';
import { AppWindow } from './app-window';
import type { UrlAppWindowInterface } from './url-app-window-interface';
import { WindowClosedError } from './window-closed-error';
import Timeout = NodeJS.Timeout;
export abstract class UrlAppWindow extends AppWindow implements UrlAppWindowInterface {
protected loadOptions: LoadURLOptions;
protected readyCheck?: (webContents: WebContents) => Promise<boolean>;
/**
* the wait interval after a failed load to try again
*/
@ -33,12 +34,11 @@ export abstract class UrlAppWindow extends AppWindow implements UrlAppWindowInte
protected constructor(
logger: LoggerInterface,
sessionHelper: SessionHelperInterface,
uri: string,
options: BrowserWindowConstructorOptions = {},
loadOptions: LoadURLOptions = {}
) {
super(logger, sessionHelper, uri, {
super(logger, uri, {
...options,
...{
webPreferences: {
@ -54,10 +54,7 @@ export abstract class UrlAppWindow extends AppWindow implements UrlAppWindowInte
public downloadUrlSafe(url: string, savePath: string, options?: LoadURLOptions): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!this._window) {
throw new WindowClosedError();
}
this._window.webContents.session.once('will-download', (event, item) => {
this.getWindow().webContents.session.once('will-download', (event, item) => {
item.setSavePath(savePath);
item.once('done', (doneEvent, state) => {
switch (state) {
@ -75,42 +72,63 @@ export abstract class UrlAppWindow extends AppWindow implements UrlAppWindowInte
item.resume();
});
});
void this.loadUrlSafe(url, options);
void this.loadUrlSafe(url, undefined, options);
});
}
public async loadUrlSafe(url: string, options?: LoadURLOptions): Promise<void> {
public async loadUrlSafe(
url: string,
readyCheck?: (webContents: WebContents) => Promise<boolean>,
options?: LoadURLOptions
): Promise<void> {
return this.loadWait.then(async () => {
let failedLoad = true;
while (failedLoad) {
await new Promise<void>((resolve) => {
if (!this._window) {
throw new WindowClosedError();
await this.loadUrl(url, options).then((httpResponseCode) => {
failedLoad = HttpCode.BAD_REQUEST <= httpResponseCode;
if (HttpCode.TOO_MANY_REQUESTS === httpResponseCode) {
// go slower
this.loadWaitTime += this.loadWaitTimeStep;
// but go faster again after a time
clearTimeout(this.loadWaitTimeStepResetTimeout);
this.loadWaitTimeStepResetTimeout = setTimeout(() => {
this.loadWaitTime = 0;
}, this.loadWaitTimeResetTimeoutTime);
}
this._window.webContents.once('did-navigate', (event, navigationUrl, httpResponseCode) => {
failedLoad = HttpCode.BAD_REQUEST <= httpResponseCode;
if (HttpCode.TOO_MANY_REQUESTS === httpResponseCode) {
// go slower
this.loadWaitTime += this.loadWaitTimeStep;
// but go faster again after a time
clearTimeout(this.loadWaitTimeStepResetTimeout);
this.loadWaitTimeStepResetTimeout = setTimeout(() => {
this.loadWaitTime = 0;
}, this.loadWaitTimeResetTimeoutTime);
}
resolve();
});
void this._window.loadURL(url, options);
});
if (failedLoad) {
await promisify(setTimeout)(this.waitInterval);
}
}
this.loadWait = promisify(setTimeout)(this.loadWaitTime);
if (readyCheck) {
let isReady = await readyCheck(this.getWindow().webContents);
do {
await promisify(setTimeout)(Milliseconds.TEN);
isReady = await readyCheck(this.getWindow().webContents);
} while (!isReady);
}
});
}
protected load(window: BrowserWindow): Promise<void> {
return window.loadURL(this.uri, this.loadOptions);
/**
* This is the method used for loading specific URLs.
* It resolves when the url is loaded, successfully or not-
*
* It is meant to be overridden for site specific logic, e.g. C l o u d f l a r e
*
* @return a Promise of the http status code the url loaded with
*/
protected loadUrl(url: string, options?: LoadURLOptions): Promise<number> {
return new Promise((resolve) => {
this.getWindow().webContents.once('did-navigate', (event, navigationUrl, httpResponseCode) => {
resolve(httpResponseCode);
});
void this.getWindow().loadURL(url, options);
});
}
protected load(): Promise<void> {
return this.loadUrlSafe(this.uri, this.readyCheck, this.loadOptions).then();
}
}

View File

@ -0,0 +1,30 @@
import type { LoadURLOptions } from 'electron';
import { SiteAppWindow } from '../app-window/site-app-window';
import { mergeContentSecurityPolicy } from '../session/session-util';
import { cloudflareSiteCsp, humanInteractionRequired, isCloudFlareSite } from './cloudflare-util';
export abstract class CloudflareSiteAppWindow extends SiteAppWindow {
protected loadUrl(url: string, options?: LoadURLOptions): Promise<number> {
return new Promise((resolve) => {
const onDidNavigate: (event: Event, url: string, httpResponseCode: number) => void = async (
event,
navigationUrl,
httpResponseCode
) => {
if (!(await isCloudFlareSite(this.getWindow().webContents))) {
this.getWindow().webContents.removeListener('did-navigate', onDidNavigate);
resolve(httpResponseCode);
} else if (await humanInteractionRequired(this.getWindow().webContents)) {
this.askForUserInteraction();
}
};
this.getWindow().webContents.on('did-navigate', onDidNavigate);
void this.getWindow().loadURL(url, options);
});
}
protected getCsp(): Session.ContentSecurityPolicy {
return mergeContentSecurityPolicy(super.getCsp(), cloudflareSiteCsp);
}
}

View File

@ -0,0 +1,19 @@
import type { WebContents } from 'electron';
import ContentSecurityPolicy = Session.ContentSecurityPolicy;
export const cloudflareSiteCsp: ContentSecurityPolicy = {
'style-src': ['cdnjs.cloudflare.com'],
'script-src': ['hcaptcha.com'],
};
export function humanInteractionRequired(webContents: WebContents): Promise<boolean> {
return webContents.executeJavaScript(
"[...document.querySelectorAll('iframe')].map(iframe => (new URL(iframe.src)).hostname.match(/hcaptcha/)).some(e => e)"
) as Promise<boolean>;
}
export function isCloudFlareSite(webContents: WebContents): Promise<boolean> {
return webContents.executeJavaScript(
"!!document.querySelector('.cf-browser-verification, #cf-content')"
) as Promise<boolean>;
}

View File

@ -6,9 +6,8 @@ import { URL } from 'url';
import { createReadStream, remove } from 'fs-extra';
import { injectable } from 'inversify';
import { inject } from '../../core/inject';
import { SiteAppWindow } from '../app-window/site-app-window';
import { WindowClosedError } from '../app-window/window-closed-error';
import type { SessionHelperInterface } from '../session/session-helper-interface';
import { CloudflareSiteAppWindow } from '../cloudflare/cloudflare-site-app-window';
import { mergeContentSecurityPolicy } from '../session/session-util';
import type { NhentaiAppWindowInterface } from './nhentai-app-window-interface';
import {
url as nhentaiUrl,
@ -31,17 +30,20 @@ import {
tagLabelTags,
mainTitleSelector,
postTitleSelector,
galleryPageIsReady,
loginPageIsReady,
favoritePageIsReady,
pageIsReady,
} from './nhentai-util';
const waitInterval = 2000;
@injectable()
export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowInterface {
public constructor(
@inject('logger') logger: LoggerInterface,
@inject('session-helper') sessionHelper: SessionHelperInterface
) {
super(logger, sessionHelper, nhentaiUrl);
export class NhentaiAppWindow extends CloudflareSiteAppWindow implements NhentaiAppWindowInterface {
protected readyCheck = pageIsReady;
public constructor(@inject('logger') logger: LoggerInterface) {
super(logger, nhentaiUrl);
}
public async getFavorites(): Promise<NodeJS.ReadableStream> {
@ -95,9 +97,6 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
if (this.isClosed()) {
await this.open();
}
if (!this._window) {
throw new WindowClosedError();
}
const gallery: Nhentai.Gallery = {
title: {
@ -116,7 +115,7 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
const bookUrl = getBookUrl(identifier);
try {
await this.loadUrlSafe(bookUrl);
await this.loadGalleryPageSafe(bookUrl);
await Promise.all([
this.getInnerHtml(preTitleSelector).then((preTitle) => {
gallery.title.pre = preTitle.trim();
@ -155,24 +154,24 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
}
protected getCsp(): Session.ContentSecurityPolicy {
return {
return mergeContentSecurityPolicy(super.getCsp(), {
'default-src': ['nhentai.net'],
'script-src': ['nhentai.net', "'unsafe-eval'"],
'script-src-elem': ['*.nhentai.net', "'unsafe-inline'", '*.google.com', '*.gstatic.com'],
'script-src-elem': ['*.nhentai.net', 'nhentai.net', "'unsafe-inline'", '*.google.com', '*.gstatic.com'],
'style-src': [
'*.nhentai.net',
'nhentai.net',
"'unsafe-inline'",
'fonts.googleapis.com',
'cdnjs.cloudflare.com',
'maxcdn.bootstrapcdn.com',
'*.gstatic.com',
],
'img-src': ['*.nhentai.net', 'data:', '*.gstatic.com', '*.google.com'],
'img-src': ['*.nhentai.net', 'nhentai.net', 'data:', '*.gstatic.com', '*.google.com'],
'font-src': ['fonts.gstatic.com', 'cdnjs.cloudflare.com', 'maxcdn.bootstrapcdn.com'],
'frame-src': ['*.google.com'],
'connect-src': ['nhentai.net', '*.google.com'],
'worker-src': ['*.google.com'],
};
});
}
protected onWillNavigate(event: Electron.Event, navigationUrl: string): void {
@ -184,11 +183,8 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
}
private async isLoggedIn(): Promise<boolean> {
if (!this._window) {
throw new WindowClosedError();
}
return this._window.webContents
.executeJavaScript(
return this.getWindow()
.webContents.executeJavaScript(
`fetch('${
nhentaiUrl + nhentaiPaths.favorites
}', {credentials: 'include', redirect: 'manual'}).then((res) => res.status)`
@ -196,14 +192,8 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
.then((status: number) => status === HttpCode.OK);
}
/**
* @throws WindowClosedError when this._window is null
*/
private async login(): Promise<void> {
if (!this._window) {
throw new WindowClosedError();
}
await this.loadUrlSafe(nhentaiUrl + nhentaiPaths.login);
await this.loadLoginPageSafe();
return new Promise((resolve, reject) => {
const timeout = setInterval(() => {
@ -220,20 +210,17 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
}
private async *getFavoritePageWebContentsGenerator(): AsyncGenerator<WebContents, undefined> {
if (!this._window) {
throw new WindowClosedError();
}
await this.loadUrlSafe(getFavoritePageUrl());
await this.loadFavoritesPageSafe(getFavoritePageUrl());
while (true) {
yield this._window.webContents;
const hasNextPage = (await this._window.webContents.executeJavaScript(
yield this.getWindow().webContents;
const hasNextPage = (await this.getWindow().webContents.executeJavaScript(
`!!document.querySelector('${nextFavoritePageSelector}')`
)) as boolean;
if (hasNextPage) {
const nextPageHref = (await this._window.webContents.executeJavaScript(
const nextPageHref = (await this.getWindow().webContents.executeJavaScript(
`document.querySelector('${nextFavoritePageSelector}').href`
)) as string;
await this.loadUrlSafe(nextPageHref);
await this.loadFavoritesPageSafe(nextPageHref);
} else {
break;
}
@ -242,14 +229,11 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
}
private async getBookTorrent(bookUrl: string): Promise<Nhentai.Favorite> {
if (!this._window) {
throw new WindowClosedError();
}
const galleryId = getGalleryId(bookUrl);
const fileName = `${galleryId}.torrent`;
const filePath = path.resolve(os.tmpdir(), fileName);
await this.loadUrlSafe(bookUrl);
const downloadLink: string = (await this._window.webContents.executeJavaScript(
await this.loadGalleryPageSafe(bookUrl);
const downloadLink: string = (await this.getWindow().webContents.executeJavaScript(
`document.getElementById('${downloadLinkId}').href`
)) as string;
await this.downloadUrlSafe(downloadLink, filePath);
@ -266,11 +250,20 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
};
}
private loadGalleryPageSafe(url: string): Promise<void> {
return this.loadUrlSafe(url, galleryPageIsReady);
}
private loadLoginPageSafe(): Promise<void> {
return this.loadUrlSafe(nhentaiUrl + nhentaiPaths.login, loginPageIsReady);
}
private loadFavoritesPageSafe(url: string): Promise<void> {
return this.loadUrlSafe(url, favoritePageIsReady);
}
private getTags(tagLabel: string): Promise<string[]> {
if (!this._window) {
throw new WindowClosedError();
}
return this._window.webContents.executeJavaScript(
return this.getWindow().webContents.executeJavaScript(
`Array.from(
document.querySelectorAll('${labeledTagContainerSelector}')
).filter(

View File

@ -1,3 +1,5 @@
import type { WebContents } from 'electron';
export const hostname = 'nhentai.net';
export const url = `https://${hostname}/`;
@ -24,6 +26,22 @@ export const tagLabelTags = 'Tags';
export const tagLabelArtists = 'Artists';
export const tagLabelGroups = 'Groups';
export function pageIsReady(webContents: WebContents): Promise<boolean> {
return webContents.executeJavaScript(`!!document.getElementById('content')`) as Promise<boolean>;
}
export function galleryPageIsReady(webContents: WebContents): Promise<boolean> {
return webContents.executeJavaScript(`!!document.getElementById('${downloadLinkId}')`) as Promise<boolean>;
}
export function favoritePageIsReady(webContents: WebContents): Promise<boolean> {
return webContents.executeJavaScript(`!!document.getElementById('favorites-random-button')`) as Promise<boolean>;
}
export function loginPageIsReady(webContents: WebContents): Promise<boolean> {
return webContents.executeJavaScript(`!!document.getElementById('id_username_or_email')`) as Promise<boolean>;
}
export function getFavoritePageUrl(page?: number): string {
return `${url + paths.favorites}${page ? `?page=${page}` : ''}`;
}

View File

@ -1,5 +0,0 @@
import { BrowserWindow } from 'electron';
interface SessionHelperInterface {
setCsp(window: BrowserWindow, csp: Session.ContentSecurityPolicy): void;
}

View File

@ -1,46 +0,0 @@
import { injectable } from 'inversify';
import { isDev } from '../../core/env';
import type { SessionHelperInterface } from './session-helper-interface';
const defaultCsp: Session.ContentSecurityPolicy = {
'default-src': ["'self'"],
'style-src': ["'unsafe-inline'"],
'object-src': ["'none'"],
};
@injectable()
export class SessionHelper implements SessionHelperInterface {
private static stringifyCspHeader(csp: Session.ContentSecurityPolicy): string {
return Object.entries(csp)
.map(
(directive: [string, Session.CspValue[] | undefined]) =>
`${directive[0]} ${directive[1] ? directive[1]?.join(' ') : ''}`
)
.join('; ');
}
public setCsp(window: Electron.BrowserWindow, csp: Session.ContentSecurityPolicy): void {
const mergedCsp: Session.ContentSecurityPolicy = { ...defaultCsp, ...csp };
if (isDev()) {
mergedCsp['default-src'] = ['devtools:'].concat(mergedCsp['default-src'] ?? []);
mergedCsp['script-src'] = ["'unsafe-eval'"].concat(mergedCsp['script-src'] ?? []);
mergedCsp['script-src-elem'] = ['file:', 'devtools:', "'unsafe-inline'"].concat(
mergedCsp['script-src-elem'] ?? []
);
mergedCsp['style-src'] = ['devtools:', "'unsafe-inline'"].concat(mergedCsp['style-src'] ?? []);
mergedCsp['img-src'] = ['devtools:'].concat(mergedCsp['img-src'] ?? []);
mergedCsp['connect-src'] = ['devtools:', 'data:'].concat(mergedCsp['connect-src'] ?? []);
mergedCsp['worker-src'] = ['devtools:'].concat(mergedCsp['worker-src'] ?? []);
}
window.webContents.session.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': SessionHelper.stringifyCspHeader(mergedCsp),
},
});
});
}
}

View File

@ -0,0 +1,78 @@
import { isDev } from '../../core/env';
import ContentSecurityPolicy = Session.ContentSecurityPolicy;
import CompleteContentSecurityPolicy = Session.CompleteContentSecurityPolicy;
const defaultCsp: Session.ContentSecurityPolicy = {
'default-src': ["'self'"],
'style-src': ["'unsafe-inline'"],
};
function stringifyCspHeader(csp: ContentSecurityPolicy): string {
return Object.entries(csp)
.map(
(directive: [string, Session.CspValue[] | undefined]) =>
`${directive[0]} ${directive[1] ? directive[1]?.join(' ') : ''}`
)
.join('; ');
}
export function mergeContentSecurityPolicy(...contentSecurityPolicies: ContentSecurityPolicy[]): ContentSecurityPolicy {
return contentSecurityPolicies.reduce((mergedCsp, contentSecurityPolicy) => {
Object.entries(contentSecurityPolicy).forEach(([policyName, policy]) => {
const mergedPolicy = mergedCsp[policyName as keyof ContentSecurityPolicy];
if (mergedPolicy && policy) {
mergedPolicy.push(...policy);
} else if (policy) {
mergedCsp[policyName as keyof ContentSecurityPolicy] = policy;
}
});
return mergedCsp;
}, {});
}
export function setWindowCsp(window: Electron.BrowserWindow, csp: CompleteContentSecurityPolicy): void {
const mergedCsp: ContentSecurityPolicy = { ...defaultCsp, ...csp };
if (isDev()) {
mergedCsp['default-src'] = ['devtools:'].concat(mergedCsp['default-src'] ?? []);
mergedCsp['script-src'] = ["'unsafe-eval'"].concat(mergedCsp['script-src'] ?? []);
mergedCsp['script-src-elem'] = ['file:', 'devtools:', "'unsafe-inline'"].concat(mergedCsp['script-src-elem'] ?? []);
mergedCsp['style-src'] = ['devtools:', "'unsafe-inline'"].concat(mergedCsp['style-src'] ?? []);
mergedCsp['img-src'] = ['devtools:'].concat(mergedCsp['img-src'] ?? []);
mergedCsp['connect-src'] = ['devtools:', 'data:'].concat(mergedCsp['connect-src'] ?? []);
mergedCsp['worker-src'] = ['devtools:'].concat(mergedCsp['worker-src'] ?? []);
}
window.webContents.session.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': stringifyCspHeader(mergedCsp),
},
});
});
}
/**
* fills script-src-elem and script-src-attr with script-src when not present,
* fills rest of missing policies with default-src or nothing when it is not present
*/
export function completeContentSecurityPolicy(csp: ContentSecurityPolicy): CompleteContentSecurityPolicy {
const defaultSrc = csp['default-src'] ?? [];
const scriptSrc = csp['script-src'] ?? [];
return {
'child-src': csp['child-src'] ?? defaultSrc,
'connect-src': csp['connect-src'] ?? defaultSrc,
'default-src': csp['default-src'] ?? defaultSrc,
'font-src': csp['font-src'] ?? defaultSrc,
'frame-src': csp['frame-src'] ?? defaultSrc,
'img-src': csp['img-src'] ?? defaultSrc,
'media-src': csp['media-src'] ?? defaultSrc,
'object-src': ["'none'"],
'script-src': csp['script-src'] ?? defaultSrc,
'script-src-elem': csp['script-src-elem'] ?? scriptSrc ?? defaultSrc,
'script-src-attr': csp['script-src-attr'] ?? scriptSrc ?? defaultSrc,
'style-src': csp['style-src'] ?? defaultSrc,
'worker-src': csp['worker-src'] ?? defaultSrc,
};
}

View File

@ -15,11 +15,29 @@ declare namespace Session {
'frame-src'?: CspValue[];
'img-src'?: CspValue[];
'media-src'?: CspValue[];
'object-src'?: ["'none'"];
'script-src'?: CspValue[];
'script-src-elem'?: CspValue[];
'script-src-attr'?: CspValue[];
'style-src'?: CspValue[];
'worker-src'?: CspValue[];
};
/**
* meant for usages where the browser shouldn't unexpectedly fall back to default-src
*/
type CompleteContentSecurityPolicy = {
'child-src': CspValue[];
'connect-src': CspValue[];
'default-src': CspValue[];
'font-src': CspValue[];
'frame-src': CspValue[];
'img-src': CspValue[];
'media-src': CspValue[];
'object-src': ["'none'"];
'script-src': CspValue[];
'script-src-elem': CspValue[];
'script-src-attr': CspValue[];
'style-src': CspValue[];
'worker-src': CspValue[];
};
}

View File

@ -1,4 +1,6 @@
declare const enum Milliseconds {
TEN = 10,
TWO_HUNDRED = 200,
ONE_SECOND = 1000,