adrian Posted 9 hours ago Posted 9 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 8 hours ago Posted 8 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 8 hours ago Author Posted 8 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 8 hours ago Posted 8 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 8 hours ago Author Posted 8 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 8 hours ago Posted 8 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 7 hours ago Author Posted 7 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 7 hours ago Posted 7 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 7 hours ago Author Posted 7 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.
adrian Posted 1 hour ago Author Posted 1 hour ago @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.
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