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.
| Interceptor | Problem addressed | Where it acts | Default in defaultStack (observed) |
|---|---|---|---|
|
XSS and data injection through scripts the page did not intend to load |
Response header |
Report-only; sent only when a session exists (see the nonce section) |
|
Cross-window attacks and process-wide side channels |
Response header |
|
|
A page loading cross-origin resources that did not opt in |
Response header |
|
|
Cross-origin requests that abuse the application’s endpoints (CSRF-like, XSSI, information leaks) |
Server side, before the action runs; |
Enforcing: a rejected request gets the result code |
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.
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.
| Directive | Value | Effect in the browser |
|---|---|---|
|
|
No plug-in content ( |
|
|
A script runs only if its element carries the current nonce, or was created by a script that did; the |
|
|
A |
|
The interceptor’s |
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
| Parameter | Default | Meaning |
|---|---|---|
|
|
|
|
none |
URI that receives violation reports; the servlet context path is prepended unless |
|
|
Whether the context path is put in front of |
|
|
Full name of a |
|
none |
Exists on the 7.4.0 class ( |
|
|
Inherited from every interceptor of the stack: |
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.
Source: Struts Version Notes 7.4.0
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: Struts Security Bulletin S2-073
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.
-
Keep
cspin its default report-only mode and addcsp.reportUripointing at a report endpoint that stores reports. -
Replace every hand-written
scriptandlinkelement of the JSP files withs:scriptands:link, and every inline event handler with a script that has a nonce. -
Watch the reports for a full business cycle (and for every browser the users use); each
script-src-elemorscript-src-attrreport names a page that still has a script without a nonce. -
Set
csp.enforcingModetotrue, 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.
mode |
Effect in the browser |
|---|---|
|
The document shares its group only with same-origin documents that send the same value; any other document is severed from it |
|
Like |
|
The browser default: the document shares its group with any document that does not opt in |
Source: MDN — Cross-Origin-Opener-Policy
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.
| Interceptor | Parameter | Meaning |
|---|---|---|
|
|
One of |
|
|
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) |
|
|
|
|
|
|
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.
Source: MDN — Sec-Fetch-Site
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:
| Method | Sec-Fetch-Site |
Sec-Fetch-Mode |
Sec-Fetch-Dest |
Result |
|---|---|---|---|---|
|
|
|
|
Allowed (204) |
|
|
|
|
Allowed (204) |
|
|
|
|
Allowed (204) |
|
|
|
|
Rejected (403 with the global result) |
|
|
|
|
Allowed (204) |
|
|
|
|
Rejected (403 with the global result) |
|
|
|
|
Rejected (403 with the global result) |
|
|
|
|
Allowed (204) |
|
|
|
|
Allowed (204) |
|
none sent ( |
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
| Symptom | Cause (observed or read in the 7.4.0 jar) | Fix |
|---|---|---|
No |
The nonce is kept in the HTTP session by default and no session exists yet |
Set |
CSP "is configured" but nothing is blocked |
|
Collect the reports, then set |
Violation reports answered with 404, or never logged |
|
Map a |
A subclass of |
|
Use |
Inline scripts and |
No nonce on a hand-written |
Use |
|
The tags write |
Build the URL with |
The application’s own file under |
Struts serves its own static content under |
Serve application files from another path such as |
|
A parameter overridden on a |
Repeat |
|
|
Use |
A mistyped |
The invalid value is swallowed and the default |
Check the response header after every change |
|
In 7.4.0 the set is compared with the context path, not the request path |
Use |
A rejected request gives a 404 "No result defined … result 403" |
|
Add a global |
A cross-site form |
The 7.4.0 policy allows |
Protect state-changing actions with a token and |
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 |
|
The policy allows requests with no |
Expected: Fetch Metadata is a browser-side protection, not authentication |
What Changed Recently
| 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 |
6.2.0 |
|
6.4.0 |
|
6.5.0 |
|
6.8.0 |
|
6.11.0 and 7.3.0 |
The CSP report body is read up to a limit (8192 characters by default, |
7.0 |
The interceptor classes live in |
7.4.0 |
|
Source: Struts Security Bulletin S2-073
Source: Struts Version Notes 7.4.0
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.
| 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 |
Cross-site request forgery is handled with the |
A token is still the defence for state-changing actions, but |
A |
The 7.4.0 stack has twenty-four entries including |
XSS defence stops at |
Escaping stays the first defence; a nonce-based CSP makes an injected |
|
Since 7.0 the packages are |
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
GETorHEAD; the code allowsnavigatemode with any method, and anyGET, and does not special-caseHEAD. -
The
exemptedPathsof 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)ofCspReportAction; the method is package-private. -
The CSP page’s
CspSettingsAwareexample returnsCspSetting; the type isCspSettings. -
The COEP page says
enforcingModedefaults tofalse; the Java default is enforcing and only the stack’s explicit parameter makes it report-only. -
The CSP page does not list the
reportToproperty that the class has, and the interceptor pages do not say that the rejection of Fetch Metadata needs a mapped403result.
Summary
-
Struts 7.4.0 puts
csp,coep,coopandfetchMetadatain thedefaultStack; onlycoopandfetchMetadataenforce by default. -
CSP in Struts is a nonce-based script policy;
s:scriptands:linkwrite the nonce, and enforcement needscsp.enforcingMode=trueafter a report-only period. -
The CSP header needs a nonce, and the default nonce store is the session: set
struts.csp.nonce.sourcetorequestto cover first requests. -
A report endpoint needs a mapped
204result, a body limit and an action that accepts the content type the browsers send. -
coopneedssame-origin-allow-popupsfor pages that open cross-origin popups;coepbreaks third-party resources and is best run report-only first. -
fetchMetadatastops cross-sitefetch(), XHR, script and embed use of your endpoints, needs a mapped403result, and does not stop a cross-site formPOSTin 7.4.0. -
Per-action
exemptedPathsdoes not work in 7.4.0; usedisabled=trueon an action or on a package stack. -
Static files and other servlets get their headers from a servlet filter, not from Struts.
References
-
Core Developers Guide — Content Security Policy Interceptor, Description
-
Core Developers Guide — Content Security Policy Interceptor, Parameters
-
Core Developers Guide — Content Security Policy Interceptor, Nonce source
-
Core Developers Guide — Content Security Policy Interceptor, Report action
-
Core Developers Guide — Content Security Policy Interceptor, Action aware
-
Core Developers Guide — Content Security Policy Interceptor, Examples
-
Core Developers Guide — Cross-Origin Embedder Policy Interceptor, Description
-
Core Developers Guide — Cross-Origin Embedder Policy Interceptor, Parameters
-
Core Developers Guide — Cross-Origin Embedder Policy Interceptor, Examples
-
Core Developers Guide — Cross-Origin Opener Policy Interceptor, Description
-
Core Developers Guide — Cross-Origin Opener Policy Interceptor, Parameters
-
Core Developers Guide — Fetch Metadata Interceptor, Description
-
Core Developers Guide — Fetch Metadata Interceptor, Parameters
-
Core Developers Guide — Fetch Metadata Interceptor, Examples
-
Core Developers Guide — Interceptors, The Default Configuration
-
Core Developers Guide — Interceptors, Interceptor Parameter Overriding
-
Core Developers Guide — Interceptors, Interceptor Parameter Overriding Inheritance
-
Apache Tomcat 11 — HTTP Header Security Filter, Initialisation parameters
-
web.dev — Protect your resources from web attacks with Fetch Metadata
-
web.dev — Making your website cross-origin isolated using COOP and COEP