adrian Posted 6 hours ago Posted 6 hours ago 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. 2
elabx Posted 5 hours ago Posted 5 hours ago 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 1
adrian Posted 5 hours ago Author Posted 5 hours ago 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 😁
elabx Posted 4 hours ago Posted 4 hours ago 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.
adrian Posted 4 hours ago Author Posted 4 hours ago @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?
elabx Posted 4 hours ago Posted 4 hours ago 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.
adrian Posted 4 hours ago Author Posted 4 hours ago 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?
elabx Posted 4 hours ago Posted 4 hours ago 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. 1
adrian Posted 4 hours ago Author Posted 4 hours ago 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.
Recommended Posts
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 accountSign in
Already have an account? Sign in here.
Sign In Now