Is your feature request related to a problem? Please describe.
The DSpace UI cannot currently be served under a Content-Security-Policy that omits
'unsafe-inline' from script-src and style-src. Angular injects component styles as runtime
<style> elements, and the served HTML's bundle <script> tags carry no nonce, so a policy
without 'unsafe-inline' produces a blank page.
'unsafe-inline' in script-src defeats the main purpose of having a CSP at all, so in practice
this forces every DSpace deployment to run a policy that offers little real protection.
See the umbrella issue #6079 for the wider context.
@angular/core 20.3.x, which we ship, provides the CSP_NONCE injection token and the
ngCspNonce root-element attribute for exactly this purpose. Neither string appears anywhere in
this repository today.
Describe the solution you'd like
Add opt-in, per-request CSP nonce support to the Express server, so a deployer can run the UI
under a nonce-based policy.
1. Generate a nonce per request
In server.ts, generate a cryptographically random value per request
(crypto.randomBytes(16).toString('base64')) and hang it off the request, so it is available to
both the SSR render path and the CSR fallback path.
2. Give the nonce to Angular
Add { provide: CSP_NONCE, useValue: nonce } to the providers array passed to
commonEngine.render() (server.ts:266-281, alongside the existing APP_BASE_HREF, REQUEST,
RESPONSE and APP_CONFIG providers). This covers the <style> elements Angular injects.
3. Stamp the nonce onto the bundle script tags
CSP_NONCE covers only Angular's injected styles. The <script src="main-*.js"> tags that come
from the built index.html still need a nonce attribute added server-side before the HTML is
sent. This has to happen on both paths that emit HTML:
- the SSR path,
server.ts:300 (res.send(html))
- the CSR fallback path used for routes matched by
environment.ssr.excludePathPatterns, which
serves the static index.html directly
4. Solve the SSR page cache interaction (the non-obvious part)
server.ts caches fully rendered HTML in the bot cache and the anonymous cache
(saveToCache(req, html) at server.ts:300, res.send(cachedCopy.page) at server.ts:434).
A per-request nonce baked into a cached page is either stale or, worse, reused across visitors,
which silently reduces the nonce to a constant and removes its value.
Suggested approach: store a fixed placeholder token in the cached HTML and substitute the actual
per-request nonce at send time in cacheCheck(), so caching keeps working. Bypassing the cache
whenever CSP is enabled would also be correct but would be a significant performance regression
for exactly the deployments most likely to want CSP.
Whichever approach is chosen, it needs a test asserting that two requests for the same cached page
receive different nonces.
5. Emit the header
Proposed model: the UI server owns the nonce and the header. Only the server can produce a
per-request random value and get it into the HTML, so splitting responsibility (proxy sets the
policy, server sets the nonce) means the two have to stay in sync through configuration that lives
in two places. That is the failure mode worth designing out.
Add an opt-in csp: block to config.yml, off by default for backwards compatibility, with at
least:
enabled: false
reportOnly: true (send Content-Security-Policy-Report-Only while a deployer is validating)
reportUri
extraSources escape hatch for locally required hosts
This does not take control away from deployers who want to manage policy in Apache or nginx.
Browsers enforce the intersection of all Content-Security-Policy headers on a response, so a
proxy can still layer additional restrictions on top of the server-generated policy without
needing the nonce at all. Deployers who prefer to keep everything in the proxy simply leave
csp.enabled false and carry on as today.
The intersection behaviour should be documented in the config comments, because a proxy policy and
a server policy that each work in isolation can combine into one that breaks the page.
Target policy
default-src 'self';
base-uri 'self';
object-src 'none';
frame-ancestors 'self';
form-action 'self';
script-src 'nonce-{random}' 'strict-dynamic' https:;
style-src 'nonce-{random}';
img-src 'self' data: https:;
font-src 'self' data:;
connect-src 'self' {rest.baseUrl};
worker-src 'self' blob:;
'strict-dynamic' is deliberate for this first step: it means the nonced bundle is trusted and
anything it loads is trusted too, so the optional integrations (Analytics, Matomo, MathJax,
reCAPTCHA, AddToAny) keep working without any change to their code and without host allowlists
that would go stale. Tightening beyond 'strict-dynamic' is phase 3 in #6079.
Note connect-src cannot be 'self' alone: config.example.yml shows rest.host may differ from
ui.host, and split UI/REST origins are a common deployment.
Acceptance criteria
- With
script-src 'nonce-X' 'strict-dynamic' and style-src 'nonce-X' (no 'unsafe-inline',
no 'unsafe-eval'), the UI loads, hydrates and reports zero CSP violations in the console
for: home, search, browse, item page, submission, login, admin.
- Two requests to the same cached page receive different nonces.
- The CSR fallback routes (
ssr.excludePathPatterns) also receive a valid nonce.
- Default behaviour is unchanged when
csp.enabled is false.
Describe alternatives or workarounds you've considered
- Hashes (
'sha256-...') instead of nonces. Avoids per-request state and the cache problem
entirely, but cannot work for Angular's runtime-injected styles, which are not known at build
time.
- Keep the header entirely in the reverse proxy. This is what deployers do today and it is why
they are stuck with 'unsafe-inline': a static proxy config cannot produce a per-request nonce
and cannot get it into the HTML.
style-src 'unsafe-inline' only, nonce for scripts only. A reasonable intermediate that gets
the majority of the benefit, since inline style injection is a much weaker attack primitive
than inline script. Could be an acceptable first milestone if the styles work proves difficult.
Additional information
Is your feature request related to a problem? Please describe.
The DSpace UI cannot currently be served under a Content-Security-Policy that omits
'unsafe-inline'fromscript-srcandstyle-src. Angular injects component styles as runtime<style>elements, and the served HTML's bundle<script>tags carry no nonce, so a policywithout
'unsafe-inline'produces a blank page.'unsafe-inline'inscript-srcdefeats the main purpose of having a CSP at all, so in practicethis forces every DSpace deployment to run a policy that offers little real protection.
See the umbrella issue #6079 for the wider context.
@angular/core20.3.x, which we ship, provides theCSP_NONCEinjection token and thengCspNonceroot-element attribute for exactly this purpose. Neither string appears anywhere inthis repository today.
Describe the solution you'd like
Add opt-in, per-request CSP nonce support to the Express server, so a deployer can run the UI
under a nonce-based policy.
1. Generate a nonce per request
In
server.ts, generate a cryptographically random value per request(
crypto.randomBytes(16).toString('base64')) and hang it off the request, so it is available toboth the SSR render path and the CSR fallback path.
2. Give the nonce to Angular
Add
{ provide: CSP_NONCE, useValue: nonce }to the providers array passed tocommonEngine.render()(server.ts:266-281, alongside the existingAPP_BASE_HREF,REQUEST,RESPONSEandAPP_CONFIGproviders). This covers the<style>elements Angular injects.3. Stamp the nonce onto the bundle script tags
CSP_NONCEcovers only Angular's injected styles. The<script src="main-*.js">tags that comefrom the built
index.htmlstill need anonceattribute added server-side before the HTML issent. This has to happen on both paths that emit HTML:
server.ts:300(res.send(html))environment.ssr.excludePathPatterns, whichserves the static
index.htmldirectly4. Solve the SSR page cache interaction (the non-obvious part)
server.tscaches fully rendered HTML in the bot cache and the anonymous cache(
saveToCache(req, html)atserver.ts:300,res.send(cachedCopy.page)atserver.ts:434).A per-request nonce baked into a cached page is either stale or, worse, reused across visitors,
which silently reduces the nonce to a constant and removes its value.
Suggested approach: store a fixed placeholder token in the cached HTML and substitute the actual
per-request nonce at send time in
cacheCheck(), so caching keeps working. Bypassing the cachewhenever CSP is enabled would also be correct but would be a significant performance regression
for exactly the deployments most likely to want CSP.
Whichever approach is chosen, it needs a test asserting that two requests for the same cached page
receive different nonces.
5. Emit the header
Proposed model: the UI server owns the nonce and the header. Only the server can produce a
per-request random value and get it into the HTML, so splitting responsibility (proxy sets the
policy, server sets the nonce) means the two have to stay in sync through configuration that lives
in two places. That is the failure mode worth designing out.
Add an opt-in
csp:block toconfig.yml, off by default for backwards compatibility, with atleast:
enabled: falsereportOnly: true(sendContent-Security-Policy-Report-Onlywhile a deployer is validating)reportUriextraSourcesescape hatch for locally required hostsThis does not take control away from deployers who want to manage policy in Apache or nginx.
Browsers enforce the intersection of all
Content-Security-Policyheaders on a response, so aproxy can still layer additional restrictions on top of the server-generated policy without
needing the nonce at all. Deployers who prefer to keep everything in the proxy simply leave
csp.enabledfalse and carry on as today.The intersection behaviour should be documented in the config comments, because a proxy policy and
a server policy that each work in isolation can combine into one that breaks the page.
Target policy
'strict-dynamic'is deliberate for this first step: it means the nonced bundle is trusted andanything it loads is trusted too, so the optional integrations (Analytics, Matomo, MathJax,
reCAPTCHA, AddToAny) keep working without any change to their code and without host allowlists
that would go stale. Tightening beyond
'strict-dynamic'is phase 3 in #6079.Note
connect-srccannot be'self'alone:config.example.ymlshowsrest.hostmay differ fromui.host, and split UI/REST origins are a common deployment.Acceptance criteria
script-src 'nonce-X' 'strict-dynamic'andstyle-src 'nonce-X'(no'unsafe-inline',no
'unsafe-eval'), the UI loads, hydrates and reports zero CSP violations in the consolefor: home, search, browse, item page, submission, login, admin.
ssr.excludePathPatterns) also receive a valid nonce.csp.enabledis false.Describe alternatives or workarounds you've considered
'sha256-...') instead of nonces. Avoids per-request state and the cache problementirely, but cannot work for Angular's runtime-injected styles, which are not known at build
time.
they are stuck with
'unsafe-inline': a static proxy config cannot produce a per-request nonceand cannot get it into the HTML.
style-src 'unsafe-inline'only, nonce for scripts only. A reasonable intermediate that getsthe majority of the benefit, since inline style injection is a much weaker attack primitive
than inline script. Could be an acceptable first milestone if the styles work proves difficult.
Additional information
inlineCriticalCssdefaults tofalseinconfig.example.yml:31, so critical-CSS inlining isnot currently an additional blocker, but it will need the nonce too if a deployer enables it.
provideClientHydration()is used withoutwithEventReplay()(
src/modules/app/browser-app.config.ts:90), so Angular's event-replay inline script is notcurrently emitted. If event replay is adopted later it will need the nonce.