adrian Posted February 18 Posted February 18 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. 3
elabx Posted February 18 Posted February 18 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 February 18 Author Posted February 18 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 February 18 Posted February 18 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 February 18 Author Posted February 18 @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 February 18 Posted February 18 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 February 18 Author Posted February 18 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 February 18 Posted February 18 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 February 18 Author Posted February 18 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 February 19 Author Posted February 19 @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.
adrian Posted March 29 Author Posted March 29 If anyone is interested in being able to set script-src-attr to "none" on the frontend of their sites, the namespaced branch of Tracy now uses eventListeners everywhere - no more inline handlers.
nbcommunication Posted Monday at 02:48 PM Posted Monday at 02:48 PM Hi, We use the mod_cspnonce + Apache SSI approach. .htaccess: Options +Includes AddType text/html .html .php AddOutputFilter INCLUDES .html .php <IfModule mod_headers.c> # If not the PW admin <If "! %{QUERY_STRING} =~ /{your-admin-url}/"> ... Header set Content-Security-Policy "\ default-src 'self';\ script-src 'nonce-%{CSP_NONCE}e' 'strict-dynamic';\ ... " </If> </IfModule> In your template: <!--NoMinify--> <script src="./your-script.js" nonce="<!--#echo var="CSP_NONCE"-->"> <!--/NoMinify--> Note that the NoMinify tags need to be added to stop ProCache stripping out the SSI var as it treats it as an HTML comment. We've been using this in production for 2-3 years on sites with the Apache module available and it works well. Cheers, Chris 1
adrian Posted yesterday at 09:50 PM Author Posted yesterday at 09:50 PM @nbcommunication - my main goal is giving PW module developers a guaranteed way to add a nonce to script calls. I have set up a very strict CSP and I am having to modify some modules to add the nonce. If PW had this it would make life easier. I'd even be ok with a core wireGetNonce() helper function that module authors could use instead of a $config->cspNonce() to define one. I feels really good to have a strict CSP set up - most sites don't seem to bother, or if they do, the restrictions are pretty useless in reality.
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