Skip to content

fix(security): drop 'unsafe-inline' from the CSP script-src - #213

Open
fjaeckel wants to merge 1 commit into
mainfrom
security/csp-unsafe-inline
Open

fix(security): drop 'unsafe-inline' from the CSP script-src#213
fjaeckel wants to merge 1 commit into
mainfrom
security/csp-unsafe-inline

Conversation

@fjaeckel

Copy link
Copy Markdown
Owner

Problem

The Content-Security-Policy included 'unsafe-inline' in script-src:

script-src 'self' 'unsafe-inline';

That opts out of the single most useful thing a CSP does. Any attacker-controlled string that reaches the DOM as markup executes — exactly the class of bug the policy exists to stop. It matters more here than usual, because auth tokens live in localStorage: one successful injection reads them directly. The CSP was the compensating control for that storage choice, and it was disabled.

The directive was there for two hand-written inline <script> blocks in index.html — the APP_NAME title override and the pre-paint theme bootstrap.

Change

Both blocks move to public/app-init.js, loaded as a classic render-blocking script from <head> immediately after env-config.js.

It must stay classic and non-deferred. A deferred or module script paints the wrong theme first and corrects it afterwards, which is the flash the original block was written to prevent. There is a comment on the file saying so.

Nothing else in the built output is inline — verified against a real npm run build:

<script src="/env-config.js"></script>
<script src="/app-init.js"></script>
<script type="module" crossorigin src="/assets/index-*.js"></script>
<script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script>

vite-plugin-pwa references registerSW.js by src and Vite emits the entry as a module with src, so script-src 'self' is satisfiable with no hashes or nonces.

Caching

app-init.js has no content hash in its filename, so without an explicit rule it falls through to the immutable 1-year static-asset block and a deploy cannot dislodge the old copy from browser caches. Added a no-cache location in nginx.conf and in the TLS server block the entrypoint generates.

While adding it there I found the TLS block was missing that rule for env-config.js too — meaning runtime environment config was being served public, immutable, expires 1y under HTTPS, so a redeploy with changed env values would not reach returning browsers. Added there as well.

Verification

Served the production dist/ from a static server emitting the exact production CSP header and loaded it in headless Chromium:

check result
CSP violations 0
React mounts yes
stored theme dark <html class="dark">
stored theme light / unset class cleared
VITE_APP_NAME override "SkyBook - Pilot Logbook"

As a control, re-adding an inline script to that same page under the same header:

Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self'".
CSP-VIOLATION: script-src-elem blocked inline
inline script executed -> false

so the policy is enforced, not merely present.

Also: npx vitest run — 40 files / 352 tests pass; npx tsc --noEmit clean; sh -n docker-entrypoint.sh clean; eslint . 0 errors.

Deliberately not in this PR

style-src keeps 'unsafe-inline'. The UI styles components through React's style prop, so dropping it is a styling-architecture change, not a config change.

Tokens remain in localStorage. The original finding paired the CSP gap with token storage. Moving to HttpOnly cookies is a cross-repo design change — the API has to set and rotate the cookies, CSRF protection has to be added alongside, the refresh flow changes shape, and the PWA/service-worker path needs re-testing on iOS (there is already a comment in vite.config.ts about session resume being fragile there). That deserves its own decision and its own PR rather than being folded in behind a CSP fix. This change is still the right first move regardless of how that lands: XSS is the delivery mechanism for stealing those tokens, and script-src 'self' is what makes injected script hard to run.


Generated by Claude Code

script-src carried 'unsafe-inline', which means any attacker-controlled
string that reaches the DOM as markup executes. That is precisely the class
of bug the policy exists to stop, and it matters more here than usual: auth
tokens live in localStorage, so a single successful injection reads them
directly. The CSP was the compensating control and it was opted out of.

'unsafe-inline' was there for two hand-written inline <script> blocks in
index.html — the APP_NAME title override and the pre-paint theme bootstrap.
Both move to public/app-init.js, loaded as a classic render-blocking script
from <head> immediately after env-config.js. It must stay classic and
non-deferred: a deferred script paints the wrong theme first and corrects it
afterwards, which is the flash the block was written to prevent.

Nothing else in the built output is inline. vite-plugin-pwa references
registerSW.js by src and Vite emits the entry as a module with src, so
script-src 'self' is satisfiable as-is.

app-init.js has no content hash in its filename, so it needs an explicit
no-cache rule or it falls through to the immutable 1-year static-asset rule
and a deploy cannot dislodge the old copy. Added in nginx.conf and in the
TLS server block the entrypoint generates. The TLS block was missing that
rule for env-config.js too, which meant runtime environment config was being
served immutable for a year under HTTPS; added there as well.

style-src keeps 'unsafe-inline'. The UI styles components through React's
style prop, so removing it needs a different styling approach rather than a
config change.

Verified against a static server emitting the exact production CSP header:
the app boots with zero violations, React mounts, the stored theme applies
(dark set, light and default clear), and the APP_NAME override retitles the
document. As a control, re-adding an inline script to the same page is
refused by the browser with "Refused to execute inline script" and does not
run — confirming the policy is enforced, not merely present.
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.

2 participants