Security Headers: CSP, COOP, COEP & Fetch Metadata

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Struts 7.4.0 (released 2026-10-02) on Apache Tomcat 11.0.26, Jakarta Servlet 6.1 and Java 21 (Struts 7 needs Java 17 or later and a Servlet 6.0 or later container). Everything was verified on 2026-10-08 and re-checked on 2026-10-10: the facts against the Core Developers Guide, the Security page, the 7.4.0 Javadoc and the struts-default.xml, default.properties and class files of the 7.4.0 jar, and every example by building it into a Maven WAR (Struts from the struts2-bom 7.4.0) and calling it with curl on Tomcat 11.0.26. The outputs shown are the outputs of those runs; nonce values and session ids are replaced by placeholders. Struts 7.4.0 ships four interceptors that make the browser enforce rules that the server cannot enforce itself: csp (Content Security Policy), coop (Cross-Origin Opener Policy), coep (Cross-Origin Embedder Policy) and fetchMetadata (a resource isolation policy over the Sec-Fetch-* request headers). All four are part of the defaultStack, so every action that uses the default stack already sends headers and applies a policy, and this page explains what they are, which of them are only reporting, and where the 7.4.0 implementation differs from what the documentation says. The running scenario is the Bookshelf of the Struts half of this section: the books action that lists titles, a remove action that changes state, a public widget endpoint that other sites may call, and a violation-report endpoint. The OGNL-side defences (allowlist, excluded classes, @StrutsParameter) are on the security hardening page of this section; the token side of cross-site request forgery is on the double-submit and tokens page.

The Four Interceptors at a Glance

The four interceptors address three different problems, and it helps to keep them apart. csp limits which scripts the browser may run on a page that Struts rendered, so it mitigates cross-site scripting (XSS) and data injection even when an attacker manages to put markup into the page. coop and coep decide how a page shares its browsing context with other origins; together they give the page "cross-origin isolation", a precondition for some powerful browser features and a mitigation for side-channel attacks such as Spectre. fetchMetadata runs on the server before the action: it reads the Sec-Fetch-Site, Sec-Fetch-Mode and Sec-Fetch-Dest headers that the browser attaches to every request and refuses requests that another website caused the browser to make.

Table 1. The four security-header interceptors of Struts 7.4.0
Interceptor Problem addressed Where it acts Default in defaultStack (observed)

csp

XSS and data injection through scripts the page did not intend to load

Response header Content-Security-Policy or Content-Security-Policy-Report-Only, plus a nonce that s:script and s:link write into the markup

Report-only; sent only when a session exists (see the nonce section)

coop

Cross-window attacks and process-wide side channels

Response header Cross-Origin-Opener-Policy

Cross-Origin-Opener-Policy: same-origin, enforced

coep

A page loading cross-origin resources that did not opt in

Response header Cross-Origin-Embedder-Policy (require-corp)

Cross-Origin-Embedder-Policy-Report-Only: require-corp, report-only

fetchMetadata

Cross-origin requests that abuse the application’s endpoints (CSRF-like, XSSI, information leaks)

Server side, before the action runs; Vary header on the Sec-Fetch-* request headers

Enforcing: a rejected request gets the result code 403

The positions in the stack matter for what the code can see. In the 7.4.0 struts-default.xml the defaultStack lists exception, alias, servletConfig, httpMethod, i18n, then csp, then (after prepare, chain, the model-driven interceptors, upload, params and conversionError) coep, coop and fetchMetadata, then validation and workflow. csp, coep and coop register a pre-result listener, so their headers are written just before the result renders; fetchMetadata decides before the action method is called.

flowchart TD R["Request with Sec-Fetch headers"] --> E["exception, alias, servletConfig, httpMethod, i18n"] E --> C["csp: nonce created, header listener registered"] C --> P["prepare, chain, model driven, upload, params, conversionError"] P --> CC["coep and coop: header listeners registered"] CC --> FM{"fetchMetadata: does the policy allow the request?"} FM -- no --> X["result code 403: needs a mapped result"] FM -- yes --> A["validation, workflow, action method"] A --> L["pre-result listeners write the CSP, COEP and COOP headers"] X --> L L --> V["Result renders the page, s:script and s:link write the nonce"]

Because the headers come from interceptors, they exist only on responses that an action produced. A static file served by the container’s default servlet, a response written by another servlet and the Tomcat 404 page for an unmapped URL carry none of them (the Bookshelf’s /assets/books.css answered with no Cross-Origin-* and no Content-Security-Policy header). Headers that must cover every response need a servlet filter; the section on headers for non-action responses below shows one.

The Bookshelf Used on This Page

