Search code examples
php.htaccessmeta-tagsnonce

Dynamic Nonce : Error using dynamic nonce in .htaccess Content-Security-Policy (CSP) and PHP


I know this has been asked countless times, but I cant seem to find a solution to my problem.

The Problem : I am unable to use Content-Security-Policy nonce and its generating an error.

The Error via console.log : Refused to apply inline style because it violates the following Content Security Policy directive: "default-src 'self'". Either the 'unsafe-inline' keyword, a hash ('sha256-eM7IckhPhRx5dBGXZhwsgAKulpq/euetK0YPweqUKX4='), or a nonce ('nonce-...') is required to enable inline execution. Note that hashes do not apply to event handlers, style attributes and javascript: navigations unless the 'unsafe-hashes' keyword is present. Note also that 'style-src' was not explicitly set, so 'default-src' is used as a fallback.

What I also tried : I also tried using mod_unique_id instead of using PHP set env but it throws internal server error

What am i doing wrong

My Code :

.htaccess

Options +FollowSymLinks
RewriteEngine On

<IfModule mod_headers.c>
FileETag None
Header unset ETag
Header set Cache-Control "max-age=0, no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "Wed, 11 Jan 1984 05:00:00 GMT"
Header set Connection keep-alive
Header set X-XSS-Protection "1; mode=block"

SetEnv MY_CSP_NONCE "<?php echo $_SERVER['MY_CSP_NONCE']; ?>"

Header always set Content-Security-Policy "expr=default-src 'none'; script-src 'self' require-trusted-types-for 'script' https://www.googletagmanager.com https://www.facebook.com https://www.twitter.com https://www.instagram.com 'nonce-%{ENV:MY_CSP_NONCE}' 'strict-dynamic' 'wasm-eval' 'unsafe-eval'; script-src-elem 'self'; connect-src 'self'; img-src 'self' https://storage.googleapis.com data:; video-src 'self' https://storage.googleapis.com data:; style-src 'self' style-src-attr 'self' 'nonce-%{ENV:MY_CSP_NONCE}'; base-uri 'none'; object-src 'none'; frame-ancestors 'self'; frame-src 'self'; sandbox allow-same-origin allow-scripts allow-popups; media-src 'self'; worker-src 'self https://*.cloudflare.com'; manifest-src 'self'; child-src 'self'; prefetch-src 'self' https://storage.googleapis.com https://www.googletagmanager.com; form-action 'self' https://www.paystack.com; font-src 'self' data:; upgrade-insecure-requests"

Header set Feature-Policy "geolocation 'self'; vibrate 'none'"
Header always set Content-Security-Policy-Report-Only "default-src 'self'; report-uri https://www.example.com/csp-report-endpoint"
Header always set X-Frame-Options "sameorigin"
Header set X-Content-Type-Options "nosniff"
Header set Strict-Transport-Security "max-age=15552000; includeSubDomains; preload"
Header always set Cross-Origin-Opener-Policy "same-origin-allow-popups"
Header always set Cross-Origin-Resource-Policy "same-site"
SetEnvIf Referer "^https://storage.googleapis.com" CORP_EXEMPT
Header always set Cross-Origin-Embedder-Policy "require-same-origin"
Header always set Cross-Origin-Embedder-Policy "unsafe-none" env=CORP_EXEMPT
Header set Cross-Origin-Embedder-Policy "unsafe-none" "expr=%{REQUEST_URI} =~ m!\.(png|jpe?g|gif|svg|webp|avif|mp4|webm|m4a|ogv)$!"
</IfModule>

RewriteCond %{SCRIPT_FILENAME} !-d
RewriteCond %{SCRIPT_FILENAME} !-f

RewriteRule ^index$ ./index.php
RewriteRule ^about$ ./about.php

RewriteRule ^404$ ./404.php
RewriteRule ^500$ ./500.php

ErrorDocument 404 https://www.example.com/404

IndexIgnore *

my cookiesetter.php - Where nonce is stored to be included in every script

  <?php 
  $nonce = rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
  putenv("MY_CSP_NONCE=$nonce");
  ?>

and index.php

  <?php include "cookiesetter.php" ?>

  <html>
  <head>
  <title>Example</title>
  <style nonce="<?php echo $nonce ?>">
  bla bla bla
  </style>
  </head>

  <body>
  <script nonce="<?php echo $nonce ?>">
  bla bla bla
  </script>
  </body>
  </html>

