* 🛡️ feat: Configurable Baseline HTTP Security Headers
Adds helmet's CSP-independent headers (HSTS, X-Frame-Options,
X-Content-Type-Options, COOP, CORP, Referrer-Policy) on every response,
with contentSecurityPolicy explicitly disabled. Every header that can
break a deployment is configurable, so there is no allow-list to go
stale the way #7377's hardcoded CSP directives did.
HSTS includeSubDomains defaults off rather than matching helmet's
on-by-default: it would otherwise pin every sibling subdomain to HTTPS
for a year in every visitor's browser, and undoing that requires
serving max-age=0 from each affected host.
* 🛡️ feat: Nonce-Based Content Security Policy for the SPA Shell
Adds an opt-in, per-response nonce CSP on the HTML response, resolved once
at startup so each request only mints a nonce and concatenates the header.
Report-only by default, since that is the rollout step #7377 skipped.
Rebase and correctness pass over #13226:
- Styles carry no nonce. A nonce in style-src makes browsers ignore
'unsafe-inline', which would have blocked the <style> element the theme
script injects at runtime, plus every style third-party components inject.
- frame-ancestors 'self' is now a default rather than opt-in, so enabling
CSP actually covers the clickjacking half of #7110.
- CSP_SCRIPT_SRC_EXTRA now drops 'strict-dynamic', which would otherwise
make browsers ignore the very hosts the operator configured.
- Nonce stamping runs after the query-devtools bootstrap injection so that
injected script is covered too.
* fix: replace frame-ancestors instead of merging it
Merging the configured value into the default turned a deliberate
CSP_FRAME_ANCESTORS='none' into `frame-ancestors 'self' 'none'`, which
browsers resolve back to 'self'. Also bail out if the serialized policy
somehow lacks the nonce slot rather than emitting a header the shell
cannot match.
* fix: address Codex review findings on the CSP defaults
All five were real against LibreChat's actual runtime:
- CSP_REPORT_ONLY now only enforces on an explicit false/off/0/no. A typo
or `1` previously fell through isEnabled() to enforcing, turning a
config slip into a blocked SPA. Shares the parse helper with
headers.ts via a new security/env.ts.
- Module preloads are stamped. A production client/dist/index.html
carries 32 parser-inserted `<link rel="modulepreload">` tags, which
'strict-dynamic' does not cover and 'self' cannot rescue.
- Stale nonce attributes are replaced rather than preserved; only the
current response's nonce is authorized.
- worker-src allows data:, which Monaco's default CDN loader needs to
bootstrap its workers (there is no loader.config() in the client).
- script-src allows 'wasm-unsafe-eval' for the HEIC upload path, which
compiles WebAssembly through heic-to. Narrower than 'unsafe-eval'.
Verified against the real built shell: 4 scripts and all 32 preloads
nonced, stylesheets/icons/manifest and <style> untouched.
* fix: address second Codex round on CSP rollout controls
- SECURITY_HEADERS=false now disables CSP too. It is documented as the
global kill switch, and an operator reaching for it to recover a shell
broken by an enforcing policy must not be left with that policy on.
- The SPA shell is forced to `no-store` while CSP is enabled, ignoring
INDEX_CACHE_CONTROL/INDEX_PRAGMA/INDEX_EXPIRES and warning when they
are set. A cacheable shell pins one nonce across page loads and users,
which is the whole thing a nonce policy defends against.
- Added CSP_ALLOW_WASM and CSP_ALLOW_DATA_WORKERS. The previous commit's
.env.example claimed CSP_ADDITIONAL_DIRECTIVES could drop
'wasm-unsafe-eval' and data:, but merging only ever appends sources, so
the documented hardening step was impossible. These toggles make it real.