Jump to content

Recommended Posts

Posted

Hi @ryan - I mentioned this here: https://github.com/processwire/processwire-issues/issues/2184#issuecomment-3901936805 but I want to make sure it isn't lost. I really think PW needs a $config->cspNonce that gets injected into all core scripts and can be used by all module developers as well.

I am doing this in my config.php

$config->cspNonce = base64_encode(random_bytes(16));

but if it was in /wire/config.php we could all use it - in modules and template files.

Another possibility is maybe have a core method like:

function getNonce(): ?string
	{
		return preg_match('#^Content-Security-Policy(?:-Report-Only)?:.*\s(?:script-src|script-src-elem)\s+(?:[^;]+\s)?\'nonce-([\w+/]+=*)\'#mi', implode("\n", headers_list()), $m)
			? $m[1]
			: null;
	}

that finds an existing nonce set for either script-src or script-src-elem and use the result of that to add to core scripts (src file and inline).

But I don't think PW should set the CSP itself - I think that should be up to us.

Please let me know if you have any thoughts or questions - I've been trying to up my game in the CSP area and this would make life a lot easier because at the moment I can't lock down the PW admin without it so I need separate CSP's for frontend and admin.

  • Like 2
Posted

I solved this once for frontend usage using mod_cspnonce, which created the nonces per request right on Apache. This way I was able to keep using ProCache and not having to manage a CSP list in the header. I guess that for ProcessWire admin purposes, hashes implementation could work better? 

Also there's the caveat of scripts that generate other scripts, like GTM, which need a special implementations:
https://developers.google.com/tag-platform/security/guides/csp

  • Like 1
Posted

Thanks @elabx - that's a cool idea having apache generate the nonce so you can populate the CSP in htaccess.

Can you explain how this works with ProCache though - wouldn't the <script> calls in the cached html files have the version of the nonce from when they were created which would differ from the current one when the page is loaded. I am guessing there is something to this that I am missing - note that I don't really use ProCache.

I have been setting my CSP headers in init.php because I want to change them depending on whether I am in the admin or not. And, yes, GTM is an absolute PITA with all their country TLDs. At the moment I have that part of the policy in report-only mode and I am adding domains as they are reported.

$isAdmin = isset($_SERVER['REQUEST_URI']) && strpos($_SERVER['REQUEST_URI'], $this->wire('config')->urls->admin) === 0;

// add to list as we observe more violations and determine which sources we need to allow
$googleDomains = [
    "https://www.google.ca",
    "https://www.google.co.in",
    "https://www.google.co.nz",
    "https://www.google.co.uk",
    "https://www.google.com",
    "https://www.google.com.au",
    "https://www.google.com.br",
    "https://www.google.com.my",
    "https://www.google.com.sg",
    "https://www.google.de",
    "https://www.google.fr",
    "https://www.google.lu",
    "https://www.google.md",
    "https://www.google.nl",
    "https://www.google.se",
    "https://*.googletagmanager.com",
    "https://*.google-analytics.com",
    "https://*.googleadservices.com",
    "https://*.googlesyndication.com",
    "https://*.doubleclick.net"
];

$imgSources = array_merge(
    [
        "'self'",
        "data:",
        "https://cdn.jsdelivr.net",
        "https://i.vimeocdn.com",
        "https://www.facebook.com",
        "https://connect.facebook.net",
        "https://www.gravatar.com"
    ],
    $googleDomains
);

$connectSources = array_merge(
    [
        "'self'",
        "https://svc.webspellchecker.net",
        "https://www.facebook.com",
        "https://connect.facebook.net",
        "https://*.facebook.com",
        "https://*.facebook.net"
    ],
    $googleDomains
);

$scriptSourcesElem = [
    "'self'",
    "'unsafe-eval'", // Required for Vue 2 template compilation
];

// Admin needs unsafe-inline, frontend uses nonces
if ($isAdmin) {
    $scriptSourcesElem[] = "'unsafe-inline'";
} else {
    $scriptSourcesElem[] = "'nonce-" . $config->cspNonce . "'";
}

$scriptSourcesElem = array_merge($scriptSourcesElem, [
    "https://cdn.jsdelivr.net",
    "https://cdn.paddle.com",
    "https://www.googletagmanager.com",
    "https://www.google-analytics.com",
    "https://googleads.g.doubleclick.net",
    "https://connect.facebook.net",
    "https://svc.webspellchecker.net"
]);

// For inline event handlers (onclick, onload, etc.) - allow them
$scriptSourcesAttr = [
    "'unsafe-inline'" // Allow onclick/onload/etc handlers
];

$styleSources = [
    "'self'",
    "'unsafe-inline'",
    "https://cdn.jsdelivr.net",
    "https://fonts.googleapis.com",
    "https://svc.webspellchecker.net"
];

$fontSources = [
    "'self'",
    "data:",
    "https://fonts.gstatic.com",
    "https://svc.webspellchecker.net"
];


// ENFORCED CSP (blocks violations)
$cspEnforce = [
    "object-src 'none'",
    "base-uri 'none'",
    "form-action 'self' https://www.facebook.com",
    "frame-ancestors 'self'",
    "report-uri https://mysite.com/csp-report/",
    "report-to csp-endpoint"
];
if($config->https) {
    $cspEnforce[] = "upgrade-insecure-requests";
}

// REPORT-ONLY CSP (just reports, doesn't block)
$cspReportOnly = [
    "script-src-elem " . implode(' ', $scriptSourcesElem), // Controls <script> tags
    "script-src-attr " . implode(' ', $scriptSourcesAttr), // Controls onclick/onload/etc
    "style-src " . implode(' ', $styleSources),
    "font-src " . implode(' ', $fontSources),
    "img-src " . implode(' ', $imgSources),
    "connect-src " . implode(' ', $connectSources),
    "report-uri https://mysite.com/csp-report/",
    "report-to csp-endpoint"
];

header("Reporting-Endpoints: csp-endpoint=\"https://mysite.com/csp-report/\"");
header("Content-Security-Policy: " . implode('; ', $cspEnforce));
header("Content-Security-Policy-Report-Only: " . implode('; ', $cspReportOnly));


And in case anyone is interested, I have this script for the csp-report template file:

<?php namespace ProcessWire;

$data = file_get_contents('php://input');

if($data) {
    $report = json_decode($data, true);

    // Browser extension indicators - these are SAFE to ignore
    $extensionIndicators = [
        'chrome-extension://',
        'moz-extension://',
        'safari-extension://',
        'webkit-masked-url://',
        'ms-browser-extension://'
    ];

    // Known bad actor domains to filter
    $knownExtensionDomains = [
        'cdn.honey.io',
        'honey.io',
        'extensions.grammarly.com',
        'grammarly.com',
        'lastpass.com',
        'dashlane.com',
        'bitwarden.com',
        '1password.com'
    ];

    $reports = [];
    if (isset($report[0]) && is_array($report[0])) {
        $reports = $report;
    } else {
        $reports = [$report];
    }

    $shouldLog = false;
    foreach ($reports as $singleReport) {
        $blockedURL = $singleReport['body']['blockedURL'] ?? '';
        $sourceFile = $singleReport['body']['sourceFile'] ?? '';

        $isExtension = false;

        // Only ignore if it's DEFINITELY from a browser extension (protocol check)
        foreach ($extensionIndicators as $indicator) {
            if (stripos($sourceFile, $indicator) !== false) {
                $isExtension = true;
                break;
            }
        }

        // Or from a known safe extension domain
        if (!$isExtension) {
            foreach ($knownExtensionDomains as $domain) {
                if (stripos($blockedURL, $domain) !== false || stripos($sourceFile, $domain) !== false) {
                    $isExtension = true;
                    break;
                }
            }
        }

        if (!$isExtension) {
            $shouldLog = true;
            break;
        }
    }

    if ($shouldLog) {
        $log->save('csp-report', $data);
    }
}

http_response_code(204);
exit;

 

I am not sure if I am overly complicating things. It honestly seems pretty hard to get all this working when you're relying on different third party scripts. I see that even google.com has a very limited policy and it's in report-only mode, so maybe everyone has given up on this anyway 😁

Posted
10 minutes ago, adrian said:

Can you explain how this works with ProCache though - wouldn't the <script> calls in the cached html files have the version of the nonce from when they were created which would differ from the current one when the page is loaded. I am guessing there is something to this that I am missing - note that I don't really use ProCache.

I forgot there was one more complexity,  which is actually adding the nonce to the response. I remember doing it with mod_substitute and there seems to be an easier way using Apache SSI. Can't remember if I actually tried SSI. 

Posted

@elabx - I don't really know how those apache modules work exactly, but wouldn't they have the same issue as I realized when I thought - why not just hook into Page::render and do a string replace on all <script> tags to add in the nonce at runtime - the problem being of course that this would also add the nonce to any maliciously injected <script> tags, thereby effectively removing all protection?

Posted
1 minute ago, adrian said:

hy not just hook into Page::render and do a string replace on all <script> tags to add in the nonce at runtime

Apache just does it faster from what I remember. 

Posted
3 minutes ago, elabx said:

Apache just does it faster from what I remember. 

I suppose it would, but wouldn't the process would still invalidate the protection, right? Or am I missing something?

 

Posted
39 minutes ago, adrian said:

wouldn't the <script> calls in the cached html files have the version of the nonce from when they were created

No! they would hold the placeholder reference! Something like

<script src="{APACHE_SCP_NONCE}"> 

Then swapped by apache right before delivery.

  • Like 1
Posted

Ah yes, of course - that makes sense and a very different use case to what I was thinking about. I suppose if an attacker knew your system they could add that to an injected script, but hopefully that's unlikely.

Posted

@ryan I am wondering if this should be the approach in wire/config.php

if (preg_match('#^Content-Security-Policy(?:-Report-Only)?:.*\s(?:script-src|script-src-elem)\s+(?:[^;]+\s)?\'nonce-([\w+/]+=*)\'#mi', implode("\n", headers_list()), $m)) {
    $config->cspNonce = $m[1];
} 
else {
    $config->cspNonce = base64_encode(random_bytes(16));
}

This way if we have already set one in htaccess or another included script has set one via a PHP header, then PW would use that instead of defining a new one.

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
  • Recently Browsing   1 member

×
×
  • Create New...