Cotiless had the kind of frontend stack I see all the time: marketing scripts, analytics, consent tooling, a couple of “just paste this snippet” integrations, and a team that wanted security without breaking the site.
That’s exactly where CSP gets messy.
The goal wasn’t to build the most academic Content Security Policy. The goal was to ship a policy that reduced XSS risk, survived real production traffic, and didn’t turn every release into a blame game between security and frontend.
Here’s how I’d approach CSP for Cotiless, using a real production-style header as the reference point.
The starting point
The reference CSP header looks like this:
content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-ZmQzZGU5YTktODk2Zi00ZGFkLTg1ZjgtMmM4ZjcxYTgxMWVh' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
frame-src 'self' https://consentcdn.cookiebot.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'
This is a pretty realistic policy. It’s not toy-level. It’s already doing a few things right:
object-src 'none'kills old plugin attack surfacebase-uri 'self'blocks base tag abuseform-action 'self'limits form exfiltrationframe-ancestors 'none'prevents clickjackingscript-srcuses a nonce andstrict-dynamic
That said, I wouldn’t call it “done.” I’d call it “halfway to solid.”
What Cotiless looked like before CSP
Here’s the kind of HTML I usually find before a cleanup.
<!doctype html>
<html>
<head>
<title>Cotiless</title>
<script>
window.dataLayer = window.dataLayer || [];
function trackPage() {
console.log("tracking page");
}
trackPage();
</script>
<script src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXX"></script>
<script src="https://consent.cookiebot.com/uc.js" data-cbid="abc123"></script>
<style>
.hero { background: #111; color: white; }
</style>
</head>
<body onload="initApp()">
<button onclick="startCheckout()">Buy now</button>
<script>
function initApp() {
console.log("init");
}
function startCheckout() {
fetch("https://api.cotiless.example/checkout", {
method: "POST"
});
}
</script>
</body>
</html>
This is common and bad for CSP:
- inline scripts
- inline styles
- event handlers like
onclickandonload - third-party scripts with broad trust
- no clear inventory of network destinations
If someone lands an HTML injection anywhere in this page, they’ve got a pretty good shot at turning it into script execution.
The first bad fix
A lot of teams respond by shipping a permissive CSP that looks secure on paper and weak in reality.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://*.cookiebot.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src *;
I’ve seen versions of this in production more times than I’d like.
Why it’s weak:
'unsafe-inline'inscript-srclargely defeats XSS protection'unsafe-eval'expands the attack surface even moreconnect-src *means injected code can exfiltrate data almost anywhere- no
object-src,base-uri,frame-ancestors, orform-action
This kind of policy exists mostly to satisfy a checkbox.
The Cotiless target state
For Cotiless, I’d aim for a CSP closer to this:
Content-Security-Policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.cotiless.example https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
frame-src 'self' https://consentcdn.cookiebot.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
This is close to the reference header, but adapted for Cotiless.
A few opinions here:
1. Nonce + strict-dynamic is the right direction
If you can do nonces, do nonces.
A nonce-based policy lets you explicitly trust server-rendered scripts you intended to run. With strict-dynamic, those trusted scripts can load their own dependencies without you having to endlessly maintain host allowlists. For a lot of modern apps, that’s much more survivable than trying to guess every script origin forever.
If you want the deeper mechanics, the official CSP docs and https://csp-guide.com are worth reading.
2. style-src 'unsafe-inline' is still a compromise
I’m not going to pretend otherwise. If Cotiless can remove inline styles and move to nonce- or hash-based styles, that’s better. But in the real world, consent managers and tag tooling often push teams into temporary compromises.
I’d accept this short term, but I’d put it on the cleanup list.
3. connect-src should be aggressively narrow
This directive gets ignored too often. If an attacker finds a way to run script, connect-src can still limit where data gets sent.
Don’t use connect-src * unless you enjoy helping attackers.
The code changes that made the CSP possible
A real CSP rollout usually means touching templates.
Before
<body onload="initApp()">
<button onclick="startCheckout()">Buy now</button>
<script>
function initApp() {
console.log("init");
}
function startCheckout() {
fetch("/checkout", { method: "POST" });
}
</script>
</body>
After
<body>
<button id="buy-button">Buy now</button>
<script nonce="{{ .CSPNonce }}" src="/static/app.js"></script>
</body>
Then in app.js:
function initApp() {
console.log("init");
}
function startCheckout() {
fetch("/checkout", { method: "POST" });
}
window.addEventListener("load", initApp);
document
.getElementById("buy-button")
.addEventListener("click", startCheckout);
This is the boring work that actually makes CSP effective:
- remove inline handlers
- move inline code into external files
- attach a nonce to any script that must remain inline
- stop relying on browser execution of random HTML attributes
Generating the nonce
Cotiless needs a fresh nonce per response. Reusing one across requests is sloppy and unnecessary.
Example in Node/Express:
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString("base64");
res.locals.cspNonce = nonce;
res.setHeader(
"Content-Security-Policy",
[
"default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com`,
"style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.cotiless.example https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com",
"frame-src 'self' https://consentcdn.cookiebot.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'"
].join("; ")
);
next();
});
Template usage:
<script nonce="{{cspNonce}}">
window.appConfig = {
env: "production"
};
</script>
<script nonce="{{cspNonce}}" src="/static/app.js"></script>
What broke during rollout
A real rollout always breaks something.
For Cotiless, I’d expect these failures first:
- inline analytics bootstrap code blocked
- old
onclickhandlers stop working - consent tool iframe or script not covered by the policy
- CSS-in-JS injecting styles in ways that need adjustment
- websocket or API calls blocked by
connect-src
That’s why I roll this out in report-only mode first.
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
Start tight. Watch reports. Then add only what production proves you need.
Not what vendors ask for. What the app actually needs.
Before and after risk profile
Before
- any inline script injection had a high chance of executing
- event-handler injection like
onclick=...could execute - broad third-party trust made script loading harder to reason about
- exfiltration controls were weak or absent
After
- untrusted inline scripts are blocked unless they carry the server nonce
- injected event handlers stop working
- form submission and framing are locked down
- object/embed vectors are gone
- outbound connections are limited to known services
- third-party script loading is under tighter control
That’s a meaningful improvement, even if style-src 'unsafe-inline' still needs work.
The final Cotiless policy I’d ship
If I had to put a production recommendation in front of a team today, it would be this:
Content-Security-Policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.cotiless.example https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
frame-src 'self' https://consentcdn.cookiebot.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
And the follow-up ticket would be obvious:
- remove inline styles where possible
- review whether
default-srcneeds third-party hosts at all - prune unused origins from
connect-src - keep all new scripts nonce-based
- fail builds when templates reintroduce inline handlers
That’s the part teams miss. CSP isn’t a one-time header. It’s a contract with your frontend. If Cotiless treats it that way, the policy stays useful instead of slowly turning into a pile of exceptions.