Solution

  • So i solved it with the help of @soulseekah and another online friend.

    NB: You must be using PHP 8.2 version for this to work on live server and localhost. You can change it, for example, on cpanel live server, by searching for "select php version"

    NB: You could ask chatgpt to generate the Nginx server version for you, copy and paste the .htaccess and headersettercsp.php, it would do that.

    The solution

    .htaccess

     Options +FollowSymLinks
    RewriteEngine On
    
    <IfModule mod_mime.c>
    AddType text/css .css
    AddType image/png .png
    AddType image/jpeg .jpg
    AddType image/avif .avif
    AddType image/webp .webp
    AddType application/font-woff2 .woff2
    </IfModule>
    
    <Files "csp_violations.log">
    Order Allow,Deny
    Deny from all
    </Files>
    
    <Files "application_log">
    Order Allow,Deny
    Deny from all
    </Files>
    
    <Files "error_log">
    Order Allow,Deny
    Deny from all
    </Files>
    
    <Files "security_log">
    Order Allow,Deny
    Deny from all
    </Files>
    
    <IfModule mod_headers.c>
    FileETag None
    Header unset ETag
    Header set Cache-Control "public, max-age=240"
    Header set Pragma "cache"
    Header set Expires "Wed, 11 Jan 1984 05:00:00 GMT"
    Header set Connection keep-alive
    Header set X-XSS-Protection "1; mode=block"
    Header set Referrer-Policy "strict-origin-when-cross-origin"
    SetEnvIf Referer "^https://storage.googleapis.com" CORP_EXEMPT
    Header always set Cross-Origin-Embedder-Policy "require-same-origin"
    Header always set Cross-Origin-Embedder-Policy "unsafe-none" env=CORP_EXEMPT
    Header set Cross-Origin-Embedder-Policy "unsafe-none" "expr=%{REQUEST_URI} =~ m!\.(png|jpe?g|gif|svg|webp|avif|mp4|webm|mov|m4a|ogv)$!"
    <Files "headersettercsp.php">
    <If "-f %{REQUEST_FILENAME}">
    SetHandler application/x-httpd-php
    Header always set Content-Security-Policy "none"
    </If>
    </Files>
    Header always set Content-Security-Policy-Report-Only "default-src 'self'; report-uri https://www.example.com/csp-report-endpoint"
    Header set Feature-Policy "geolocation 'self'"
    Header always set X-Frame-Options "SAMEORIGIN"
    Header set X-Content-Type-Options "nosniff"
    Header set Strict-Transport-Security "max-age=15552000; includeSubDomains; preload"
    Header always set Cross-Origin-Opener-Policy "same-origin-allow-popups"
    Header always set Cross-Origin-Resource-Policy "cross-origin"
    SetEnvIf Origin "https://storage.googleapis.com" CORP_ENABLE
    SetEnvIf Origin "https://www.cloudflare.com" CORP_ENABLE
    SetEnvIf Origin "https://www.paystack.com" CORP_ENABLE
    Header always set Cross-Origin-Resource-Policy "cross-origin" env=CORP_ENABLE
    #Header set Access-Control-Expose-Headers "Content-Disposition"
    Header set Access-Control-Allow-Methods "GET, HEAD, OPTIONS"
    Header set Access-Control-Allow-Headers "Origin, Content-Type, X-Requested-With, Authorization, Accept, x-test-header"
    Header merge Vary Origin
    RewriteCond %{REQUEST_METHOD} OPTIONS
    RewriteRule .* / [R=200,L]
    </IfModule>
    
    RewriteCond %{SCRIPT_FILENAME} !-d
    RewriteCond %{SCRIPT_FILENAME} !-f
    
    RewriteRule ^index$ ./index.php
    RewriteRule ^about$ ./about.php
    
    RewriteRule ^about/(.*)$ ./about.php?linkcheck=$1 [NC,L]
    
    RewriteRule ^400$ ./400.php
    RewriteRule ^401$ ./401.php
    RewriteRule ^403$ ./403.php
    RewriteRule ^404$ ./404.php
    RewriteRule ^500$ ./500.php
    RewriteRule ^503$ ./503.php
    
    ErrorDocument 400 https://www.example.com/400
    ErrorDocument 401 https://www.example.com/401
    ErrorDocument 403 https://www.example.com/403
    ErrorDocument 404 https://www.example.com/404
    ErrorDocument 500 https://www.example.com/500
    
    RewriteCond %{REQUEST_URI} !^/503.php$
    RewriteCond %{ENV:REDIRECT_STATUS} 503
    
    # Redirect users to the maintenance page
    RewriteRule ^ https://www.example.com/503 [R=301,L]
    
    IndexIgnore *
    

    my headersettercsp.php - Where nonce is stored to be included in every script

    $nonce = '';
    $charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
    $charset_length = strlen($charset);
    $nonce_length = 16;
    for ($i = 0; $i < $nonce_length; $i++) {
    $nonce .= $charset[random_int(0, $charset_length - 1)];
    }
    // Encode the nonce for safe use in URLs
    $nonce = base64_encode($nonce);
    $nonce = rtrim(strtr($nonce, '+/', '-_'), '=');
    
    $cspHeader = "default-src 'self' data: blob: https://www.example.com/ http://localhost/example http://localhost https://localhost https://www.cloudflare.com/" .
    "script-src 'self' 'nonce-$nonce' data: blob: https://www.example.com/js/ http://localhost/example/js; " .
    "script-src-attr 'self' " . 
    "strict-dynamic 'nonce-$nonce' 'wasm-eval'; " .
    "connect-src 'self' https://www.example.com; " .
    "style-src 'self' 'nonce-$nonce' data: blob: https://www.example.com; " . 
    "style-src-attr 'self' 'nonce-$nonce'; " .
    "base-uri 'none'; " . 
    "object-src 'none'; " . 
    "frame-ancestors 'self'; " . 
    "frame-src 'self' https://www.example.com; " .
    "sandbox allow-scripts allow-forms; " .
    "img-src 'self' 'nonce-$nonce' data: blob: http://localhost/example https://storage.googleapis.com https://localhost/; " .
    "media-src 'self' 'nonce-$nonce' data: blob: http://localhost/example https://storage.googleapis.com https://localhost/; " .
    "worker-src 'self' data: blob: https://*.cloudflare.com; " .
    "manifest-src 'self' data: blob: https://www.googletagmanager.com https://storage.googleapis.com; " .
    "child-src 'self'; " .
    "form-action 'self' data: blob: https://www.paystack.com; " .
    "font-src 'self' data: blob: https://fonts.gstatic.com; " .
    "http://localhost/example/css " .
    "https://www.example.com/css " .
    "block-all-mixed-content;" .
    "upgrade-insecure-requests;" .
    "require-trusted-types-for 'script';";
    
    header("Content-Security-Policy: $cspHeader");
    
    // Get all HTTP request headers
    $headers = getallheaders();
    
     // Function to perform strict parsing and validation of HTTP headers START
    function isValidHeader($header, $value) {
    // Check if the header name contains only alphanumeric characters and hyphens
    if (!preg_match('/^[a-zA-Z0-9-]+$/', $header)) {
    return false;
    }
    
    // Check if the header value contains only printable ASCII characters
    if (!preg_match('/^[ -~]*$/', $value)) {
    // Log the incident
    error_log('Header value contains non-printable ASCII characters: ' . $value);
    // Optionally, reject the request
    // return false;
    // Or sanitize the header value
    $value = filter_var($value, FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH);
    }
    // Additional validation rules can be added as needed
    return true; // Header is considered valid
    }
    // Function to perform strict parsing and validation of HTTP headers END
    
    
    // Iterate through each header START
    foreach ($headers as $header => $value) {
    // Perform strict parsing and validation
    if (!isValidHeader($header, $value)) {
    // Reject the request if the header is malformed or suspicious    
    http_response_code(500);
    ?>
    <script nonce="<?php echo $nonce; ?>"><?php include "includeprefixlink.php" ?>500</script>
    <?php
    exit();
    }
    }
    
    
    // Iterate through each header END
    
    $allowedDomains = [
    'https://paystack.com',
     'https://www.cloudflare.com',
     'https://www.googletagmanager.com',
     'https://storage.googleapis.com',
     'https://www.example.com',
     'http://localhost',
     'http://localhost/example',
     'http://localhost:8080', // Adjusted to include HTTP for localhost
    ];
    
    
     // Initialize a flag to track whether the CORS headers have been set
    $corsHeadersSet = false;
    
    foreach ($allowedDomains as $domain) {
    $domain = trim($domain);
    $sanitizedDomain = filter_var($domain, FILTER_SANITIZE_URL);
    if ($sanitizedDomain !== $domain) {
    continue; // Reject the domain if the sanitized version is different from the original AND Skip to the next iteration
    }
    
    // Check the origin and set CORS headers if a match is found
    $origin = isset($_SERVER['HTTP_ORIGIN']) ? $_SERVER['HTTP_ORIGIN'] : '';
    //if (!empty($origin) && $origin === $domain) {
    // Origin is in the whitelist, allow the request
    header('Access-Control-Allow-Origin: ' . $origin);
    header('Access-Control-Allow-Credentials: true');
     Set the flag to indicate that CORS headers have been set
    $corsHeadersSet = true;
    break; // Exit the loop early as CORS headers are already set
    }
    }
    
    //If CORS headers have not been set (no match found in the whitelist), deny the request
    if (!$corsHeadersSet) {
    http_response_code(403);
    ?>
    <script nonce="<?php echo $nonce; ?>"><?php include "includeprefixlink.php" ?>500</script>-->
    exit();
    }
    

    helpful resources

    https://content-security-policy.com/examples/

    AND

    https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src

    this CSP drove me crazy

    and index.php and other pages eg about.php

     <?php include "headersettercsp.php" ?>
    
      <html>
      <head>
      <title>Example</title>
      <style nonce="<?php echo $nonce ?>">
      bla bla bla
      </style>
      </head>
    
      <body>
      <script nonce="<?php echo $nonce ?>">
      bla bla bla
      </script>
      </body>
      </html>
    

    Guys please feel free to add an edit what you think. Edit my answer and add yours even if it is in 10 years time.

    This is a learning platform.