Francisco IA Lover
Francisco IA Lover

Reputation: 75

Usage of CSP /nonce on Electron app with Node.js

I'm making a desktop application with Electron and Node.js, in which I'm trying to implement CSP /nonce.

So far I have developed a script that should generate the content security policy:

const crypto = require('crypto');

class SecurityPolicies {
    static NONCE_LENGTH = 32;

    static CSP_DIRECTIVES = {
        'default-src': ["'self'"],
        'script-src': ["'self'", "'unsafe-inline'"],
        'style-src': ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
        'img-src': ["'self'", "data:", "https:", "blob:"],
        'font-src': ["'self'", "data:","https://fonts.gstatic.com"],
        'object-src': ["'none'"],
        'base-uri': ["'self'"],
        'form-action': ["'self'"],
        'frame-ancestors': ["'none'"],
        'connect-src': ["'self'","ws:", "wss:"],
        'media-src': ["'self'", "blob:"],
        'worker-src': ["'self'", "blob:"],
        'manifest-src': ["'self'"]
    };

    constructor() {
        this.nonce = this.generateNonce();
        this.contentSecurityPolicy = this.buildCSP();
    }

    generateNonce() {
        return crypto.randomBytes(SecurityPolicies.NONCE_LENGTH).toString('base64');
    }

    buildCSP() {
        const directives = {...SecurityPolicies.CSP_DIRECTIVES};

        directives['script-src'] = [
            ...directives['script-src'],
            `'unsafe-inline'`,
            `'nonce-${this.nonce}'`
        ];

        directives['style-src'] = [
            ...directives['style-src'],
            `'unsafe-inline'`,
            `'nonce-${this.nonce}'`
        ];

        return Object.entries(directives)
            .map(([key, values]) => `${key} ${values.join(' ')}`)
            .join('; ');
    }

    applySecurityPolicy(window) {

        window.webContents.session.webRequest.onHeadersReceived((details, callback) => {
            callback({
                responseHeaders: {
                    ...details.responseHeaders,
                    'Content-Security-Policy': [this.contentSecurityPolicy],
                    'X-Content-Type-Options': ['nosniff'],
                    'X-Frame-Options': ['DENY'],
                    'X-XSS-Protection': ['1; mode=block'],
                    'Referrer-Policy': ['strict-origin-when-cross-origin'],
                    'Permissions-Policy': ['camera=(), microphone=(), geolocation=()']
                }
            });
        });

        // Disable navigation
        window.webContents.on('will-navigate', (event, url) => {
            const parsedUrl = new URL(url);
            if (parsedUrl.origin !== 'file://') {
                event.preventDefault();
            }
        });

        // Block new window creation
        window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));

        // Disable all permission requests
        window.webContents.session.setPermissionRequestHandler((_, __, callback) => {
            callback(false);
        });

        // Disable remote content
        window.webContents.session.setPreloads([]);
    }

    static initSecurity(window) {
        const securityPolicies = new SecurityPolicies();
        securityPolicies.applySecurityPolicy(window);
        return securityPolicies;
    }

    getNonce() {
        return this.nonce;
    }

    getCSP() {
        return this.contentSecurityPolicy;
    }
}

module.exports = SecurityPolicies;

The main idea is to apply nonce to the elements which are then incorporated my index.html looks like this:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'nonce-<%nonce%>'; style-src 'self' 'nonce-<%nonce%>';">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebStack Deployer for Docker</title>
    <link rel="stylesheet" href="assets/css/bootstrap.css" nonce="<%nonce%>">
    <link rel="stylesheet" href="assets/css/styles.css" nonce="<%nonce%>">
</head>
<body class="d-flex vh-100 vw-100">
<div class="title-bar top">
    <div class="dropdown">
        <button class="btn btn-dark dropbtn" id="maintenanceBtn" data-bs-toggle="dropdown" aria-expanded="false">
            Maintenance
        </button>
        <div class="dropdown-content dropdown-menu" aria-labelledby="maintenanceBtn">
            <a class="dropdown-item" href="#" id="option1">Check for Updates</a>
            <a class="dropdown-item" href="#" id="option2">Backup Settings</a>
            <a class="dropdown-item" href="#" id="option3">Clear Cache</a>
            <a class="dropdown-item" href="#" id="option4">Reset Preferences</a>
            <div class="dropdown-divider"></div>
            <a class="dropdown-item" href="#" id="option5">Exit</a>
        </div>
    </div>
    <div class="top-right-controls">
        <button class="btn btn-dark control-button" id="settingsBtn">Settings</button>
        <button class="btn btn-dark control-button" id="helpBtn">Help</button>
        <button class="btn btn-dark control-button" id="aboutBtn">About</button>
        <button class="btn window-control minimize" id="minimizeBtn">─</button>
        <button class="btn window-control maximize" id="maximizeBtn">□</button>
        <button class="btn window-control close" id="closeBtn">×</button>
    </div>
</div>

<div class="content container-fluid">
    <h1 class="display-4">WebStack Deployer for Docker</h1>
</div>
<script src="assets/js/bootstrap.bundle.min.js" nonce="<%nonce%>"></script>
<script src="assets/js/renderer.js" nonce="<%nonce%>"></script>
</body>
</html>

As you can see, I have implemented this token <%nonce%> which should be replaced by the generated nonce string nonce.

I assumed I could use a content loader:

async loadContent() {
        const isDev = process.env.NODE_ENV === 'development';
        const nonce = this.securityPolicies.getNonce();

        if (isDev) {
            await this.mainWindow.loadURL('http://localhost:3000');
        } else {
            const htmlPath = path.join(__dirname, '../index.html');
            let htmlContent = readFileSync(htmlPath, 'utf8');
            htmlContent = htmlContent.replace(/<%nonce%>/g, nonce);

            await this.mainWindow.loadFile(htmlPath, {
                query: {nonce: nonce}
            });
        }
    }

Here is where the problem is, if I already saw that I generate htmlContent and I don't use it... this is because in order to use it I must generate a uri but I always get an error about the malformed uri, what other way can I implement it?


windows manager class:

const { BrowserWindow } = require('electron');
const path = require('node:path');
const SecurityPolicies = require('../security/SecurityPolicies');
const {readFileSync} = require("node:fs");


class WindowManager {
    constructor() {
        this.mainWindow = null;
        this.securityPolicies = null;
    }

    async createMainWindow() {
        this.mainWindow = new BrowserWindow({
                width: 1200,
                height: 800,
                frame: false,
                webPreferences: {
                    nodeIntegration: false,
                    contextIsolation: true,
                    webSecurity: true,
                    preload: path.join(__dirname, '../preload.js'),
                    sandbox: true
                }
            });

        this.securityPolicies = SecurityPolicies.initSecurity(this.mainWindow);
        this.mainWindow.maximize();
        await this.loadContent();

        return new Promise((resolve) => {
            this.mainWindow.once('ready-to-show', () => {
                resolve(this.mainWindow);
            });

            this.mainWindow.on('closed', () => {
                this.mainWindow = null;
                this.securityPolicies = null;
            });
        });
    }

    async loadContent() {
        const isDev = process.env.NODE_ENV === 'development';
        const nonce = this.securityPolicies.getNonce();

        if (isDev) {
            await this.mainWindow.loadURL('http://localhost:3000');
        } else {
            const htmlPath = path.join(__dirname, '../index.html');
            let htmlContent = readFileSync(htmlPath, 'utf8');
            htmlContent = htmlContent.replace(/<%nonce%>/g, nonce);

            await this.mainWindow.loadFile(htmlPath, {
                query: {nonce: nonce}
            });
        }
    }

    getMainWindow() {
        return this.mainWindow;
    }

    getSecurityPolicies() {
        return this.securityPolicies;
    }
}

module.exports = WindowManager;

main.js class:

const {app} = require('electron');
const AppConfig = require('./models/AppConfig');
const BackendController = require('./controllers/BackendController');
const WindowManager = require('./views/WindowManager');
const IpcController = require('./controllers/IpcController');
const ApplicationSetup = require('./core/ApplicationSetup');
const DevToolsManager = require('./core/DevToolsManager');
const AppLifecycle = require('./core/AppLifecycle');

class Application {
    constructor() {
        this.appConfig = new AppConfig();
        this.backendController = new BackendController();
        this.windowManager = null;
        this.mainWindow = null;
        this.ipcController = null;
    }

    async initialize() {
        ApplicationSetup.configureCommandLineFlags();
        app.setPath('userData', this.appConfig.getUserDataPath());

        // Enable sandbox for all renderers
        app.enableSandbox();

        // Disable navigation outside the app
        app.on('web-contents-created', (_, contents) => {
            contents.on('will-navigate', (event) => {
                event.preventDefault();
            });
            contents.setWindowOpenHandler(() => ({ action: 'deny' }));
        });

        try {
            await this.backendController.initializeFetch();
            await this.setupAppEvents();
            return this.ipcController;
        } catch (error) {
            console.error('Initialization error:', error);
            throw error;
        }
    }

    async setupAppEvents() {
        await app.whenReady();

        try {
            await this.backendController.startGoBackend();

            this.windowManager = new WindowManager();
            this.mainWindow = await this.windowManager.createMainWindow();

            if (this.mainWindow) {
                this.ipcController = new IpcController(
                    this.windowManager,
                    this.backendController,
                    this.appConfig
                );

                // Only enable DevTools in development
                //if (process.env.NODE_ENV === 'development') {
                    DevToolsManager.setupDevToolsHandlers(this.mainWindow);
                //}
            }

            AppLifecycle.setupAppEventHandlers(this.windowManager, this.backendController);
            ApplicationSetup.setupProcessEventHandlers(this.backendController);

        } catch (error) {
            console.error('Setup error:', error);
            throw error;
        }
    }
}

const application = new Application();
application.initialize().catch(err => {
    console.error('Application initialization failed:', err);
    app.quit();
});

module.exports = application;

Update

I got it working but I'm not sure I'm doing it correctly; I'm just removing the class SecurityPolicies and can be used directly in this way:

this.mainWindow = new BrowserWindow({
                    width: 1200,
                    height: 800,
                    frame: false,
                    icon: path.join(__dirname, '../assets/icons', process.platform === 'win32' ? 'icon.ico' : 'icon.icns'),
                    webPreferences: {
                        nodeIntegration: false,
                        contextIsolation: true,
                        webSecurity: true,
                        preload: path.join(__dirname, '../preload.js'),
                        //sandbox: true,
                        experimentalFeatures: false
                    }
                });
            const cspDirectives = [
                "default-src 'self'",
                "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
                "style-src 'self' 'unsafe-inline' 'unsafe-eval' https: data:",
                "img-src 'self' data: https:",
                "font-src 'self' data: https:",
                "connect-src 'self' http://localhost:8080 ws://localhost:3000",
                "base-uri 'self'",
                "form-action 'self'",
                "worker-src 'self' blob:",
                "media-src 'self' blob: data:",
                "object-src 'none'"
            ].join('; ');
            this.mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => {
                callback({
                    responseHeaders: {
                        ...details.responseHeaders,
                        'Content-Security-Policy': [cspDirectives]
                    }
                });
            });

And on index.html i add this to the head:

<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:;">

This without implementing /nonce because of problem in deploy contents

Upvotes: 3

Views: 101

Answers (0)

Related Questions