Skip to content

JCU/fix(security): stop advertising Express and mark cookies Secure on HTTPS - #1436

Open
Kasinhou wants to merge 1 commit into
customer/jcufrom
jcu/harden-cookie-and-server-headers
Open

JCU/fix(security): stop advertising Express and mark cookies Secure on HTTPS#1436
Kasinhou wants to merge 1 commit into
customer/jcufrom
jcu/harden-cookie-and-server-headers

Conversation

@Kasinhou

@Kasinhou Kasinhou commented Aug 6, 2026

Copy link
Copy Markdown

DO NOT MERGED FOR NOW

Fixes the part of M7 (dataquest-dev/dspace-customers#853) that lives in this repository.

M7 lists three things. Two of them are ours, one is nginx's:

Finding Where it is fixed
X-Powered-By: Express on every response this PRserver.ts
XSRF-TOKEN cookie without Secure this PRClientCookieService
no HSTS / CSP / X-Content-Type-Options / X-Frame-Options / Referrer-Policy / Permissions-Policy nginx on the JCU host — snippet at the bottom, needs someone with server access

Confirmed, today, on production

$ curl -sI https://dspace-new.jcu.cz/home
HTTP/1.1 200 OK
Server: nginx/1.26.3
X-Powered-By: Express          <-- tells an attacker exactly which stack to target
X-RateLimit-Limit: 500
Cache-Control: no-cache, no-store

and, read in the browser on the live HTTPS origin:

origin: https://dspace-new.jcu.cz
XSRF-TOKEN            secure: false   sameSite: lax
DSPACE-XSRF-COOKIE    secure: true    httpOnly: true    (set by the backend — already correct)

X-Powered-By

server.disable('x-powered-by') right after the Express app is created. Express adds the header from
its own default middleware, so this removes it everywhere — SSR pages, the 404 page, /robots.txt,
static assets and /app/health — without touching any route.

proxy_hide_header X-Powered-By in nginx would also work, but only for traffic that goes through
that particular nginx. Turning it off at the source means every deployment of this branch gets it.

Secure cookies

ClientCookieService.set() passed the caller's options straight to js-cookie, and no caller ever
sets secure. So every cookie the UI writes lacks the attribute, not just XSRF-TOKEN:

Cookie Written by
XSRF-TOKEN XsrfInterceptor, UploaderComponent
dsLanguage LocaleService
orejime-* BrowserOrejimeService (cookie-consent choices)
dsAccessibilityCookie AccessibilitySettingsService
CORRELATION-ID CorrelationIdService
hasAgreedEndUser EndUserAgreementService
dsRedirectUrl, dsImpersonatingEPerson AuthService

The service now fills in secure when the caller did not:

private withSecureFlag(options?: Cookies.CookieAttributes): Cookies.CookieAttributes {
  if (options?.secure !== undefined) {
    return options;
  }
  return { ...options, secure: this.document?.location?.protocol === 'https:' };
}

Three decisions worth stating, since they are the reviewable part:

  1. Why the page protocol and not ui.ssl. In this deployment (and in most DSpace deployments)
    nginx terminates TLS and the Node server behind it speaks plain HTTP, so ui.ssl is false on a
    site that is HTTPS-only. location.protocol is what the browser actually used, which is exactly
    the condition under which a Secure cookie is accepted and useful.
  2. Why not unconditionally true. A Secure cookie set on a plain-HTTP page is dropped by the
    browser, which would break local development and any HTTP-only test instance. Deriving the flag
    means nothing changes for those.
  3. Not httpOnly. M7 also notes httpOnly=false on XSRF-TOKEN. That one is by design and must
    stay: Angular's HttpXsrfTokenExtractor reads this cookie from JavaScript and echoes it into the
    X-XSRF-TOKEN header. The cookie that must not be script-readable is DSPACE-XSRF-COOKIE, and the
    backend already sets it httpOnly=true; secure=true; sameSite=None. sameSite is likewise left
    alone — the browser default (Lax) is already what we want and setting it explicitly would only
    add a way to get it wrong later.

Verified

Production build (npm run build:prod) of this branch against the local Docker 9.3 backend.

$ curl -s -D - -o /dev/null http://localhost:4000/home
HTTP/1.1 200 OK
X-RateLimit-Limit: 500
Content-Type: text/html; charset=utf-8
Content-Length: 451282
...

No X-Powered-By on /home, /robots.txt, /assets/config.json, /app/health or the 404 page.

New spec, client-cookie.service.spec.ts5/5 SUCCESS:

ClientCookieService
  when the page is served over HTTPS
    ✔ should mark the cookie as Secure
    ✔ should keep the caller's other attributes
  when the page is served over plain HTTP
    ✔ should not mark the cookie as Secure, so local development keeps working
  ✔ should let an explicit secure attribute win
  ✔ should still serialize non-string values as JSON

Neighbouring suites still pass: core/services + core/xsrf + shared/cookies + correlation-id +
core/locale 96/96, core/auth 123/123. npm run lint → 0 errors.

Browser check on the running local stack (HTTP, so Secure is deliberately not applied): cookies
are still written and the app behaves normally —

origin: http://localhost:4000   protocol: http:
dsLanguage    secure: false  sameSite: lax
XSRF-TOKEN    secure: false  sameSite: lax

Honest limitation: the Secure attribute can only be observed over HTTPS, and the local stack is
HTTP-only (making it HTTPS end-to-end means re-pointing dspace.server.url, dspace.ui.url and the
REST base URL at a TLS terminator — more moving parts than the four-line change deserves). The HTTPS
branch is therefore covered by the unit tests above; the flag will be visible in DevTools →
Application → Cookies on dev-6 as soon as this is deployed, and I'm happy to confirm it there.

Still open — nginx, needs server access

I cannot reach the JCU nginx; there is no bits/nginx/ for JCU under customer-specific/ in
dataquest-dev/dspace-customers (ZCU and SAV have theirs there). This is the part that needs doing on
the host:

# in the server { } block for dspace-new.jcu.cz
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
add_header X-Frame-Options "SAMEORIGIN" always;

# Only once it is confirmed that every subdomain is HTTPS-only — this is hard to undo,
# browsers remember it for max-age:
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

# CSP: start in report-only. frame-src must allow citacepro.com (the citation widget),
# and Angular SSR needs 'unsafe-inline' for styles until we adopt nonces.
# add_header Content-Security-Policy-Report-Only "default-src 'self'; frame-src https://www.citacepro.com; ..." always;

Two notes for whoever applies it:

  • Referrer-Policy: strict-origin-when-cross-origin is safe for us specifically because it still
    sends the full URL on same-origin requests, and DSpace reads Referer for download statistics
    (SolrLoggerServiceImpl, ExportEventProcessor, GoogleAsyncEventListener). A blanket
    no-referrer would silently degrade usage reporting.
  • Careful with add_header inside location blocks: an add_header in a nested block discards all
    inherited ones. Put these at server level and don't add any add_header in a location without
    repeating them.

Verify afterwards with curl -sI and https://securityheaders.com.

Upstream / other customers

server.ts and client-cookie.service.ts are byte-for-byte identical to upstream/dspace-9_x, and
neither x-powered-by nor a secure cookie attribute appears anywhere in dspace-9_x,
dspace-10_x or main — so this is not a JCU regression, it is upstream behaviour. There is no
upstream issue for either (searched "x-powered-by", "secure cookie", "security headers"). Both changes
are small and generic and are worth offering upstream.

None of our other customer branches has them either (customer/{mendelu,TUL,sav,lindat,uk,vsb-tuo,zcu-pub,zcu-data}),
and X-Powered-By: Express is live on dspace.zcu.cz as well — so this is a fleet-wide finding, not
a JCU one.

Evidence

Screenshots:

  • M7-1-before-prod-x-powered-by-and-insecure-xsrf-cookie.png
M7-1-before-prod-x-powered-by-and-insecure-xsrf-cookie
  • M7-2-after-no-stack-header-and-secure-cookies.png
M7-2-after-no-stack-header-and-secure-cookies

…n HTTPS

Two of the three findings in M7 live in this repo:

* Every response carried `X-Powered-By: Express`. That is fingerprinting
  material for an attacker and useful to nobody else, so the header is now
  disabled on the SSR server.

* Cookies written by the UI - `XSRF-TOKEN`, `dsLanguage`, the Orejime consent
  cookie, `dsAccessibility`, `dsCorrelationId`, the redirect and impersonation
  cookies - were written without the `Secure` attribute, so they are sent back
  over plaintext if the user is ever downgraded to HTTP. ClientCookieService now
  adds `Secure` whenever the page itself was loaded over HTTPS. The decision is
  taken from the page protocol rather than from `ui.ssl`, because TLS is usually
  terminated by a reverse proxy and `ui.ssl` is false on HTTPS-only sites;
  reading the protocol also keeps `http://localhost` development working.

The third finding (missing HSTS / CSP / nosniff / Referrer-Policy /
Permissions-Policy headers) belongs to the nginx in front of the app and cannot
be fixed from here - a ready-to-apply snippet is in the PR description.

Fixes the in-repo part of M7 from dataquest-dev/dspace-customers#853.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Kasinhou Kasinhou self-assigned this Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant