Content Security Policy (CSP) is one of the main security mechanisms offered by browsers to limit the impact of client-side vulnerabilities. In theory, a well-designed CSP neutralises a large proportion of injection attacks; in practice, it is often weakened by compromises that have accumulated over time: exceptions added to maintain compatibility with legacy code, third-party domain allowlists that expand without ever being pruned, poorly generated nonces, or poorly managed trust relationships. The result is that a policy which appears strict at first glance may, under certain conditions, remain entirely circumventable.
This article examines how CSP works, its main directives and source expressions, the most common configuration errors, and bypass techniques. We will then look at how to audit a CSP in a penetration testing context, followed by how to build a policy that is truly robust against such attacks.
Comprehensive Guide to Content Security Policy (CSP)
- What is Content Security Policy?
- What Role Does CSP Play in Preventing XSS?
- Principles and How Content Security Policy Works
- Understanding the Key CSP Directives
- Understanding Source Expressions and CSP Values
- Trusted Types and Reducing DOM XSS Attack Surface
- The Limitations of Content Security Policy
- What are the Most Common CSP Configuration Errors?
- Excessive reliance on unsafe-inline
- Excessive reliance on unsafe-eval
- Wildcards and overly broad domain allowlists
- Excessive confidence in ‘self’
- Incomplete policy
- Explicit absence of object-src and base-uri
- Static, predictable or incorrectly assigned nonces
- CSP deployed in Report-Only mode only
- Inconsistent deployment depending on the routes
- Compromise on compatibility with older browsers
- Common Techniques for Bypassing Content Security Policy
- Exploiting a policy allowing unsafe-inline
- Trusted third-party domain and JSONP endpoint
- Bypassing the ‘self’ script-src restriction via an upload
- Bypassing a weak or misused nonce
- strict-dynamic and vulnerable script loader
- Gadget script, trusted framework and DOM clobbering
- HTML injection without JavaScript execution, but with an impact via base-uri or form-action
- DOM XSS, CSP and Trusted Types: Understanding the Differences Between these Mechanisms
- Penetration Testing Methodology of a Content Security Policy
- How to Build a Robust Content Security Policy (CSP)?
- Start with the actual functional requirements
- Prioritise nonces or hashes for JavaScript
- Explicitly prohibit inline event handlers wherever possible
- Phase out unsafe-inline and unsafe-eval
- Minimise third-party domains
- Isolate user-controlled content
- Use base-uri, form-action, frame-ancestors and object-src explicitly
- Adopt Trusted Types where the architecture allows
- Roll out the policy gradually using Report-Only
- Monitor breaches without turning reporting into noise
- Ensure the policy is maintained over time
- Conclusion
What is Content Security Policy?
Content Security Policy is a security mechanism enforced directly by the browser. An application sends the browser a policy that describes the authorised sources and behaviours for the current document. The browser then checks each operation affected by CSP before allowing it.
For example, a minimal policy could be sent in the HTTP response:
Content-Security-Policy: default-src 'self'; script-src 'self'
This configuration specifies that, by default, resources must come from the same origin as the application and that JavaScript scripts must also be loaded from a source corresponding to ‘self’.
If the page then contains:
<script src="/js/app.js"></script>
The browser will normally allow the resource to be loaded, as it comes from the current origin. However:
<script src="https://attacker.example/payload.js"></script>
will be blocked if attacker.example is not authorised by the directive that actually applies to the script.
CSP does not attempt to determine whether a JavaScript file is malicious. The browser does not perform a semantic analysis of the code to decide whether it is dangerous. It checks whether the requested loading or execution complies with the rules provided to it. This distinction is essential: CSP is, first and foremost, a system for controlling the capabilities and trust relationships of the document.
What Role Does CSP Play in Preventing XSS?
CSP is often presented as a safeguard against XSS. This statement is correct, provided one understands precisely what it means. A CSP does not fix the vulnerability that allows user-controlled data to be injected into HTML or the DOM. It can, however, render some attack vectors ineffective.
Let us imagine that an application re-injects a user-controlled value without encoding it:
<div>
USER_INPUT
</div>
If the user manages to convert this value into arbitrary HTML, the vulnerability exists regardless of CSP. A restrictive policy may, however, prevent certain payloads from being executed. For example:
Content-Security-Policy: script-src 'self'
An inline JavaScript injection will normally be blocked unless another mechanism within the policy allows it.
However, it would be dangerous to conclude from this that the injection no longer has any impact. An attacker may sometimes find another method: loading a script from an already authorised source, hijacking a trusted JavaScript loader, exploiting a gadget script provided by a framework, altering a form’s destination, manipulating a base URL, or taking advantage of DOM behaviour that does not require the insertion of a new <script> block.
CSP must therefore remain a defence-in-depth measure. Prevention at source still relies on context-aware output encoding, sanitisation where HTML is legitimately accepted, the use of secure DOM APIs, and the removal of pipelines that transform untrusted data into code or interpreted content.
Principles and How Content Security Policy Works
When a browser receives a CSP-protected document, it analyses the policies associated with the document and constructs the set of rules it must apply whilst using it. When a resource needs to be fetched, when a script needs to be executed, when a form is submitted, or when any other CSP-related operation occurs, the browser determines the applicable directive and then checks whether the action complies with the policy.
Let’s consider the following configuration:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://static.example.com;
img-src 'self' https://images.example.com;
connect-src 'self' https://api.example.com
A script from https://static.example.com/app.js may be allowed, whilst a script from https://attacker.example/payload.js is blocked. Similarly, a fetch() request to https://api.example.com/users may be allowed by connect-src, whilst a request to an undeclared origin is refused.
This mechanism turns the browser into an additional enforcement layer. It does not prevent the application from generating vulnerable HTML, but it can block certain consequences that this HTML might attempt to cause.
The HTTP Content-Security-Policy header
The recommended method for implementing a policy is the HTTP header:
Content-Security-Policy: default-src 'self'
This header is included in the response containing the document to be protected. It enables the policy to be applied as soon as the document is loaded and provides access to all the guidelines specified for this delivery method.
A policy can also be declared in the HTML document:
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'">
This method can be useful when it is not possible to modify HTTP headers, but it has several limitations. Content-Security-Policy-Report-Only cannot be deployed via a <meta>
tag, and directives such as frame-ancestors, report-uri or sandbox are not supported in this mode. Furthermore, a policy declared using <meta> only applies to content located after the element once it has been parsed. Wherever the infrastructure allows, the HTTP header should therefore be used in preference.
In-browser validation process
The applicable directive depends on the type of operation. For a <script> element, for example, the browser may use `script-src-elem`, then fall back to `script-src` and finally `default-src` if the more specific directives are absent. For an inline event handler such as `onclick`, `script-src-attr` may apply. For a `fetch()` or WebSocket connection, the browser checks `connect-src`, then `default-src` if `connect-src` is not defined.
However, some directives follow a different logic. `base-uri`, `form-action` and `frame-ancestors` do not derive their values from `default-src`. A policy that sets default-src ‘none’ without defining these directives should therefore not be interpreted as automatically blocking the operations they control.
This concept of fallback is one of the most important points to master during an audit. The aim is not to read a string of characters, but to reconstruct the policy actually applied to each behaviour.
Multiple CSP policies
A single response may contain several CSP policies. They do not override one another: the browser must apply them all together.
Let’s consider:
Content-Security-Policy: default-src 'self'; connect-src 'none'
Content-Security-Policy: script-src https://static.example.com; connect-src https://api.example.com
The second policy theoretically allows https://api.example.com for `connect-src`, but the first contains connect-src ‘none’. A connection to this API therefore remains blocked, as it must satisfy all policies in enforcement mode.
This behaviour is important in penetration testing. Naively merging two CSPs or reading only the last header can lead to an incorrect conclusion about which resources are actually authorized.
Content-Security-Policy and Content-Security-Policy-Report-Only
CSP has two main deployment modes. With:
Content-Security-Policy: default-src 'self'
violations are effectively blocked. Using:
Content-Security-Policy-Report-Only: default-src 'self'
the browser evaluates the policy and may generate reports, but does not block the operation because of this Report-Only policy.
This distinction is fundamental. Report-Only is particularly useful when introducing a CSP, as it allows you to identify legitimate resources that would otherwise be blocked, adjust the policy and then gradually enable enforcement.
The two modes can coexist. An application can maintain a stable policy in enforcement mode whilst simultaneously testing a more restrictive policy in Report-Only mode. During an audit, it is therefore always necessary to identify which policy actually protects the page and which policy merely monitors its behaviour.
Understanding the Key CSP Directives
A CSP policy consists of directives that control categories of resources or specific behaviours. Some are fetch directives, others protect the document’s structure or navigation, whilst others relate to reporting or modern mechanisms such as Trusted Types.
It is important not to view CSP as a list of independent keywords. The directives form a coherent whole, with fallback mechanisms and interactions that influence the effective policy.
Fetch directives
Fetch directives specify the authorised sources for different types of resources. default-src often acts as a fallback value, but a more specific directive overrides this fallback for the category it controls.
default-src
default-src provides a fallback policy for many categories of resources.
Content-Security-Policy: default-src 'self'
If no more specific directive exists, the resources in question must match ‘self’. For example:
Content-Security-Policy:
default-src 'self';
img-src https://images.example.com
images are controlled by img-src, whilst other resources covered by the fallback mechanism remain subject to default-src 'self'.
A common hardening strategy is to start with:
Content-Security-Policy: default-src 'none'
and then explicitly re-authorise the necessary categories. This approach reduces implicit permissions, but it does not eliminate the need to define directives that do not depend on default-src, notably base-uri, form-action and frame-ancestors.
script-src
script-src is generally the most sensitive directive in a CSP, as it controls a large proportion of the mechanisms that enable JavaScript execution.
Content-Security-Policy: script-src 'self'
This policy normally allows scripts loaded from the current origin and blocks those from unauthorised origins. It also blocks, in the absence of exceptions, the standard execution of inline scripts and many dynamic evaluation mechanisms.
However, analysis of script-src must never be limited to the list of domains. Nonces, hashes, strict-dynamic, 'unsafe-inline', 'unsafe-eval', 'wasm-unsafe-eval', as well as the more specific directives script-src-elem and script-src-attr, can profoundly alter the trust model.
script-src-elem and script-src-attr
CSP Level 3 allows for a more granular distinction between scripts contained within <script> elements and inline event handlers.
script-src-elem applies to <script> elements, whether they are external scripts or inline blocks. For example:
Content-Security-Policy:
script-src 'self';
script-src-elem https://static.example.com
In this case, <script> elements are evaluated according to `script-src-elem`. If this directive is absent, the browser falls back to `script-src`, then to `default-src` if necessary.
`script-src-attr` applies to JavaScript event handlers declared in HTML attributes, such as:
<button onclick="save()">Save</button>
A modern application can explicitly disallow these handlers:
Content-Security-Policy:
script-src 'nonce-RANDOM' 'strict-dynamic';
script-src-attr 'none'
This distinction is important during an audit, as a policy can be strict for <script> elements whilst maintaining different rules for executable attributes.
style-src, style-src-elem and style-src-attr
style-src controls style sheets and, depending on the configuration, inline CSS. CSP3 also provides style-src-elem for elements and style sheets, as well as style-src-attr for style attributes.
Content-Security-Policy: style-src 'self'
Styles generally pose less of a risk than JavaScript when it comes to code execution, but they should not be regarded as having no impact. A CSS injection can alter the user interface in a misleading way and, in certain scenarios, contribute to information leaks or the creation of a side channel. A robust policy therefore restricts styles to only those sources that are genuinely necessary.
img-src
img-src defines the authorised sources for images.
Content-Security-Policy:
img-src 'self' https://images.example.com
Controlling images is useful beyond general security best practice. Loading an image triggers a network request and can be exploited in certain scenarios as a channel for data exfiltration or signalling. Restricting img-src therefore limits the capabilities available to injected content, even when that content cannot directly execute JavaScript.
connect-src
connect-src controls the destinations to which the document can connect via mechanisms such as fetch(), XMLHttpRequest, WebSocket, EventSource or certain Beacon-based operations.
Content-Security-Policy:
connect-src 'self' https://api.example.com
A script running on the page will be able to contact the authorised API, but an attempt to connect directly to an unauthorised origin may be blocked. This directive provides a useful defence-in-depth measure against certain forms of data exfiltration, but it should not be interpreted as a blanket ban on all outbound network traffic: other types of resources or browsing are controlled by other directives.
frame-src and child-src
frame-src defines the origins that may be loaded into elements such as <iframe>.
Content-Security-Policy:
frame-src https://player.example.com
This directive should not be confused with frame-ancestors. frame-src controls what the page can embed; frame-ancestors controls which pages are permitted to embed the current page.
child-src is an older directive that can still serve as a fallback in certain contexts, particularly when frame-src or worker-src are not defined. In a modern policy, it is preferable to explicitly define the directives corresponding to the uses that are actually required, rather than relying on fallback behaviours that are difficult to interpret.
worker-src
worker-src controls the sources used to create Workers, SharedWorkers and Service Workers.
Content-Security-Policy:
worker-src 'self'
When worker-src is absent, the search for an applicable directive may proceed via child-src, then script-src, then default-src. This fallback chain explains why a policy that appears strict at first glance may allow or block workers in a way that differs from what developers intended.
In modern applications that use Service Workers, compute workers or complex front-end architectures, this directive should be configured explicitly.
font-src, media-src et manifest-src
font-src restricts the origins of web fonts. media-src applies to audio and video content, whilst manifest-src controls web application manifests.
These directives are generally less critical than script-src, but they contribute to the principle of least privilege. An application should only allow the categories of resources and origins that it actually needs.
object-src
object-src controls the resources used by the <object> and <embed> elements. These mechanisms are less common today and were historically based on technologies with a large attack surface.
A modern policy therefore frequently specifies:
Content-Security-Policy: object-src 'none'
However, an important caveat must be noted. object-src has a fallback to default-src. Its absence therefore does not necessarily imply a complete lack of restriction if default-src is already restrictive. Explicitly setting object-src ‘none’ remains, however, a best practice when these resources are not required, as it clearly expresses the security intent and prevents any future relaxation of default-src from inadvertently reintroducing this capability.
Directives related to the document and navigation
Some important directives do not directly control a JavaScript file, an image or a stylesheet. They affect the document’s structure, the navigation destinations or the way in which the page can be embedded.
base-uri
The HTML tag <base> allows the base used by the browser to resolve a document’s relative URLs to be changed. An injection such as:
<base href="https://attacker.example/">
can therefore alter the resolution of relative references further down the page. To prevent this behaviour when the application does not use <base>:
Content-Security-Policy: base-uri 'none'
is a robust choice. Where this functionality is required, base-uri ‘self’ may be a more permissive alternative. base-uri does not derive its value from default-src, which means that its absence must be explicitly checked for.
form-action
form-action defines the permitted destinations for form submissions.
Content-Security-Policy: form-action 'self'
This directive is particularly useful when an HTML injection vulnerability remains, even where JavaScript cannot be executed. An attacker may sometimes attempt to inject or modify a form in order to send data to another origin. form-action helps to reduce this attack surface.
Like base-uri, this directive has no fallback to default-src. A default-src 'none' policy therefore does not automatically block all form destinations.
frame-ancestors
frame-ancestors controls which origins are permitted to embed the current page within a frame.
Content-Security-Policy: frame-ancestors 'none'
prohibits all embedding, whilst:
Content-Security-Policy: frame-ancestors 'self'
restricts embedding to origins matching ‘self’.
This directive provides a modern mechanism for protecting against clickjacking and offers greater flexibility than the older X-Frame-Options header. It has no fallback to default-src and must therefore be explicitly set when protection against framing is required.
sandbox
The sandbox directive applies restrictions to the document that are comparable to those of an iframe’s sandbox attribute. Depending on the tokens that are explicitly reauthorised, it can restrict script execution, forms, navigation, pop-ups or certain features of the origin.
This directive is not suitable for all pages, but it can be useful for isolating content that is inherently less trustworthy. It must be used with caution, as a configuration that is too restrictive may break many features, whilst one that is too permissive may give a false sense of isolation.
upgrade-insecure-requests
The directive:
Content-Security-Policy: upgrade-insecure-requests
instructs the browser to treat certain HTTP URLs as if they had been replaced by their HTTPS equivalents. It can facilitate the migration of a legacy application that still contains insecure references.
However, it does not replace the correct implementation of HTTPS or HSTS. Its role is to facilitate the transition and reduce certain forms of mixed content, not to constitute a secure transport strategy in its own right.
Reporting directives
CSP reporting provides visibility into the operations that the policy blocks or would block. It is particularly useful during the phased roll-out of a policy or for monitoring the emergence of new dependencies.
report-to and Reporting-Endpoints
The modern mechanism relies on an endpoint declared separately:
Reporting-Endpoints: csp-endpoint="https://reports.example.com/csp"
and then referenced from the CSP:
Content-Security-Policy:
default-src 'self';
report-to csp-endpoint
report-to contains the logical name of the endpoint, whilst Reporting-Endpoints associates this name with a URL. When a violation is detected, the browser can send a report containing, amongst other things, the applicable directive, the blocked resource and the relevant ‘enforce’ or ‘report’ mode.
Reporting must be treated as a data source that may be controlled by an attacker. The collected values must not be displayed in an internal dashboard without being encoded or validated.
report-uri
report-uri is the legacy CSP reporting mechanism. It has now been superseded by report-to and the Reporting API, but remains useful for certain older browsers or environments.
During a transition period, an application may therefore declare both mechanisms. It should be borne in mind, however, that a browser supporting report-to may prioritise this mechanism and ignore report-uri for the policy in question.
Understanding Source Expressions and CSP Values
A directive alone is not enough to determine whether a policy is restrictive. Actual security depends on how the sources and keywords are specified.
Thus, the following policies all use script-src but establish very different trust models:
Content-Security-Policy: script-src *
Content-Security-Policy: script-src 'self'
Content-Security-Policy: script-src 'nonce-RANDOM' 'strict-dynamic'
The first relies on an extremely broad authorisation of network sources. The second trusts the origin of the application. The third identifies specific root scripts using a nonce and can extend this trust to the scripts they load dynamically.
‘self’
The value ‘self’ refers to the protected origin and takes into account, in particular, the scheme, host and port, along with certain secure upgrade rules specified by CSP. A policy such as:
Content-Security-Policy: script-src 'self'
allows, for example, a relative script such as /js/app.js to be loaded when it belongs to the corresponding origin.
However, it would be incorrect to automatically treat ‘self’ as a secure resource. If the application also serves, from this same origin, user-controlled files, dynamically generated content or resources from storage that is insufficiently compartmentalised, the trust boundary may be too broad. The browser does not know that a file located in /uploads/ is ‘user-provided’ whilst a file located in /js/ is ‘application-provided’: if both correspond to the same CSP source, they belong to the same trust boundary.
‘none’
The value ‘none’ represents the empty set for the directive in question.
Content-Security-Policy: object-src 'none'
thus prohibits resources controlled by object-src.
‘none’ must be used on its own in a list of sources to express this prohibition. If it is mixed with other source expressions, those other expressions may still match; therefore, one must not write an ambiguous configuration on the assumption that ‘none’ will take precedence over everything else.
Host-based sources
A host source may contain a scheme, a host, a port and, optionally, a path.
script-src https://static.example.com
trusts resources corresponding to this source. The host may also be restricted to a path:
script-src https://static.example.com/js/
Paths ending with / act as prefixes, whilst a path without a trailing / may be matched more strictly. It should be borne in mind, however, that redirects have a specific semantics: following a redirect, the path portion of the expression is not used in the same way for matching. A path restriction should therefore not be regarded as a security boundary equivalent to an origin separation.
Authorising a host also raises a fundamental question: who can serve content from that host? A domain belonging to a reputable provider may still pose a risk in script-src if it is a shared platform where any user can publish JavaScript.
Wildcards
Wildcards allow you to broaden the scope of an expression. For example:
script-src https://*.example.com
allows a set of subdomains matching the expression.
Such a rule should not be analysed solely on the basis of the main domain. Overlooked development environments, subdomains delegated to third parties, storage buckets, customer spaces or integrated SaaS services may all become relevant.
The global wildcard * is even more permissive for network sources compatible with the relevant directive. However, it should not be described as ‘absolutely all possible sources’: special schemas such as data: or blob: are handled differently and often need to be explicitly allowed. The correct security conclusion remains that * massively expands the scope of trust and generally has no place in the script-src directive of a policy aimed at limiting XSS.
Sources based on a scheme
A source such as:
img-src https:
allows resources that comply with the HTTPS scheme, without restricting the host. This level of trust may be acceptable for certain less sensitive categories, depending on the context, but would be far more dangerous with:
script-src https:
as the browser could then consider scripts from a very large number of HTTPS origins to be permissible.
Modern policies should therefore favour sources that are as specific as possible, particularly for JavaScript.
data:, blob: and other special schemes
data: and blob: schemes are sometimes required by front-end applications that generate images, downloads or other resources locally. However, authorisation for these must be restricted to the directives that actually require them.
For example, authorising:
img-src 'self' data:
may be justified for inline images, whilst applying the same logic to script-src would introduce a much more vulnerable attack surface.
blob: also warrants specific attention. Depending on the resource category and the browser, a Blob may become an executable object or load code into a worker. A robust policy therefore does not add data: or blob: as a matter of course; it documents their necessity for each resource type.
Nonces
A CSP nonce is a random value associated with a given response and used to identify explicitly authorised elements.
The response may contain:
Content-Security-Policy:
script-src 'nonce-a8Dk3mQ91Yx7...'
and the document:
<script nonce="a8Dk3mQ91Yx7...">
initialiseApplication();
</script>
The browser compares the element’s nonce with that of the policy. If the values match and the other CSP conditions are met, the script is permitted.
The benefit of this model is significant: trust is no longer granted to an entire domain but directly to specific scripts approved by the application for that response.
To be effective, a nonce must be generated on the server side using a cryptographically secure random source, possess sufficient entropy and be renewed with every response. A static value, a predictable value or one derived from a timestamp does not provide the required property.
However, cryptographic quality alone is not sufficient. If the rendering engine automatically adds the nonce to a <script> element, part of which is controlled by an attacker, the policy may continue to trust the incorrect content. It is therefore necessary to audit both the generation of the nonce and the locations where it is applied.
Hashes
Hashes allow you to authorise specific content rather than a domain or a value that changes with every response.
Content-Security-Policy:
script-src 'sha256-BASE64_HASH'
The browser calculates the hash of the script in question and checks that it matches the one declared in the policy. This approach is suitable for static inline blocks whose content changes infrequently.
Hashes offer a high level of granularity, but maintaining them can become costly if the application frequently modifies its HTML or generates dynamic inline code. They can also be used as part of a Strict CSP with strict-dynamic, where the script corresponding to the hash becomes a trusted root.
strict-dynamic
strict-dynamic fundamentally changes the way in which script-src establishes trust.
Consider the following:
Content-Security-Policy:
script-src 'nonce-RANDOM' 'strict-dynamic'
and a root script:
<script nonce="RANDOM" src="/js/loader.js"></script>
The browser trusts this script because it contains the expected nonce. When this script subsequently loads other scripts programmatically, trust can be propagated to these new scripts under the conditions specified by CSP.
For example:
const script = document.createElement("script");
script.src = "/js/module.js";
document.head.appendChild(script);
The aim is to avoid having to maintain large domain allowlists for applications that use loaders or dynamic bundles.
In CSP3 browsers, when strict-dynamic is enabled with a trust root based on a nonce or hash, the `host sources`, `scheme sources`, `self` and `unsafe-inline` values present in the relevant directive are ignored for the modern model. This makes it possible, in particular, to create backwards-compatible policies where older browsers use allowlists whilst newer browsers rely on nonce + strict-dynamic.
However, this property carries a significant responsibility: root scripts become critical security components. If an authorised script creates a new <script> element from a user-controlled URL, an attacker may seek to exploit the trust already granted rather than injecting an unauthorised script themselves.
unsafe-inline
'unsafe-inline' re-enables forms of inline content that CSP normally seeks to prevent, notably <script> blocks and, depending on the directive actually applied, certain inline event handlers.
Content-Security-Policy:
script-src 'self' 'unsafe-inline'
can therefore make many HTML injections directly exploitable in JavaScript.
However, we must avoid drawing overly simplistic conclusions. In a modern policy that uses nonces or hashes, the browser may ignore ‘unsafe-inline’ for the relevant scripts. With strict-dynamic, this value is also ignored by CSP3 browsers in the modern trust model. Furthermore, script-src-attr can impose a specific rule on event handlers.
During a penetration test, the textual presence of ‘unsafe-inline’ is therefore an indicator to analyse, not automatic proof of a bypass.
unsafe-hashes
'unsafe-hashes' facilitates certain migration scenarios where an application wishes to retain specific inline event handlers without re-enabling the entire 'unsafe-inline' feature. The mechanism allows certain executable attributes to be permitted via their hashes.
Whilst this approach may be useful temporarily, modern code should, wherever possible, replace:
<button onclick="save()">Save</button>
with a binding implemented in permitted JavaScript:
button.addEventListener("click", save);
This reduces reliance on executable code directly embedded within HTML and facilitates the adoption of a strict policy.
unsafe-eval
'unsafe-eval' enables various mechanisms that construct or execute code from strings, notably eval() and Function().
Content-Security-Policy:
script-src 'self' 'unsafe-eval'
does not, on its own, introduce an XSS vulnerability. However, it removes a barrier that might otherwise have blocked a dangerous evaluation primitive.
For example:
const expression = getUserControlledValue();
eval(expression);
is vulnerable because untrusted data reaches an execution mechanism. The presence of ‘unsafe-eval’ then allows the exploit to occur despite the CSP.
Dependencies that still require ‘unsafe-eval’ in production must be identified and, where possible, reconfigured or replaced.
wasm-unsafe-eval
WebAssembly applications may need to permit certain dynamic compilation mechanisms. ‘wasm-unsafe-eval’ provides a more targeted authorisation than ‘unsafe-eval’ for this purpose.
Where only dynamic WebAssembly is required, this setting is preferable to the much broader activation of ‘unsafe-eval’. However, if ‘unsafe-eval’ is already enabled, it allows more mechanisms and makes this level of granularity less useful.
Trusted Types and Reducing DOM XSS Attack Surface
Trusted Types complements CSP by addressing an issue that goes beyond the simple selection of sources. The aim is to reduce the number of places where an arbitrary string can be passed to DOM sinks capable of interpreting HTML, script or script URLs.
Let’s consider:
element.innerHTML = userInput;
A strict CSP may block certain payloads produced by this assignment, but the dangerous sink remains. Trusted Types allows you to instruct the browser that certain APIs should no longer accept strings directly, but should instead receive typed objects produced by policies explicitly created by the application.
require-trusted-types-for ‘script’
An application can enable:
Content-Security-Policy:
require-trusted-types-for 'script'
The browser then applies the Trusted Types requirements to the relevant DOM sinks. Directly assigning a string to a protected sink may result in an error if the value has not been produced by an appropriate Trusted Types policy.
Since February 2026, this directive has been available in recent versions of the major browsers, although compatibility with older clients must still be taken into account in any deployment strategy.
The trusted-types directive
The `trusted-types` directive controls the names of policies that the code is authorised to create.
Content-Security-Policy:
require-trusted-types-for 'script';
trusted-types appPolicy
The code can then create an authorised policy:
const policy = trustedTypes.createPolicy("appPolicy", {
createHTML: input => sanitizer.sanitize(input)
});
The benefit is that it centralises dangerous transformations and makes the points where trusted values are created easier to audit.
This directive does not automatically make the code secure. A policy such as:
trustedTypes.createPolicy("appPolicy", {
createHTML: input => input
});
does not sanitise anything. It wraps an arbitrary string in an approved object and therefore shifts the vulnerability to the policy itself.
Limitations of Trusted Types
Trusted Types is particularly useful against DOM XSS attacks linked to certain sinks, but it does not replace data validation or the analysis of trusted code behaviour. Scripts can still create URLs, load resources, interpret data structures or perform operations, not all of which are protected by the same mechanism.
The audit must therefore examine the Trusted Types policies, the createHTML, createScript and createScriptURL functions that are actually defined, as well as the data they receive. The main benefit is architectural: the area of code to be examined is reduced and conversions to trusted types become explicit.
The Limitations of Content Security Policy
CSP can significantly reduce the exploitability of a client-side vulnerability, but it is no substitute for fundamental application-level safeguards.
An HTML injection remains a vulnerability even if an injected <script> is blocked. An attacker can still modify the interface, create interactive elements, influence certain navigation paths, hijack a form or exploit behaviour provided by JavaScript that has already been authorised.
An application using innerHTML with untrusted data also remains vulnerable, even if a restrictive CSP blocks several obvious payloads. The fundamental problem is that untrusted data reaches an HTML interpreter.
Furthermore, CSP considers certain resources to be trusted by definition. If an authorised script contains a vulnerable loader, a gadget or logic that transforms controlled data into a new executable resource, the browser has no reason to regard this behaviour as malicious: the action is carried out by a component that the policy has already trusted.
Finally, CSP is not designed to replace other browser and server controls. HSTS, secure cookies, CSRF protections, access controls, input validation, dependency security and origin isolation remain necessary. CSP should be understood as a specific layer of defence-in-depth rather than as a universal security policy.
What are the Most Common CSP Configuration Errors?
Implementing a CSP is not enough to make an application resistant to XSS. A policy may be syntactically valid and correctly interpreted by the browser, yet still establish an overly permissive trust model.
The most common errors rarely stem from a single keyword: they often result from a series of compromises, introduced to ensure that legacy applications continue to function or to integrate new third-party services.
Excessive reliance on unsafe-inline
The following configuration is still common in applications that use numerous inline scripts or event handlers declared directly within the HTML:
Content-Security-Policy:
script-src 'self' 'unsafe-inline'
This policy restricts external scripts to the current origin, but it re-enables a mechanism that accounts for a significant proportion of the classic XSS attack surface. An HTML injection can then become directly executable again if it allows the insertion of a JavaScript block or attribute that is compatible with the directive actually being applied.
Migrating to a strict CSP generally involves moving inline scripts to dedicated files or authorising them individually using nonces or hashes. Inline event handlers should be replaced by JavaScript listeners wherever possible.
However, one should avoid automatically flagging every occurrence of ‘unsafe-inline’ as a critical vulnerability. With correctly used nonces or hashes – and even more so in a CSP3 policy with strict-dynamic – the value may be ignored by modern browsers whilst serving as a fallback for older clients. The analysis must focus on the actual behaviour.
Excessive reliance on unsafe-eval
'unsafe-eval' is often introduced because a legacy framework, a template engine, a development tool or a dependency still uses eval(), Function() or a similar primitive.
Content-Security-Policy:
script-src 'self' 'unsafe-eval'
The problem lies less in the presence of the keyword than in the operations that the code is subsequently able to perform. If user-controlled data reaches a dynamic evaluation function, CSP will no longer block execution on the basis of this restriction.
Teams should therefore treat ‘unsafe-eval’ as an exception requiring justification. A dependency that uses it only during development should not impose this value on the production policy. Where there is a need for WebAssembly without the need for general JavaScript evaluation, ‘wasm-unsafe-eval’ allows for finer-grained control.
Wildcards and overly broad domain allowlists
A policy such as:
Content-Security-Policy:
script-src “self”
https://www.googletagmanager.com
https://cdn.jsdelivr.net
https://cdnjs.cloudflare.com
https://*.marketing.example
may seem reasonable because it does not directly authorise just any domain. However, every source added becomes part of the application’s trust boundary.
The issue is particularly significant for shared domains, platforms that host user-generated content, services capable of dynamically loading other scripts, and subdomains covered by wildcards. A tag manager is, by its very nature, sensitive: its function is to trigger or load additional code. Allowing it in script-src is therefore tantamount to delegating part of the execution decision to that service and its configuration.
A robust allowlist must be kept to a minimum, regularly reassessed and built based on an understanding of the features actually available on each source, not merely on the domain’s reputation.
Excessive confidence in ‘self’
'self' simplifies policies, but assumes that the application’s origin constitutes a consistent trust boundary.
This assumption becomes tenuous when the same origin serves files uploaded by users, content generated from untrusted inputs, or resources provided by multiple applications with different levels of trust.
For example:
Content-Security-Policy:
script-src 'self'
does not protect against a resource controlled by an attacker if that resource can be served from a URL such as:
https://app.example.com/uploads/user-controlled.js
and loaded in a JavaScript-compatible context. The best architectural defence is to isolate user-generated content on an origin that is not trusted by the main application’s policy.
Incomplete policy
Some policies define only script-src and assume that the rest of the application is covered:
Content-Security-Policy:
script-src 'self'
This configuration provides no fallback for the other retrieval directives. A basic configuration such as:
Content-Security-Policy:
default-src 'none';
script-src 'self';
img-src 'self';
style-src 'self'
makes the intentions much clearer.
However, it is important to distinguish between directives that inherit from default-src and those that must be defined separately. A policy may be very restrictive regarding resources whilst omitting base-uri, form-action or frame-ancestors, leaving open possibilities for abuse that do not necessarily rely on the execution of JavaScript.
Explicit absence of object-src and base-uri
In a modern application, object-src ‘none’ and base-uri ‘none’ are often good default choices when the corresponding features are not in use.
The absence of object-src must be interpreted with caution: if default-src ‘none’ is already defined, objects remain blocked by the fallback mechanism. The main benefit of an explicit directive is to formalise and document the security decision.
base-uri, on the other hand, is independent of default-src. Omitting it may allow an HTML injection to influence the resolution of relative URLs if the application contains relevant references and if the rest of the CSP allows this to be exploited.
Static, predictable or incorrectly assigned nonces
A correctly generated nonce can provide a very robust foundation. However, weak implementations negate much of its benefit.
A static value:
Content-Security-Policy:
script-src 'nonce-production123'
or a value derived from a predictable timestamp does not possess the properties expected of a ‘number used once’.
But reuse is not the only problem. A nonce may be random and renewed with every response, yet still be mistakenly assigned to content that can be manipulated by a user. For example, a rendering engine that systematically adds the nonce to all <script> elements of a controllable HTML component may mark the script injected by the attacker as trustworthy.
The review must therefore cover the generation, renewal, any storage and, above all, the logic governing the insertion of the nonce into the document.
CSP deployed in Report-Only mode only
Sometimes a scanner or a quick review may detect the presence of a CSP header, even though it is solely:
Content-Security-Policy-Report-Only: ...
This policy does not prevent any actions under CSP. It is useful for preparing a deployment and detecting violations, but must not be presented as active protection when no enforcement policy exists.
Another common situation is having a strict Report-Only policy alongside an older, permissive policy that is currently in force. The pentester must always distinguish between the two and assess the actual level of protection, not the target configuration envisaged by the team.
Inconsistent deployment depending on the routes
An application may enforce a strict policy on /, /account or /admin, but overlook error pages, legacy routes, support pages or a sub-application built using a different stack.
This inconsistency becomes particularly significant if an injection vulnerability exists precisely on one of the pages lacking a policy or protected by a different CSP.
A CSP audit must therefore cover a representative sample of routes and authentication contexts. The presence of a correct header on the home page does not prove that the entire application shares the same protection.
Compromise on compatibility with older browsers
Modern policies can combine several expressions to provide a fallback for older browsers. A migration policy might, for example, contain:
Content-Security-Policy:
script-src 'unsafe-inline' https: 'nonce-RANDOM' 'strict-dynamic'
In a CSP3 browser, the effective model is based on the nonce and strict-dynamic, whilst an older browser may interpret only part of the policy and end up with a more permissive model.
This difference is not necessarily a vulnerability if older browsers are no longer supported or if the risk has been accepted. However, it must be understood and documented. A policy must be assessed based on the clients that are actually exposed and the behaviours they implement.
Common Techniques for Bypassing Content Security Policy
A CSP bypass should not be confused with an XSS vulnerability. In most scenarios, the attacker already has a primitive at their disposal: HTML injection, DOM XSS, control over an attribute, file upload or the ability to influence data manipulated by JavaScript. CSP prevents the most direct exploitation of this primitive; bypassing it therefore involves identifying a method that remains compatible with the trust model defined by the policy.
An analysis of a bypass must therefore answer four questions. What is the initial primitive? Which operation does the CSP block? Which resource or behaviour is still considered trustworthy? How does the initial primitive allow this trust to be circumvented?
Exploiting a policy allowing unsafe-inline
Let us consider an application that returns:
HTTP/1.1 200 OK
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline'
Content-Type: text/html
A search feature re-injects the requested term into the response without correct context-sensitive encoding:
<p>Results for: USER_INPUT</p>
The attacker observes that they can inject HTML. With a script-src ‘self’ policy and no other exceptions, an inline event handler would normally be blocked. Here, the presence of ‘unsafe-inline’ may re-enable this primitive.
A demonstration payload such as:
<img src="invalid" onerror="alert(document.domain)">
can then be executed if script-src-attr or another more specific mechanism does not prohibit it.
The browser has not been bypassed in the sense of a vulnerability in its CSP engine. It is applying the policy that explicitly instructs it to allow this behaviour. The weakness stems from the fact that the defence-in-depth mechanism intended to limit injection has been neutralised by an exception that is too broad.
The fix involves removing the reliance on inline scripts and event handlers, using nonces or hashes for legitimate blocks, and, above all, correcting the injection at its source. Removing ‘unsafe-inline’ does not make unencoded HTML rendering acceptable.
Trusted third-party domain and JSONP endpoint
Historical CSPs are often based on a domain allowlist:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://api.example.test
An attempt to load directly from https://attacker.example/payload.js is blocked. The auditor then examines what api.example.test, which is already authorised, can actually serve.
Suppose this domain still exposes a JSONP endpoint:
GET /legacy/search?callback=displayResult HTTP/1.1
Host: api.example.test
with a response:
displayResult({"result":"example"});
JSONP was designed to produce JavaScript that can be loaded into a <script> element. If the callback parameter is insufficiently validated, the user can manipulate the structure of the returned code.
If the main application also has an HTML injection vulnerability that allows the insertion of a <script src="..."> element, the auditor can test whether the authorised JSONP endpoint can be hijacked to produce controlled JavaScript.
The chain of events is as follows: the injection allows a script tag to be added; the CSP blocks an origin controlled by the attacker; the JSONP domain is already approved; the endpoint for this domain generates JavaScript from a user parameter; the response is therefore loaded from a source that the browser considers legitimate.
JSONP is not inherently a CSP bypass. The problem arises because an approved source is itself capable of transforming user input into an executable resource. Remediation involves removing legacy JSONP endpoints where possible, reducing allowlists, and prioritising a Strict CSP based on nonces or hashes rather than on domain reputation alone.
Bypassing the ‘self’ script-src restriction via an upload
Let us consider:
Content-Security-Policy:
default-src 'self';
script-src 'self'
This policy prevents scripts from being loaded from an external origin. However, the application allows users to upload files, which are then accessible at:
https://app.example.test/uploads/7f94ab/file.js
If an attacker can control the content of this file and the server serves it in a manner compatible with active loading, an HTML injection could use:
<script src="/uploads/7f94ab/file.js"></script>
The resource belongs to the same origin as the application. From the perspective of script-src ‘self’, it therefore satisfies the trust model.
However, one must avoid treating every upload as exploitable. The server may enforce an incompatible Content-Type, add X-Content-Type-Options: nosniff, force Content-Disposition: attachment, transform the file, or serve it from a different origin. The auditor must verify the actual behaviour of the browser and the response.
The most robust defence is to isolate user-generated content on an origin that lies outside the main application’s trust domain, and then to supplement this separation with correct MIME types, `nosniff`, appropriate download policies and content validation.
Bypassing a weak or misused nonce
A policy based on a nonce may initially appear robust:
Content-Security-Policy:
script-src 'nonce-static123'
If the same value is reused in all responses, an attacker who knows it could potentially use it in an injection that is compatible with the application’s rendering model.
Another implementation might generate:
const nonce = Date.now().toString();
The problem here is the predictability of the value, not the CSP syntax.
A more subtle case arises when the nonce is perfectly random but applied to content that can be manipulated. Let’s imagine a template component that automatically generates:
<script nonce="RANDOM">
loadWidget("USER_CONTROLLED_VALUE");
</script>
If `USER_CONTROLLED_VALUE` is re-injected into a JavaScript context without appropriate encoding, the nonce specifically authorises the block containing the dangerous data. The attacker does not need to know or guess the value: the server has already marked the vulnerable script as trustworthy.
An audit of nonces must therefore check their renewal and entropy, as well as the templates and components to which they are assigned. Remediation involves generating unique and cryptographically secure nonces, then ensuring that they are applied only to scripts whose content and parameters are under control.
strict-dynamic and vulnerable script loader
A Strict CSP may take the following form:
Content-Security-Policy:
script-src 'nonce-K7Ew8VQvYpY5...' 'strict-dynamic';
object-src 'none';
base-uri 'none'
The page loads an authorised root script:
<script nonce="K7Ew8VQvYpY5..." src="/js/loader.js"></script>
The nonce is refreshed with every response and cannot realistically be guessed. The auditor then analyses what `loader.js` does:
const params = new URLSearchParams(location.search);
const moduleUrl = params.get("module");
if (moduleUrl) {
const script = document.createElement("script");
script.src = moduleUrl;
document.head.appendChild(script);
}
The root script is legitimate and explicitly approved. With strict-dynamic, the scripts it creates programmatically can benefit from the trust propagated by this root. If `moduleUrl` accepts an arbitrary URL controlled by the user, the real weak point is the loader.
The attacker is therefore not trying to guess the nonce. They are trying to hijack a script that already has the browser’s trust.
This situation encapsulates the shift in reasoning between an allowlist-based CSP and a Strict CSP. The question is no longer simply ‘from which domains can I load JavaScript?’, but ‘which scripts are trusted roots, what capabilities do they possess, and what data can influence these capabilities?’.
The fix does not involve removing `strict-dynamic`. The loader needs to be secured. Rather than accepting a URL directly, the code can resolve an internal identifier to a closed list of modules:
const modules = {
dashboard: "/js/dashboard.js",
profile: "/js/profile.js"
};
const moduleUrl = modules[userChoice];
if (moduleUrl) {
const script = document.createElement("script");
script.src = moduleUrl;
document.head.appendChild(script);
}
The script remains capable of dynamically loading the necessary components, but the user no longer directly chooses the executable destination.
Gadget script, trusted framework and DOM clobbering
A modern bypass does not always require loading a new script from a domain controlled by the attacker. Code that has already been authorised may itself provide the necessary primitive.
Consider a legitimate script that iterates through the DOM:
document.querySelectorAll("[data-module]").forEach(element => {
const script = document.createElement("script");
script.src = element.dataset.module;
document.head.appendChild(script);
});
This component is authorised by a nonce. Meanwhile, an HTML injection allows a user to insert:
<div data-module="USER_CONTROLLED_VALUE"></div>
The injected data contains no inline JavaScript. It is the trusted code itself that reads the DOM and converts the attribute into a script URL. If the value can point to an unintended resource, the script becomes an exploitable gadget.
Older AngularJS applications provided particularly well-known historical examples of this principle: injected content or expressions could be interpreted by a framework that was already authorised, serving as a reminder that a policy cannot assume all behaviour of a trusted script to be automatically safe.
DOM clobbering is based on a similar idea. By injecting elements with certain `id` or `name` attributes, an attacker can sometimes influence the resolution of properties that legitimate code treats as internal objects or values. If this value ends up in a sensitive sink, a seemingly limited HTML primitive can become part of an exploitation chain.
The remediation is to prevent trusted code from directly converting values derived from the DOM into sensitive resources or operations. References must be validated, resolved to internal identifiers and decoupled as far as possible from user-controllable content.
HTML injection without JavaScript execution, but with an impact via base-uri or form-action
Not all vulnerabilities resulting from an incomplete CSP should be classified as JavaScript bypasses.
Let’s imagine an HTML injection on a page protected by a robust script-src. The injected scripts are correctly blocked. However, the policy contains neither base-uri nor form-action.
If the attacker can inject a tag:
<base href="https://attacker.example/">
and the page subsequently contains relative URLs whose destinations are compatible with the rest of the policy, the attacker can influence their resolution. Similarly, the injection or modification of a form may become relevant if no form-action restriction prevents a submission to another origin.
These scenarios do not necessarily demonstrate JavaScript execution despite CSP. They highlight another significant limitation: an HTML injection retains security implications even when script-src fulfils its role correctly. An analysis of CSP must therefore not be reduced to the question ‘can I trigger `alert(1)?’.
DOM XSS, CSP and Trusted Types: Understanding the Differences Between these Mechanisms
A DOM XSS does not automatically constitute a CSP bypass. Let’s consider:
output.innerHTML = location.hash.substring(1);
This statement creates a dangerous sink. A strict CSP may, however, prevent certain forms of JavaScript execution triggered by the injected HTML.
The auditor must therefore analyse what happens after the injection: can the content modify a structure read by a trusted script? Influence a loader? Introduce an attribute interpreted by a framework? Trigger a navigation or a form? The mere presence of `innerHTML` establishes a DOM XSS attack surface, but not automatically a CSP bypass chain.
Trusted Types reinforces this separation by directly controlling certain sinks. When require-trusted-types-for 'script' is active, an assignment of a raw string to `innerHTML` may be rejected. The analysis then shifts to the Trusted Types policies and the way in which they construct trusted values.
A policy that actually sanitises the input reduces the attack surface. Conversely, a policy that returns the value as-is creates a new vulnerability. Trusted Types therefore primarily helps to make sensitive conversions explicit and auditable; it does not replace proper sanitisation logic.
Penetration Testing Methodology of a Content Security Policy
Auditing a CSP does not simply involve copying its value into an automated tool and searching for a few keywords deemed to be dangerous. A thorough analysis seeks to reconstruct the policy actually enforced by the browser, to identify the resources and scripts that constitute the trust boundary, and then to determine whether data controlled by an attacker can reach any of these authorised paths.
A CSP may be open to improvement without being exploitable in the context of the application. Conversely, a policy that appears very strict on the face of it may be circumventable due to specific behaviour in the code. The methodology must therefore start with the raw configuration and progress to a conclusion of demonstrated exploitability.
Compile the policies on the relevant pages
The first mistake would be to analyse only the homepage. An application may return different policies depending on the route, the authentication status, the response type or the infrastructure component that generated the page.
An auditor therefore collects headers from the main areas of the application: public pages, authentication, user area, administrative areas, support pages, upload features, error pages and any legacy sub-applications.
A response may, for example, contain:
GET /account HTTP/1.1
Host: app.example.test
Cookie: session=...
HTTP/1.1 200 OK
Content-Security-Policy:
default-src 'none';
script-src 'nonce-Q7jJ8...' 'strict-dynamic';
style-src 'self';
img-src 'self';
object-src 'none';
base-uri 'none';
form-action 'self'
Content-Type: text/html
The same checks must be repeated on pages generated by other components. An error route originating from a reverse proxy or an old page served by a separate backend may have a much more permissive policy.
The <meta http-equiv=‘Content-Security-Policy’> tag must also be checked where it is used. Its position in the document and its limitations must be taken into account.
Understanding enforcement and ‘report-only’ policies
The auditor identifies Content-Security-Policy: … and Content-Security-Policy-Report-Only: … separately.
A common scenario is to find an older, permissive policy in enforcement mode, whilst a much stricter policy is being tested in Report-Only mode:
Content-Security-Policy:
script-src 'self' 'unsafe-inline'
Content-Security-Policy-Report-Only:
script-src 'nonce-RANDOM' 'strict-dynamic';
object-src 'none';
base-uri 'none'
The second policy likely describes the migration target, but it does not yet protect the page. The level of security must be assessed based on the policies that are actually enforced.
This distinction is also useful for understanding the deployment cycle. An application that remains for months with a CSP set exclusively to ‘Report-Only’ has instrumentation in place, not an active barrier.
Rebuilding the effective policy and fallbacks
A policy must be interpreted directive by directive. The auditor identifies the values actually used for scripts, styles, images, connections, workers, frames and other capabilities.
For a <script> element, one must examine script-src-elem, script-src and then default-src, depending on the directives present. For an inline event handler, script-src-attr may take precedence. For workers, the fallback chain may go via worker-src, child-src, script-src and then default-src.
Conversely, base-uri, form-action and frame-ancestors must be examined independently because they do not automatically fall back to default-src.
When multiple CSP headers are present, each policy in enforcement mode must be satisfied. The auditor must therefore not merge the allowlists to form a policy that is more permissive than the one actually applied.
Identify the script-src trust model
Once the policy has been understood, the auditor determines how scripts are authorised.
A policy such as:
script-src 'self' https://cdn.example.test https://vendor.example.test
relies primarily on an allowlist of origins.
A policy such as:
script-src 'nonce-RANDOM'
trusts individually marked scripts.
A policy such as:
script-src 'nonce-RANDOM' 'strict-dynamic'
establishes roots of trust capable of propagating that trust to certain dynamically generated scripts.
Finally, a static application may use hashes, possibly combined with strict-dynamic.
The type of policy determines the next steps in the audit. In an allowlist, the focus is on authorised sources. With nonces, the generation and assignment must be analysed. With strict-dynamic, root scripts and their loaders become the priority.
Search for exceptions that affect execution options
The auditor then examines the settings that may reintroduce dangerous primitives.
‘unsafe-inline’ must be interpreted in conjunction with nonces, hashes, strict-dynamic and script-src-attr. The mere presence of the keyword is not sufficient to draw a conclusion.
'unsafe-eval' warrants particular attention because it re-enables the evaluation of code from strings. The JavaScript code must therefore be checked for uses of eval(), Function(), some timer calls constructed using a string, and library APIs capable of interpreting expressions.
'wasm-unsafe-eval' must be distinguished from ‘unsafe-eval’ in order to understand whether the application simply requires WebAssembly compilation or whether it has re-enabled a much broader evaluation surface.
‘unsafe-hashes’ may indicate a migration that retains certain event handlers inline. It is therefore necessary to examine the handlers that are actually permitted and determine whether their behaviour can be manipulated.
Testing the generation and use of nonces
When a nonce is used, the auditor makes several independent requests and compares the values.
Response 1:
script-src 'nonce-xF7QW2mJ0kY9...'
Response 2:
script-src 'nonce-By9P1tAZ3M6c...'
Response 3:
script-src 'nonce-Lh4K8dQw2NzR...'
Correct behaviour should show a new nonce in every response, with values that have no apparent predictable pattern. Consistent reuse or a sequence based on a counter, timestamp or short identifier is a red flag.
However, the analysis must not stop at the values themselves. The HTML must be inspected to identify the scripts that carry the nonce. In particular, the auditor should look for blocks whose content or parameters include user data, components that dynamically create scripts, and templates that may copy the nonce onto a partially controlled element.
Where a code review is possible, the generation function must be verified to confirm the use of a cryptographically secure source and the absence of reuse between responses.
Analyse the hashes and the scripts they authorise
A CSP may contain:
script-src 'sha256-YWJjZGVm...' 'strict-dynamic'
The auditor then identifies which block or file matches the fingerprint. The aim is to understand the role of the authorised script: does it initialise the application, load a bundle, dynamically create other scripts, or read parameters from the URL or the DOM?
The hash ensures that the approved content matches a specific fingerprint. It does not guarantee that this content is free from exploitable behaviour. A script that is perfectly authenticated by its hash can still become a gadget if it transforms untrusted data into a sensitive operation.
Analyse the domains allowed by the allowlists
In a host-based CSP, each source must be treated as an extension of the trust boundary.
The auditor searches these domains for features that could be used to serve manipulable content: JSONP endpoints, upload areas, multi-tenant CDNs, buckets, dynamic JavaScript generators, staging subdomains, tag management services or platforms where another client can publish a resource.
Wildcards require a broader scope of analysis. An expression such as https://*.example.test brings to light subdomains that have been overlooked, delegated or acquired via an external service.
However, the analysis must remain contextual. The presence of a third-party domain in script-src is not automatically a vulnerability. It must be demonstrated that this domain can serve a controllable resource in a format and at a URL that actually satisfy the CSP source.
Analysing paths and redirects where they influence trust
Some policies attempt to restrict trust to a single path:
script-src https://cdn.example.test/static/js/
The auditor checks the semantics of the match and the behaviour of redirects. CSP has specific rules for paths, and the path component should not be regarded as a boundary as strict as a distinct origin, particularly when a redirect occurs.
You must also look for endpoints capable of redirecting from an authorised path to another resource. Depending on the scenario and the directive, the browser’s actual behaviour must be validated rather than assumed.
This step is particularly important when the development team believes it has ‘secured’ a broad origin by authorising only a subdirectory.
Search for dynamically loaded scripts
This step becomes crucial with strict-dynamic. The auditor identifies root scripts containing a nonce or corresponding to a hash, then looks for the mechanisms that allow them to create other scripts.
Constructs such as document.createElement(‘script’) and script.src = value are particularly important. The issue is not merely knowing that a script is created, but identifying the origin of value.
Logic such as:
const module = new URLSearchParams(location.search).get("module");
const script = document.createElement("script");
script.src = module;
document.head.appendChild(script);
constitutes a strong indicator if no strict validation is in place.
Module loaders, plugin managers, bundling systems and dynamic component libraries must be analysed in the same way. With a Strict CSP, this process of reviewing trusted code is often far more relevant than a conventional search for authorised external domains.
Search for script gadgets and controllable data streams
Beyond the obvious loaders, the pentester looks for scenarios where trusted code reads data from the environment and then uses it in a sensitive operation.
Common sources include location.search, location.hash, postMessage, localStorage, sessionStorage, data-* attributes, DOM fields or API responses re-injected on the client-side.
Data is not dangerous simply because it comes from location.hash. It becomes significant when it reaches a sink: the creation of a script, the generation of HTML, navigation, the selection of a template, a dynamic function call, or any other primitive capable of having a security impact.
This tracking involves starting from the source, examining the successive transformations, and then identifying the sink reached. It can be presented entirely in textual form: the data is read from the URL fragment, copied into a DOM attribute, and then consumed by an authorised script that uses this attribute as a module URL.
Mapping DOM sinks
Operations capable of interpreting controlled content must be identified, in particular:
element.innerHTML = value;
element.outerHTML = value;
element.insertAdjacentHTML("beforeend", value);
document.write(value);
Equivalent framework APIs must also be examined, such as mechanisms that explicitly allow the injection of raw HTML.
The aim is not to automatically classify every sink as a bypass. An assignment to innerHTML can create HTML without the injected JavaScript executing under a strict CSP. The pentester must continue the analysis: can the content influence a gadget script, modify an attribute read by a loader, create a useful navigation path or achieve another authorised capability?
This distinction prevents confusion between a potential DOM XSS, an HTML injection and an actually demonstrated CSP bypass.
Check Trusted Types when enabled
If the policy contains:
require-trusted-types-for 'script'
the auditor looks for calls to:
trustedTypes.createPolicy(...)
and examines the creation functions actually used, such as createHTML, createScript or createScriptURL.
A policy that passes data through a correctly configured sanitiser can significantly reduce the attack surface. A policy that simply returns the input:
trustedTypes.createPolicy("appPolicy", {
createHTML: input => input
});
does not provide the same level of protection.
The `trusted-types` directive must also be examined to determine which policies can be created. The more the application restricts these creation points, the simpler the review becomes and the more difficult it is for a dependency to unexpectedly create a permissive policy.
Analysing capabilities other than JavaScript execution
A CSP can prevent any injected JavaScript from executing whilst still allowing a significant impact to remain.
The auditor therefore examines base-uri, form-action, frame-ancestors, img-src, connect-src, frame-src and other relevant directives depending on the functionality.
An HTML injection, for example, can alter a form even if scripts are blocked. If form-action is missing, it must be checked whether a submission to an external origin is possible. If base-uri is missing, the auditor can test the impact of a <base> tag on relative URLs. If frame-ancestors is not defined, resistance to clickjacking must be assessed separately.
This approach provides a more accurate picture of the impact of an injection than simply testing for an alert(1).
Use DevTools to validate assumptions
Browser development tools are essential for monitoring the actual CSP. The console usually indicates which directive has blocked an operation, whilst the Network panel allows you to verify the headers received, any redirects, the resources actually loaded, and the responses from the test endpoints.
When a payload is blocked, the browser’s message often makes it possible to distinguish whether the block was caused by the script-src-elem, script-src-attr, connect-src or another directive. This information is particularly useful when multiple policies or fallbacks make interpreting the header less intuitive.
However, the absence of an error message does not prove that a policy is secure. It merely means that the behaviour being tested did not result in any observable violation. The audit must continue to be guided by the trust model and the application code.
Use CSP Evaluator
CSP Evaluator can speed up the identification of known weak configurations, flag dangerous allowlists and provide an initial assessment of a policy.
However, it is not familiar with all domains, all endpoints or the specific behaviours of application code. A policy may generate no major warnings whilst containing a vulnerable loader. Conversely, a configuration flagged as needing improvement may not lead to exploitation in a real-world context.
The tool should therefore be used to speed up the review process and not as a substitute for manual analysis.
Check the CSP reporting
When reporting is enabled, the auditor checks that the endpoint is correctly declared and that reports are actually being received.
A modern configuration might look like this:
Reporting-Endpoints:
csp-endpoint="https://reports.example.test/csp"
Content-Security-Policy:
default-src 'self';
report-to csp-endpoint
Depending on compatibility requirements, report-uri can be retained temporarily alongside this.
The collection endpoint must be protected against abuse. Reports are data received from clients, and certain properties may contain values controlled by an attacker. Systems that display or index them must apply the same principles of validation, encoding and volume limitation as for any other untrusted input.
How to Build a Robust Content Security Policy (CSP)?
A good CSP should not be conceived as a list of domains accumulated as and when required. It must be based on the capabilities actually required by the application and minimise implicit trust as much as possible.
For modern applications, the most robust strategy is generally to use a strict policy for JavaScript, based on nonces or hashes, and then to explicitly define the other categories of resources. Whilst this approach is not always immediately applicable to legacy applications, it represents a sounder migration target than an allowlist that grows indefinitely.
Start with the actual functional requirements
Before drafting the policy, the team must identify the resources actually used by the application: application scripts, APIs, images, fonts, workers, frames, media, forms and external integrations.
The principle of least privilege then involves denying access to anything that is not necessary and subsequently re-authorising the necessary capabilities. A starting point might be:
Content-Security-Policy:
default-src 'none'
This value alone does not constitute a complete policy. It simply forces the team to explicitly state the necessary resources rather than relying on implicit authorisations.
Directives that do not have a fallback to default-src, notably base-uri, form-action and frame-ancestors, must be added separately according to the expected behaviour.
Prioritise nonces or hashes for JavaScript
A historical allowlist might take the following form:
script-src 'self'
https://cdn1.example
https://cdn2.example
https://vendor.example
This model delegates trust to several entire origins. A policy based on a nonce:
script-src 'nonce-RANDOM'
therefore authorises scripts marked by the application for the current response.
When authorised scripts need to dynamically load other scripts, strict-dynamic can be used:
script-src 'nonce-RANDOM' 'strict-dynamic'
This approach reduces reliance on third-party domains, but requires root scripts to be treated as security components. Their code must be carefully reviewed, particularly when they dynamically construct URLs or <script> elements.
For static applications, hashes may be a suitable alternative when inline scripts change infrequently.
Explicitly prohibit inline event handlers wherever possible
A modern policy can supplement script-src with:
script-src-attr 'none'
This directive disables inline event handlers and facilitates a clear separation between HTML structure and JavaScript logic.
The code:
<button onclick="save()">Save</button>
can be replaced by:
button.addEventListener("click", save);
This migration also simplifies the use of nonces and reduces the need for compatibility flags such as ‘unsafe-inline’ or ‘unsafe-hashes’.
Phase out unsafe-inline and unsafe-eval
Historical exceptions should be subject to a phasing-out plan rather than becoming permanent through inertia.
Legitimate inline scripts can be moved, associated with a nonce or covered by a hash. Inline event handlers can be replaced by listeners. Dependencies requiring eval() must be identified to determine whether this requirement is actually necessary in production.
The removal of ‘unsafe-eval’ may require changes to the build process or framework. It is often more realistic to treat this as a migration project rather than abruptly blocking the application. The aim, however, remains to gradually reduce the number of mechanisms capable of transforming a string into executable code.
Minimise third-party domains
Every domain included in a sensitive directive must be justifiable.
For script-src, this requirement is particularly stringent. The team must understand whether the domain is dedicated to the organisation, whether it is shared, whether users can upload files to it, whether it loads additional scripts itself, and whether integration is necessary on all pages.
Tag management services, A/B testing, advanced analytics and external widgets must be treated as components of the attack surface. Where they are necessary, their scope can sometimes be limited to certain pages or isolated in less privileged contexts.
The aim is not to block all third parties, but never to confuse a ‘known provider’ with a ‘CSP source that is secure by definition’.
Isolate user-controlled content
Ideally, files uploaded by users should not share the same origin from which the application loads its trusted scripts.
A dedicated architecture, for example:
https://uploads.example-cdn.test/
allows these resources to be removed from the scope covered by the main application’s ‘self’.
This separation must be complemented by consistent MIME types, X-Content-Type-Options: nosniff, appropriate download policies, content restrictions and, where necessary, antivirus or content transformation checks.
Origin isolation is a particularly robust measure as it simplifies the trust model: the browser can continue to treat the application origin as sensitive without automatically including all user-generated content.
Use base-uri, form-action, frame-ancestors and object-src explicitly
A modern policy should not focus exclusively on JavaScript.
When the application does not use <base>: base-uri `none` is a straightforward choice.
When forms must only be submitted to the application: form-action `self` limits the possible destinations.
If no framing is required: frame-ancestors ‘none’limits clickjacking.
Finally, for modern applications that do not use content controlled by <object> or <embed>: object-src ‘none’explicitly removes this attack surface.
These directives make the policy more readable and prevent unnecessary capabilities from reappearing should there be a future change to default-src.
Adopt Trusted Types where the architecture allows
Trusted Types can complement a Strict CSP by reducing direct assignments of strings to certain sensitive DOM sinks.
A migration strategy might begin with monitoring to identify incompatible operations, then gradually enable the following:
require-trusted-types-for 'script';
trusted-types appPolicy
Legitimate operations are grouped behind audited policies that actually sanitise or validate inputs before producing trusted objects.
One must resist the temptation to create a permissive ‘default policy’ that would accept everything simply to eliminate errors. A policy that blindly transforms a string into TrustedHTML or TrustedScriptURL significantly reduces the benefit of the mechanism.
Trusted Types is particularly useful in rich applications where it is difficult to immediately remove all dangerous DOM sinks. It allows the review to focus on a small number of explicit conversions.
Roll out the policy gradually using Report-Only
A strict CSP introduced abruptly to an existing application risks blocking legitimate functionality. Report-Only mode allows you to prepare for the migration without disrupting production.
A team can start by:
Content-Security-Policy-Report-Only:
default-src 'none';
script-src 'nonce-RANDOM' 'strict-dynamic';
object-src 'none';
base-uri 'none';
report-to csp-endpoint
The violations observed help to identify overlooked dependencies and sections of code that require adaptation.
The policy must then be strengthened and migrated to Content-Security-Policy once its behaviour is sufficiently understood. A common mistake is to remain in Report-Only mode indefinitely: at this stage, CSP generates data but does not fulfil its enforcement role.
Monitor breaches without turning reporting into noise
CSP reports can reveal unexpected resources, regressions, new integrations, browser extensions that inject content, or certain exploitation attempts.
However, they also generate noise. A reporting infrastructure must therefore aggregate, deduplicate and prioritise events rather than generate an alert for every individual violation.
Trends are often more useful than isolated events: the appearance of a new blocked domain following a deployment, a sudden increase in violations of a policy, the repetition of the same resource across several pages, or differing behaviour depending on the browser.
The data received should be treated as unreliable and handled accordingly within the monitoring interface.
Ensure the policy is maintained over time
A CSP that is effective on the day it is deployed may become permissive a few months later if each new integration adds an exception without removing the old ones.
The policy must therefore form part of the application’s lifecycle. Periodic reviews must check which third-party domains are still required, nonces and hashes, recurring violations, directives that have become obsolete or unused, as well as changes to the trust code.
Significant front-end changes, framework migrations, new loaders, changes to CDNs and new upload features must trigger a review of the CSP trust model.
The same principle applies to supported browsers. Features such as Trusted Types or certain CSP3 directives are evolving in terms of compatibility; the policy must be designed with full knowledge of the actual client base and the chosen backward compatibility strategy.
Conclusion
Content Security Policy is one of the most powerful defence-in-depth mechanisms available on the browser side, but its effectiveness depends entirely on how trust is defined.
A long policy is not necessarily a robust one. A CSP based on a large allowlist may grant trust to many domains over which a team does not have complete control. Conversely, a relatively short policy based on correctly generated nonces, hashes or strict-dynamic can significantly reduce the execution surface.
For an attacker or auditor, analysing a CSP is therefore less about searching for a ‘bad’ keyword than about reconstructing the entire trust model. Which scripts are authorised? Why are they authorised? Can they load other scripts? What controllable data influences these loaders? Which domains are considered trustworthy? Can these domains host user-generated content? Are nonces correctly generated and assigned? Do gadget scripts allow a limited HTML injection to be turned into an executable operation? Are forms, frames, workers, uploads and basic URLs also properly controlled?
It is by answering these questions that it becomes possible to determine whether a CSP provides a genuine security barrier or whether it merely makes exploiting an existing vulnerability slightly more difficult.
On the defensive side, the aim must be to reduce implicit trust. This involves the gradual adoption of a Strict CSP, managing nonces and hashes, the correct use of strict-dynamic, minimising third-party dependencies, isolating untrusted content, removing legacy exceptions and, where compatible with the application, using Trusted Types to better control sensitive DOM sinks.
CSP is no substitute for secure development or for preventing XSS at source. When properly designed, however, it provides an additional layer capable of transforming many potentially critical client-side scenarios into exploitation chains that are significantly more difficult, or even impossible, to carry out under the intended conditions.
References
- W3C, Content Security Policy Level 3 : https://www.w3.org/TR/CSP3/
- MDN Web Docs, Content Security Policy (CSP) : https://developer.mozilla.org/fr/docs/Web/HTTP/Guides/CSP
- MDN Web Docs, référence de l’en-tête Content-Security-Policy : https://developer.mozilla.org/fr/docs/Web/HTTP/Reference/Headers/Content-Security-Policy