The Bookshelf is the Struts application of this section: Struts 7.4.0 from the struts2-bom, the StrutsPrepareAndExecuteFilter mapped to /* (see Getting Started with Struts and the architecture page for the project layout), and one JSP under WEB-INF/views. The page uses s:script and s:link, the two tags that know about CSP nonces. The tags do not add the context path and Struts reserves /static for its own content (both are covered in the pitfalls), so the page builds its URLs with s:url and serves its own files from /assets.

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html>
<html>
<head>
<s:url var="css" value="/assets/books.css"/>
<s:url var="js" value="/assets/books.js"/>
<s:link href="%{#css}" rel="stylesheet"/>
<s:script src="%{#js}"/>
</head>
<body>
<h1>Bookshelf (Struts)</h1>
<ul>
<s:iterator value="titles"><li><s:property/></li></s:iterator>
</ul>
<s:script>document.title = 'Bookshelf';</s:script>
</body>
</html>

The books action is a plain ActionSupport with a getTitles() method; remove increments a counter and answers 204. The first configuration changes nothing: every action runs with the defaultStack of struts-default.

<struts>
    <package name="default" extends="struts-default" namespace="/">

        <action name="books" class="org.example.bookshelf.BookAction">
            <result>/WEB-INF/views/books.jsp</result>
        </action>

        <action name="remove" class="org.example.bookshelf.RemoveAction">
            <result type="httpheader"><param name="status">204</param></result>
        </action>
    </package>
</struts>

What the Defaults Produce

With the configuration above and no parameter set, the first request of a new client to books.action returns the Vary, Cross-Origin-Embedder-Policy-Report-Only and Cross-Origin-Opener-Policy headers and no Content-Security-Policy header at all. The second request, which carries the session cookie that the JSP created, returns the CSP header too.

$ curl -si -c cookies.txt http://localhost:18080/g12sechdr/books.action
HTTP/1.1 200
Vary: Sec-Fetch-Dest,Sec-Fetch-Mode,Sec-Fetch-Site,Sec-Fetch-User
Cross-Origin-Embedder-Policy-Report-Only: require-corp
Cross-Origin-Opener-Policy: same-origin
Set-Cookie: JSESSIONID=<id>; Path=/g12sechdr; HttpOnly
Content-Type: text/html;charset=UTF-8

$ curl -si -b cookies.txt http://localhost:18080/g12sechdr/books.action
HTTP/1.1 200
Vary: Sec-Fetch-Dest,Sec-Fetch-Mode,Sec-Fetch-Site,Sec-Fetch-User
Content-Security-Policy-Report-Only: object-src 'none'; script-src 'nonce-<nonce>' 'strict-dynamic' http: https:; base-uri 'none';
Cross-Origin-Embedder-Policy-Report-Only: require-corp
Cross-Origin-Opener-Policy: same-origin

Two things follow. First, only coop is enforcing out of the box; csp and coep are in report-only mode, so nothing the browser would block is blocked until enforcingMode is switched on. Second, the CSP header is missing on the first response of a client, because the default nonce store is the HTTP session and Struts writes no header without a nonce (the debug log says "Session is not active, ignoring CSP settings"). The nonce section explains how to remove the dependency on the session.

Content Security Policy

A Content Security Policy is a response header with a list of directives that tell the browser where scripts, styles, images, frames and other resources may come from and what the page may do. A page that sets a strict policy keeps an attacker who has injected markup from running that script: the injected script element has no valid nonce, so the browser refuses it. The CSP interceptor implements the nonce-based variant of that policy for Struts pages.

The Policy the Interceptor Sends

DefaultCspSettings builds the same policy on every request, and the Bookshelf output above shows it in full: four directives, no more.

Table 2. The directives of the default policy, as sent by Struts 7.4.0
Directive Value Effect in the browser

object-src

'none'

No plug-in content (object, embed, applet)

script-src

'nonce-<nonce>' 'strict-dynamic' http: https:

A script runs only if its element carries the current nonce, or was created by a script that did; the http: and https: entries are fall-backs for browsers that do not understand strict-dynamic

base-uri

'none'

A base element cannot redirect relative URLs

report-uri

The interceptor’s reportUri (with the context path prepended), only when set

Where the browser posts violation reports

The policy protects scripts, plug-ins and the base element and nothing else: it has no default-src, no style-src, no img-src, no frame-ancestors and no form-action. It is a strong defence against injected scripts, and a deliberately small one. Inline event handler attributes such as onclick="..." have no nonce and are blocked once the policy is enforced, which is the usual migration cost; move them to a script that has a nonce. The sections on CspSettingsAware and cspSettingsClassName show how to replace the policy with a wider one.

Report-Only and Enforcing Mode

CSP can run in two modes. In report-only mode the browser evaluates the policy, reports violations and blocks nothing, and Struts sends Content-Security-Policy-Report-Only. In enforcing mode the browser blocks what the policy forbids and Struts sends Content-Security-Policy. The mode is the enforcingMode parameter (default false). The documented way to set parameters on an interceptor of the stack is the dotted form on the defaultStack reference; the secure action of the Bookshelf enforces the policy and names a report endpoint.

<action name="secure" class="org.example.bookshelf.BookAction">
    <interceptor-ref name="defaultStack">
        <param name="csp.enforcingMode">true</param>
        <param name="csp.reportUri">/csp-report.action</param>
        <param name="coep.enforcingMode">true</param>
        <param name="coop.mode">same-origin-allow-popups</param>
    </interceptor-ref>
    <result>/WEB-INF/views/books.jsp</result>
</action>

Calling it with the session cookie returns the enforcing header, the report URI with the context path prepended (prependServletContext defaults to true, since 6.4.0), the enforcing COEP header and the relaxed COOP value:

$ curl -si -b cookies.txt http://localhost:18080/g12sechdr/secure.action
HTTP/1.1 200
Vary: Sec-Fetch-Dest,Sec-Fetch-Mode,Sec-Fetch-Site,Sec-Fetch-User
Content-Security-Policy: object-src 'none'; script-src 'nonce-<nonce>' 'strict-dynamic' http: https:; base-uri 'none'; report-uri /g12sechdr/csp-report.action;
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin-allow-popups

The Parameters

Table 3. Parameters of the csp interceptor in 7.4.0
Parameter Default Meaning

enforcingMode

false

true sends Content-Security-Policy, false sends the -Report-Only header

reportUri

none

URI that receives violation reports; the servlet context path is prepended unless prependServletContext is false

prependServletContext

true

Whether the context path is put in front of reportUri (since 6.4.0)

cspSettingsClassName

DefaultCspSettings

Full name of a CspSettings implementation that builds the policy (since 6.5.0)

reportTo

none

Exists on the 7.4.0 class (setReportTo) but is not in the guide’s parameter list; with reportUri also set only report-uri appeared in the header (observed)

disabled

false

Inherited from every interceptor of the stack: true makes the interceptor do nothing

Nonces in s:script and s:link

The nonce is a random value that the interceptor generates for every request: it appears in the script-src directive and the tags copy it into the markup. The s:script and s:link tags do that automatically, which the guide calls "a painless way to implement CSP in a highly secure fashion". The rendered Bookshelf page of the secure action carries the same nonce in three places, the stylesheet link, the external script and the inline script (nonce values elided):

<link rel="stylesheet" href="/g12sechdr/assets/books.css" nonce="<nonce>"/>

<script src="/g12sechdr/assets/books.js" nonce="<nonce>">
</script>
...
<script nonce="<nonce>">
document.title = 'Bookshelf';</script>

A fresh nonce is generated on every request: two calls to secure.action with the same session produced two different values (compared by hash). That is the property that makes the nonce useless to an attacker: a value read from an earlier page is no longer valid, and an attacker who can inject markup cannot know the value of the response being rendered. A plain <script> written by hand in a JSP, or by a JSTL or EL expression, carries no nonce and is blocked in enforcing mode, so every script of a Struts page that has to run belongs in an s:script tag (or is loaded by a script that has a nonce, which strict-dynamic allows). See the tags overview for the rest of the tag family and the JSP security page for escaping, the other half of the XSS defence.

Where the Nonce Lives: Session or Request

The interceptor creates the nonce and has to keep it somewhere the tags can read it when the page renders. By default that is the HTTP session, which is why the first response of a new client has no policy. Since Struts 6.8.0 the nonce can be kept in a request attribute instead, which suits stateless or clustered deployments that do not want a session created for it. The constant takes session (default) or request:

<constant name="struts.csp.nonce.source" value="request"/>

Running the Bookshelf with that constant (and session="false" on the JSP, so that nothing creates a session) returned the CSP header on the very first request, with no Set-Cookie, and a different nonce on each request. Releases before 7.4.0 shipped default.properties with the setting under the name struts.csp.nonceSource, which the framework never read; 7.4.0 honours that name too, with a deprecation warning, so an application that carried struts.csp.nonceSource=request switches to request-scoped nonces on upgrade (WW-5669 in the 7.4.0 version notes). Rename it to struts.csp.nonce.source.

Collecting Violation Reports

A policy in report-only mode is only useful if somebody reads the reports. Browsers send a violation report to the report-uri as a POST whose body is JSON and whose content type is application/csp-report. Struts 7.4.0 has the abstract CspReportAction for receiving them and a concrete DefaultCspReportAction that logs the body; the core distribution maps neither by default. Mapping the default action is one line, but the 7.4.0 action returns the result success, and with no result mapped that overwrites the 204 status that the action sets itself with a 404 ("No result defined for action …​ and result success", observed). Map a 204 result:

<action name="csp-report" class="org.apache.struts2.action.DefaultCspReportAction">
    <result name="success" type="httpheader">
        <param name="status">204</param>
    </result>
</action>

Posting a report to it with curl (the same call a browser makes) answered 204 and the server log received the body at ERROR level:

$ curl -s -o /dev/null -w "%{http_code}\n" -b cookies.txt -X POST \
    -H "Content-Type: application/csp-report" \
    -d '{"csp-report":{"document-uri":"http://localhost:18080/g12sechdr/secure.action","violated-directive":"script-src-elem","blocked-uri":"inline","disposition":"report"}}' \
    http://localhost:18080/g12sechdr/csp-report.action
204

ERROR {"csp-report":{"document-uri":"http://localhost:18080/g12sechdr/secure.action","violated-directive":"script-src-elem","blocked-uri":"inline","disposition":"report"}}

The guide says to extend CspReportAction and implement processReport(String). In 7.4.0 that method is package-private (abstract void processReport(String)), so a class outside the org.apache.struts2.action package cannot implement it: javac answers "MyReports is not abstract and does not override abstract method processReport(String)". Only DefaultCspReportAction is usable from application code, and logging at ERROR is its whole job. An application that wants to store or count the reports writes its own action that reads the body, as the Bookshelf does:

public class ViolationReportAction extends ActionSupport implements ServletRequestAware {
    private static final Logger LOG = Logger.getLogger(ViolationReportAction.class.getName());
    private static final int MAX_CHARS = 8192;

    private HttpServletRequest request;

    @Override
    public void withServletRequest(HttpServletRequest request) {
        this.request = request;
    }

    @Override
    public String execute() throws IOException {
        String type = request.getContentType();
        if ("POST".equals(request.getMethod()) && type != null
                && (type.startsWith("application/csp-report") || type.startsWith("application/reports+json"))) {
            char[] buffer = new char[MAX_CHARS + 1];
            int read;
            try (BufferedReader reader = request.getReader()) {
                read = reader.read(buffer, 0, buffer.length);
            }
            if (read > MAX_CHARS) {
                LOG.warning("CSP report discarded: larger than " + MAX_CHARS + " characters");
            } else if (read > 0) {
                LOG.warning("CSP violation: " + new String(buffer, 0, read).replaceAll("[\\r\\n]", " "));
            }
        }
        return SUCCESS;
    }
}

Three rules about the endpoint come from the 7.4.0 class and the S2-073 bulletin. The body must be read with a limit: the endpoint is reachable without authentication, because browsers post to it directly, and S2-073 (CVE-2026-73634, Moderate, fixed in Struts 6.11.0 and 7.3.0) is the unbounded read of such a report; since those releases the default action reads at most 8192 characters (the constant struts.csp.report.maxSize changes the limit, accepted range 1 to 1048576) and discards a larger report with a warning. The bulletin adds that the facility is inert until an application maps an endpoint, and that enabling CSP alone exposes none. Second, DefaultCspReportAction reads only a POST whose content type is exactly application/csp-report: with the content types application/json and application/reports+json (the type the newer Reporting API uses) it answered 204 and logged nothing (observed), which is why the Bookshelf’s own action accepts both. Third, the response must be 204; a body or a redirect is wasted on the browser.

Source: CVE-2026-73634

A Policy per Action: CspSettingsAware and cspSettingsClassName

Since Struts 6.2.0 an action can steer its own policy: implement CspSettingsAware and return a CspSettings object. The interceptor then asks the action instead of building the default policy (the interface has setters for the enforcingMode, reportUri and reportTo values). The Bookshelf’s policy action sends a wider policy, in which only the page’s own origin and one cover-image host may load anything:

public class PolicyAction extends ActionSupport implements CspSettingsAware {

    @Override
    public CspSettings getCspSettings() {
        return new CspSettings() {
            private boolean enforcing;

            @Override
            public void addCspHeaders(HttpServletRequest request, HttpServletResponse response) {
                String header = enforcing ? CSP_ENFORCE_HEADER : CSP_REPORT_HEADER;
                response.setHeader(header, "default-src 'self'; img-src 'self' https://covers.example.org");
            }

            @Override
            public void setReportUri(String reportUri) { }

            @Override
            public void setReportTo(String reportTo) { }

            @Override
            public void setEnforcingMode(boolean enforcingMode) {
                this.enforcing = enforcingMode;
            }
        };
    }
}

Calling policy.action returned the custom policy in the report-only header (the stack still said enforcingMode=false) and, because the settings object wrote its own header, no nonce:

$ curl -si -b cookies.txt http://localhost:18080/g12sechdr/policy.action
Content-Security-Policy-Report-Only: default-src 'self'; img-src 'self' https://covers.example.org
Cross-Origin-Embedder-Policy-Report-Only: require-corp
Cross-Origin-Opener-Policy: same-origin

The guide’s example of this interface declares public CspSetting getCspSettings(), a typo; the method returns CspSettings. A policy that should apply to every action is better built once: cspSettingsClassName (since 6.5.0) names a CspSettings class that the interceptor instantiates (DefaultCspSettings, the default, has a public no-argument constructor and is not final, so a subclass can keep the nonce mechanism and change the policy); the parameter was not exercised on the Bookshelf, only read from the 7.4.0 class files.

Rolling a Policy Out

A policy that was never run against the real pages will break them, so the order is the one that the web.dev guide recommends: start in report-only mode, read the reports, fix the markup, then enforce. For the Bookshelf that means the following steps.

  1. Keep csp in its default report-only mode and add csp.reportUri pointing at a report endpoint that stores reports.

  2. Replace every hand-written script and link element of the JSP files with s:script and s:link, and every inline event handler with a script that has a nonce.

  3. Watch the reports for a full business cycle (and for every browser the users use); each script-src-elem or script-src-attr report names a page that still has a script without a nonce.

  4. Set csp.enforcingMode to true, first on a package (see the stack in the next sections) and then on the whole application.

COOP and COEP: Cross-Origin Isolation

A document that shares a browsing context group, and perhaps an operating-system process, with a document of another origin is exposed to two kinds of attack: cross-window attacks (a malicious page opens the victim in a window and later navigates it to a look-alike, or abuses postMessage) and process-wide side-channel attacks such as Spectre. The two headers solve different halves of that problem and can be enabled, tested and deployed independently. Used together they make the page "cross-origin isolated", which the browser requires before it grants SharedArrayBuffer, performance.measureMemory() and the JS Self-Profiling API. The Security page recommends Cross-Origin-Embedder-Policy: require-corp together with Cross-Origin-Opener-Policy: same-origin.

The coop Interceptor

Cross-Origin-Opener-Policy lets a document ask for a new browsing context group, so that cross-origin documents cannot hold a reference to its window. The interceptor sends the header with the value of its mode parameter, and the three values the guide lists are the three it accepts.

Table 4. COOP modes of the coop interceptor
mode Effect in the browser

same-origin (default)

The document shares its group only with same-origin documents that send the same value; any other document is severed from it

same-origin-allow-popups

Like same-origin, but popups that the document opens may stay in its group (unless the popup’s own COOP prevents it): the mode for pages that open an OAuth or payment popup and need window.opener

unsafe-none

The browser default: the document shares its group with any document that does not opt in

coop is the only one of the four that is enforcing in the 7.4.0 defaultStack, so an application that opens popups across origins notices it first: a sign-in popup of another origin loses window.opener after an upgrade to Struts 6.0 or later, and the fix is coop.mode=same-origin-allow-popups (shown in the secure action above, which returned Cross-Origin-Opener-Policy: same-origin-allow-popups). A mode outside the three is not an error in 7.4.0: with coop.mode set to same-site the application deployed without a message and the action answered Cross-Origin-Opener-Policy: same-origin, the default (observed), so a typo goes unnoticed until the headers are read.

The coep Interceptor

Cross-Origin-Embedder-Policy: require-corp makes the browser refuse to load a cross-origin sub-resource (image, script, stylesheet, font, frame) unless the resource opts in with a Cross-Origin-Resource-Policy header or is fetched with CORS. It is the header that breaks pages: every third-party cover image, analytics script and font that does not send the opt-in stops loading under enforcement. That is why the interceptor defaults to the report-only header Cross-Origin-Embedder-Policy-Report-Only, and why the rollout is the one of CSP: report first, enforce later.

Table 5. Parameters of the coop, coep and fetchMetadata interceptors
Interceptor Parameter Meaning

coop

mode

One of same-origin (default), same-origin-allow-popups, unsafe-none

coop, coep, fetchMetadata

exemptedPaths

Comma-separated set of paths that opt out; empty by default (see the section on exemptions, because the 7.4.0 behaviour differs from the guide)

coep

enforcingMode

true sends Cross-Origin-Embedder-Policy, false sends the -Report-Only header; the guide says the default is false

coop, coep, fetchMetadata

disabled

true switches the interceptor off for the action or stack

A parameter overridden on the stack reference changes the stack’s own parameters for that interceptor, and the enforcingMode default of COEP shows it. The documented default is false, and the defaultStack of struts-default.xml sets enforcingMode=false explicitly, but the Java field of CoepInterceptor starts as the enforcing header. An action that overrides only coep.exemptedPaths loses the stack’s value (the interceptors page of this section states the rule: overrides are not inherited) and sent the enforcing header; naming coep.enforcingMode again restored report-only:

<action name="ov-coep" class="org.example.bookshelf.BookAction">
    <interceptor-ref name="defaultStack">
        <param name="coep.exemptedPaths">/nothing</param>
    </interceptor-ref>
    <result>/WEB-INF/views/books.jsp</result>
</action>

<action name="ov-coep2" class="org.example.bookshelf.BookAction">
    <interceptor-ref name="defaultStack">
        <param name="coep.exemptedPaths">/nothing</param>
        <param name="coep.enforcingMode">false</param>
    </interceptor-ref>
    <result>/WEB-INF/views/books.jsp</result>
</action>
$ curl -si -b cookies.txt http://localhost:18080/g12sechdr/ov-coep.action | grep Cross-Origin
Cross-Origin-Embedder-Policy: require-corp
$ curl -si -b cookies.txt http://localhost:18080/g12sechdr/ov-coep2.action | grep Cross-Origin
Cross-Origin-Embedder-Policy-Report-Only: require-corp

Read the headers of every action that overrides any parameter of coep, and repeat the ones you want to keep.

Fetch Metadata and the Resource Isolation Policy

Modern browsers attach three request headers that describe the context of a request: Sec-Fetch-Site (the relationship between the origin that caused the request and the target: same-origin, same-site, cross-site or none for a user-initiated navigation), Sec-Fetch-Mode (navigate for a top-level navigation, cors, no-cors, same-origin) and Sec-Fetch-Dest (what the response will be used as: document, image, script, iframe, empty for fetch(), and so on). A server can compare them with a policy and refuse requests that another website caused. A page that sends a forged form POST, loads an endpoint as a script (cross-site script inclusion, XSSI) or reads a JSON endpoint with fetch() carries Sec-Fetch-Site: cross-site, and none of the application’s own pages does.

The fetchMetadata interceptor (Struts 6.0 and later) applies such a policy before the action runs. It first adds the header Vary: Sec-Fetch-Dest,Sec-Fetch-Mode,Sec-Fetch-Site,Sec-Fetch-User to the response, so that caches keep the decision apart, then checks the exemptions, then asks a ResourceIsolationPolicy (the default is StrutsResourceIsolationPolicy) whether the request is allowed. If it is not, the interceptor writes a warning ("Fetch metadata rejected cross-origin request to: …​") and returns the result code 403 without calling the action.

The 403 Needs a Mapped Result

The rejection is a result code, not an HTTP status, and nothing maps it. Without a mapping Struts answers the rejected request with a 404 whose message reads "No result defined for action org.example.bookshelf.RemoveAction and result 403" (observed), which looks like a routing mistake and hides the real cause. A global result turns it into a real 403:

<package name="default" extends="struts-default" namespace="/">

    <global-results>
        <result name="403" type="httpheader">
            <param name="status">403</param>
        </result>
    </global-results>

    <!-- actions ... -->
</package>

With the global result in place, a forged cross-site fetch() to the state-changing remove action returned 403 with the Vary header and the CSP, COEP and COOP headers (the pre-result listeners still run for the 403 result) and no body, and the action method did not run:

$ curl -si -b cookies.txt -X POST \
    -H "Sec-Fetch-Site: cross-site" -H "Sec-Fetch-Mode: cors" -H "Sec-Fetch-Dest: empty" \
    http://localhost:18080/g12sechdr/remove.action
HTTP/1.1 403
Vary: Sec-Fetch-Dest,Sec-Fetch-Mode,Sec-Fetch-Site,Sec-Fetch-User
Content-Security-Policy-Report-Only: object-src 'none'; script-src 'nonce-<nonce>' 'strict-dynamic' http: https:; base-uri 'none';
Cross-Origin-Embedder-Policy-Report-Only: require-corp
Cross-Origin-Opener-Policy: same-origin
Content-Length: 0

What the 7.4.0 Policy Allows

The guide summarises the default policy as: reject when Sec-Fetch-Site is cross-site and (the mode is not navigate or the method is not GET or HEAD). The code of StrutsResourceIsolationPolicy in 7.4.0 is different from that summary in one decisive place. It allows a request when the Sec-Fetch-Site header is absent (old browsers, curl, server-to-server clients), when the site is same-origin, same-site or none, and otherwise only when the request counts as a simple top-level navigation: the mode is navigate or the method is GET (the web.dev sample that the policy is modelled on requires both), and the destination is neither embed nor object. The Bookshelf’s remove action, called with every combination, gives the following matrix:

Table 6. Fetch Metadata decision for POST and GET requests to the Bookshelf (observed on 7.4.0)
Method Sec-Fetch-Site Sec-Fetch-Mode Sec-Fetch-Dest Result

GET

cross-site

navigate

document

Allowed (204)

GET

cross-site

cors

document

Allowed (204)

GET

cross-site

no-cors

document

Allowed (204)

GET

cross-site

navigate

embed

Rejected (403 with the global result)

POST

cross-site

navigate

document

Allowed (204)

POST

cross-site

cors

empty

Rejected (403 with the global result)

POST

cross-site

no-cors

document

Rejected (403 with the global result)

POST

same-origin

cors

empty

Allowed (204)

POST

none

navigate

document

Allowed (204)

POST

none sent (curl)

none sent

none sent

Allowed (204)

The consequences are practical. A cross-site GET that has an effect on the server is let through, and so is a cross-site form POST that the browser sends as a top-level navigation (mode navigate), which is exactly the shape of a classic forged-form CSRF attack. What the 7.4.0 policy does stop is a cross-site script loading your JSON endpoint, a cross-site fetch() or XHR with a non-GET method, and embedding an action’s output as object or embed. That is a useful defence in depth for XSSI and information leaks and not a substitute for a token on state-changing actions; the double-submit page of this section ran the same forged POST against an unprotected action and had it accepted. Use a token (and SameSite cookies) for every action that changes state, and treat fetchMetadata as one more layer.

A policy of your own implements ResourceIsolationPolicy (the method is isRequestAllowed(HttpServletRequest), and the interface defines constants for the header names and values). FetchMetadataInterceptor creates StrutsResourceIsolationPolicy directly (the 7.4.0 class has no setter for the policy), so replacing it means a subclass or a custom interceptor, which is the subject of the custom interceptors page.

Exempting Endpoints and Switching Interceptors Off

All three of coop, coep and fetchMetadata take exemptedPaths, a comma-separated set that the guides describe as "opt out endpoints that are meant to serve cross-site traffic", with paths that "contain leading slashes and must be relative". A public Bookshelf widget that other sites embed is the obvious use, and the guide’s example is fetchMetadata.exemptedPaths=/path1,/path2,/path3. The Bookshelf tries it:

<action name="widget" class="org.example.bookshelf.BookAction">
    <interceptor-ref name="defaultStack">
        <param name="fetchMetadata.exemptedPaths">/widget.action</param>
        <param name="coop.exemptedPaths">/widget.action</param>
        <param name="coep.exemptedPaths">/widget.action</param>
    </interceptor-ref>
    <result>/WEB-INF/views/books.jsp</result>
</action>

It does nothing. In 7.4.0 the three interceptors compare the set with request.getContextPath() (the class files call getContextPath() and then Set.contains), not with the request path, so a cross-site POST to widget.action was still rejected and the response still carried Cross-Origin-Opener-Policy: same-origin. An exemption of the context path itself works, and exempts every action of the application: with exemptedPaths set to /g12sechdr (the context path of the scratch application) an action answered a cross-site cors POST with 200 and sent no Cross-Origin-* header. In a ROOT-context deployment the context path is the empty string, so no per-action exemption is possible at all.

What works is switching an interceptor off with the disabled parameter that every interceptor of the stack inherits, on one action or on a package. The plain action of the Bookshelf disables all four, and the response carried none of their headers, not even the Vary header. The public endpoints are better grouped in a package that has its own stack:

<action name="plain" class="org.example.bookshelf.BookAction">
    <interceptor-ref name="defaultStack">
        <param name="csp.disabled">true</param>
        <param name="coep.disabled">true</param>
        <param name="coop.disabled">true</param>
        <param name="fetchMetadata.disabled">true</param>
    </interceptor-ref>
    <result>/WEB-INF/views/books.jsp</result>
</action>

<!-- Endpoints meant to be called from other sites -->
<package name="public" extends="default" namespace="/public">
    <interceptors>
        <interceptor-stack name="publicStack">
            <interceptor-ref name="defaultStack">
                <param name="fetchMetadata.disabled">true</param>
            </interceptor-ref>
        </interceptor-stack>
    </interceptors>
    <default-interceptor-ref name="publicStack"/>
    <action name="remove" class="org.example.bookshelf.RemoveAction">
        <result type="httpheader"><param name="status">204</param></result>
    </action>
</package>

The same forged cross-site POST with mode cors was rejected for /remove.action (403) and accepted for /public/remove.action (204). A public endpoint that changes state still needs its own authentication or token; the package only removes the origin check, as with CORS, which is the server’s way of opting specific other origins in (see What is CORS?).

Headers for Responses That Are Not Actions

The interceptors act on action results only. A static file, a JSP reached directly, an error page rendered by the container and any non-Struts servlet carry none of the headers; the browser applies a policy per response, so a page that embeds an unprotected frame, or a stylesheet without the right header, is not covered. There are no Struts interceptors for the other widely used security headers (X-Content-Type-Options, X-Frame-Options or CSP frame-ancestors, Strict-Transport-Security, Referrer-Policy), so they belong in a servlet filter in front of the Struts filter (see Servlet Fundamentals for filter ordering). Tomcat has one ready-made, org.apache.catalina.filters.HttpHeaderSecurityFilter, which the Bookshelf registers before the Struts filter:

<filter>
    <filter-name>httpHeaderSecurity</filter-name>
    <filter-class>org.apache.catalina.filters.HttpHeaderSecurityFilter</filter-class>
    <init-param>
        <param-name>antiClickJackingOption</param-name>
        <param-value>SAMEORIGIN</param-value>
    </init-param>
    <async-supported>true</async-supported>
</filter>
<filter-mapping>
    <filter-name>httpHeaderSecurity</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

A request for the action and a request for the static file then both carry the filter’s headers, while only the action carries the Struts ones (Strict-Transport-Security is added by the filter only on a secure connection, so it does not show on the plain-HTTP scratch Tomcat):

$ curl -si http://localhost:18080/g12sechdr4/books.action | grep -E "^(X-|Cross|Vary)"
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Vary: Sec-Fetch-Dest,Sec-Fetch-Mode,Sec-Fetch-Site,Sec-Fetch-User
Cross-Origin-Embedder-Policy-Report-Only: require-corp
Cross-Origin-Opener-Policy: same-origin

$ curl -si http://localhost:18080/g12sechdr4/assets/books.css | grep -E "^(X-|Cross)"
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff

Pitfalls

Table 7. Pitfalls of the Struts security-header interceptors
Symptom Cause (observed or read in the 7.4.0 jar) Fix

No Content-Security-Policy header on the first response of a client

The nonce is kept in the HTTP session by default and no session exists yet

Set struts.csp.nonce.source to request, or accept that the first response is not covered; never rely on the header being present for a new visitor

CSP "is configured" but nothing is blocked

enforcingMode is false by default, so only Content-Security-Policy-Report-Only is sent

Collect the reports, then set csp.enforcingMode to true

Violation reports answered with 404, or never logged

DefaultCspReportAction returns success and no result is mapped; it also reads only Content-Type: application/csp-report

Map a success result of type httpheader with status 204; write an own action when the report arrives as application/reports+json

A subclass of CspReportAction does not compile

processReport(String) is package-private in 7.4.0

Use DefaultCspReportAction or write an own action (see the report section)

Inline scripts and onclick attributes stop working when CSP is enforced

No nonce on a hand-written script element, and attributes cannot carry one

Use s:script and move handlers into a script with a nonce

s:script or s:link points at a URL without the context path

The tags write src and href as given

Build the URL with s:url; the nonce is added either way

The application’s own file under /static answers 404

Struts serves its own static content under /static (struts.serve.static=true, struts.ui.staticContentPath=/static)

Serve application files from another path such as /assets

Cross-Origin-Embedder-Policy is enforcing although enforcingMode was never set

A parameter overridden on a defaultStack reference replaces the stack’s other parameters for that interceptor, and the Java default of coep is enforcing

Repeat coep.enforcingMode whenever any coep.* parameter is overridden, and read the headers

window.opener is null in an OAuth or payment popup

coop is enforcing with same-origin by default

Use coop.mode=same-origin-allow-popups on the pages that open popups

A mistyped coop.mode is accepted

The invalid value is swallowed and the default same-origin stays

Check the response header after every change

exemptedPaths has no effect on a single action

In 7.4.0 the set is compared with the context path, not the request path

Use disabled=true on the action or on a package stack

A rejected request gives a 404 "No result defined …​ result 403"

fetchMetadata returns the result code 403 and no result is mapped

Add a global 403 result of type httpheader

A cross-site form POST still reaches the action

The 7.4.0 policy allows navigate mode whatever the method, and any GET

Protect state-changing actions with a token and SameSite cookies; make GET side-effect free

Static files, error pages and other servlets have no security headers

The interceptors run only around actions

Add a servlet filter (for instance Tomcat’s HttpHeaderSecurityFilter) for the others

curl or a health check bypasses Fetch Metadata

The policy allows requests with no Sec-Fetch-Site header (old browsers, scripts)

Expected: Fetch Metadata is a browser-side protection, not authentication

What Changed Recently

Table 8. Security-header changes by release
Release Change

6.0.0

The Fetch Metadata, COOP and COEP interceptors (and, in the same period, the CSP interceptor with its nonce support in s:script and s:link) appear; the Security page marks the first three "since Struts 6.0.0"

6.2.0

CspSettingsAware: an action can supply its own CspSettings

6.4.0

prependServletContext parameter of the CSP interceptor

6.5.0

cspSettingsClassName parameter

6.8.0

struts.csp.nonce.source, with request as an alternative to the default session

6.11.0 and 7.3.0

The CSP report body is read up to a limit (8192 characters by default, struts.csp.report.maxSize, 1 to 1048576): S2-073, CVE-2026-73634, Moderate; affects 6.0.0 through 6.10.0 and 7.0.0 through 7.2.1

7.0

The interceptor classes live in org.apache.struts2.interceptor (the 6.x names under com.opensymphony.xwork2 are gone), which matters for custom CspSettings and ResourceIsolationPolicy code

7.4.0

struts.csp.nonceSource (the name in older default.properties files, which was never read) is honoured and deprecated in favour of struts.csp.nonce.source (WW-5669); defaultStack contains csp, coep, coop and fetchMetadata

The security bulletins page of this section lists S2-073 in context with the other 2026 bulletins.

Where the Books Are Outdated

The books of the bibliography were written for Struts 2.x and earlier and predate these interceptors, so what follows is what they cannot tell you, not a list of mistakes in them.

Table 9. Statements found in book-era Struts material and what is true in 7.4.0
Older advice Struts 7.4.0

Security means escaping output and validating input in the action, and nothing in the framework helps the browser

The framework sends CSP, COOP, COEP and Fetch Metadata behaviour by default: four interceptors in the defaultStack, two of them (csp, coep) report-only

Cross-site request forgery is handled with the token interceptor only

A token is still the defence for state-changing actions, but fetchMetadata adds a server-side check of the Sec-Fetch-* headers; its 7.4.0 policy is weaker than the documented one, so the token stays mandatory

A defaultStack printed in a book is shorter and in a different order

The 7.4.0 stack has twenty-four entries including httpMethod, csp, coep, coop, fetchMetadata and actionFileUpload; copying a book’s stack into struts.xml silently drops the four security-header interceptors

XSS defence stops at s:property escaping and escapeHtml

Escaping stays the first defence; a nonce-based CSP makes an injected script element inert in the browser even when escaping was forgotten

ActionContext and interceptor code imported from com.opensymphony.xwork2

Since 7.0 the packages are org.apache.struts2 and org.apache.struts2.interceptor; the official CSP and Fetch Metadata pages already use the new names

Static content and error pages are covered by the framework’s filter

Only action results carry the interceptors' headers; use a servlet filter for the rest

Inconsistencies in the Official Documentation

The following mismatches between the guides and the 7.4.0 jar were found while writing and running this page; the errata are recorded with the page set so they can be reported upstream.

  • The Fetch Metadata page describes a policy that rejects cross-site requests unless they are navigations with GET or HEAD; the code allows navigate mode with any method, and any GET, and does not special-case HEAD.

  • The exemptedPaths of the Fetch Metadata, COOP and COEP interceptors are documented as request paths and compared with the context path.

  • The CSP page asks to implement processReport(String) of CspReportAction; the method is package-private.

  • The CSP page’s CspSettingsAware example returns CspSetting; the type is CspSettings.

  • The COEP page says enforcingMode defaults to false; the Java default is enforcing and only the stack’s explicit parameter makes it report-only.

  • The CSP page does not list the reportTo property that the class has, and the interceptor pages do not say that the rejection of Fetch Metadata needs a mapped 403 result.

Summary

  • Struts 7.4.0 puts csp, coep, coop and fetchMetadata in the defaultStack; only coop and fetchMetadata enforce by default.

  • CSP in Struts is a nonce-based script policy; s:script and s:link write the nonce, and enforcement needs csp.enforcingMode=true after a report-only period.

  • The CSP header needs a nonce, and the default nonce store is the session: set struts.csp.nonce.source to request to cover first requests.

  • A report endpoint needs a mapped 204 result, a body limit and an action that accepts the content type the browsers send.

  • coop needs same-origin-allow-popups for pages that open cross-origin popups; coep breaks third-party resources and is best run report-only first.

  • fetchMetadata stops cross-site fetch(), XHR, script and embed use of your endpoints, needs a mapped 403 result, and does not stop a cross-site form POST in 7.4.0.

  • Per-action exemptedPaths does not work in 7.4.0; use disabled=true on an action or on a package stack.

  • Static files and other servlets get their headers from a servlet filter, not from Struts.

References