Email doesn't work with script-src CSP

I’m currently trying to secure my new vaultwarden install by implementing a content security policy ( I have a reverse proxy nginx setup ).

However, every time I add script-src ‘self’ to the policy, this causes the emails not to work ( when I go to admin page and test SMTP by pressing the 'Send test Email" button ). Removing the script-src part of the policy restores functionality.

Is there something strange happening with the email scripts of vaultwarden? Are they stored somewhere else which would cause script-src ‘self’ not to work?

If anyone has any suggestions that would be great!

Thanks.

Your content policy blocks inline scripts. One way would be to allow the hash of the inline script that is used to send the smtp test mail.

HI @stefan0xC ,

Thanks for your comment. I’ve tried to do this but it doesn’t seem to be working for me (I’m probably doing something wrong lol ). My browser is showing me 4 script blocks of “affected resources” because of the policy. Here is one of the blocks it is showing me which includes the smtpTest() function:

<script>
    'use strict';

    function smtpTest() {
        if (formHasChanges(config_form)) {
            event.preventDefault();
            event.stopPropagation();
            alert("Config has been changed but not yet saved.\nPlease save the changes first before sending a test email.");
            return false;
        }

        let test_email = document.getElementById("smtp-test-email");

        // Do a very very basic email address check.
        if (test_email.value.match(/\S+@\S+/i) === null) {
            test_email.parentElement.classList.add('was-validated');
            event.preventDefault();
            event.stopPropagation();
            return false;
        }

        const data = JSON.stringify({ "email": test_email.value });
        _post("/admin/test/smtp/",
            "SMTP Test email sent correctly",
            "Error sending SMTP test email", data, false);
        return false;
    }
    function getFormData() {
        let data = {};

        document.querySelectorAll(".conf-checkbox").forEach(function (e) {
            data[e.name] = e.checked;
        });

        document.querySelectorAll(".conf-number").forEach(function (e) {
            data[e.name] = e.value ? +e.value : null;
        });

        document.querySelectorAll(".conf-text, .conf-password").forEach(function (e) {
            data[e.name] = e.value || null;
        });
        return data;
    }
    function saveConfig() {
        const data = JSON.stringify(getFormData());
        _post("/admin/config/", "Config saved correctly",
            "Error saving config", data);
        return false;
    }
    function deleteConf() {
        var input = prompt("This will remove all user configurations, and restore the defaults and the " +
            "values set by the environment. This operation could be dangerous. Type 'DELETE' to proceed:");
        if (input === "DELETE") {
            _post("/admin/config/delete",
                "Config deleted correctly",
                "Error deleting config");
        } else {
            alert("Wrong input, please try again")
        }

        return false;
    }
    function backupDatabase() {
        _post("/admin/config/backup_db",
            "Backup created successfully",
            "Error creating backup", null, false);
        return false;
    }
    function masterCheck(check_id, inputs_query) {
        function onChanged(checkbox, inputs_query) {
            return function _fn() {
                document.querySelectorAll(inputs_query).forEach(function (e) { e.disabled = !checkbox.checked; });
                checkbox.disabled = false;
            };
        }

        const checkbox = document.getElementById(check_id);
        const onChange = onChanged(checkbox, inputs_query);
        onChange(); // Trigger the event initially
        checkbox.addEventListener("change", onChange);
    }
    // These are formatted because otherwise the
    // VSCode formatter breaks But they still work
    //            
    masterCheck("input__enable_yubico", "#g_yubico input");
    //   
    masterCheck("input__enable_duo", "#g_duo input");
    //   
    masterCheck("input__enable_smtp", "#g_smtp input");
    //   
    masterCheck("input__enable_email_2fa", "#g_email_2fa input");
    //  

    // Two functions to help check if there were changes to the form fields
    // Useful for example during the smtp test to prevent people from clicking save before testing there new settings
    function initChangeDetection(form) {
        const ignore_fields = ["smtp-test-email"];
        Array.from(form).forEach((el) => {
            if (! ignore_fields.includes(el.id)) {
                el.dataset.origValue = el.value
            }
        });
    }
    function formHasChanges(form) {
        return Array.from(form).some(el => 'origValue' in el.dataset && ( el.dataset.origValue !== el.value));
    }

    // Trigger Form Change Detection
    const config_form = document.getElementById('config-form');
    initChangeDetection(config_form);

    // Colorize some settings which are high risk
    const risk_items = document.getElementsByClassName('col-form-label');
    function colorRiskSettings(risk_el) {
        Array.from(risk_el).forEach((el) => {
            if (el.innerText.toLowerCase().includes('risks') ) {
                el.parentElement.className += ' alert-danger'
            }
        });
    }
    colorRiskSettings(risk_items);

</script>

It is my understanding that in order to create the hash, you need to put everything between the tags ( including spaces and line breaks ). In this case, this includes many functions, etc. However, when I create the hash and add it to the policy, the browser still doesn’t identify it as valid.

Any idea of what I’m doing wrong here?

Quick update. I figured out that the browser’s debug tools actually tells me which hashes to use, so I’ve used those and made some progress. However, I’m now stuck on the actual button:

<button type="button" class="btn btn-outline-primary input-group-text" onclick="smtpTest(); return false;" data-orig-value="">Send test email</button>

How do I create a hash for this? The browser isn’t telling me what to use this time. It just says “Refused to execute inline event handler because it violates the following csp …”

Any suggestions would be appreciated. Thanks!

You can get the hash for the inline script like this

echo -n "smtpTest(); return false;" | openssl sha256 -binary | openssl base64
xW8Dfx9yzpZcw2BYLjCZgGMmt2nBQSJps1mtSo9tW+o=

Then you could add 'unsafe-hashes' 'sha256-xW8Dfx9yzpZcw2BYLjCZgGMmt2nBQSJps1mtSo9tW+o= to your script-src. Adding unsafe-hashes is necessary in this case because it’s an event handler.

You might want to raise an issue so that the inline JavaScript is moved to a file for an easier content security policy.

HI @stefan0xC ,

Ah, it was the ‘unsafe-hashes’ I was missing. Thanks a lot for your help!

Then all the user and org actions will also not work i guess.
So enabling and disabling a user or remove mfa etc… will probably also not work then.

I just did a quick test, if you add script-src ‘self’ the whole web-vault also breaks.
The self-hosted CSP Bitwarden uses has the following items:

default-src 'self'
style-src 'self' 'unsafe-inline'
img-src 'self' data: https://haveibeenpwned.com https://www.gravatar.com
child-src 'self' https://*.duosecurity.com https://*.duofederal.com
frame-src 'self' https://*.duosecurity.com https://*.duofederal.com
connect-src 'self' wss://your.domain.tld https://api.pwnedpasswords.com https://2fa.directory
object-src 'self' blob:;

Ill see if i can make some changes so that it can be more strict.

1 Like