Skip to content

feat: enable SvelteKit CSP policy with hash-based inline script/style protection#26604

Closed
midzelis wants to merge 1 commit intomainfrom
csp-policy
Closed

feat: enable SvelteKit CSP policy with hash-based inline script/style protection#26604
midzelis wants to merge 1 commit intomainfrom
csp-policy

Conversation

@midzelis
Copy link
Copy Markdown
Collaborator

See #25389

This adds a CSP - using HASH based script protection (vs the nonce based on in referenced PR) - and also serving the CSP via express (so it works in prod mode)

Not using nonces because the content never changes (since using adapter-static). Svelte-kit also creates the bootstrap script as part of app.html - and since this is created statically, the nonce will always be the same for each request - which defeats the purpose of a nonce.

So, this uses hashes instead. Sveltekit actually creates hashes for all the inline scripts it generates (including the bootstrap script) and adds it as a header.

At runtime, ApiService.ssr() and MaintanceWorkerService.ssr() will extract/remove the CSP from the file - and augment it with additional policies (websockets, payment endpoints) and sets it as a CSP header which is more secure.

specifics of this CSP:

script-src - www.gstatic.com is included for google cast support (didn't test this)
style-src has 'unsafe-inline' - svelte components that use style directives use inline styles - there is no other option here.
connect-src - https: this is super permissive right now - because of admin-configurable tile servers - personally, I think we should proxy tiles via the backend-api.
img-src - data: for JXL detection, blob: for canvas, and https: for map tiles
worker-src self - local only
font-src - self - local only
frame-src - none - no frames
object-src - none - no plugins
base-uri - self - best practice

Also included is a script to generate hash for inline scripts (the theme parsing/setting inline script)

As a followup - the tiles stuff should be locked down a little more

Comment thread misc/update-csp-hashes.mjs Fixed
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 28, 2026

Preview environment has been removed.

Copy link
Copy Markdown
Collaborator

@meesfrensel meesfrensel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I believe font-src and base-uri set to self are redundant since we already have default-src: self.

How do the directives flow from svelte config? Do they already automatically appear in the thing?

From a quick smoke test:

  • Navigating to /map doesn't load the map tiles, and gives in this error in the console, but I don't see failed requests in the network tab: Content-Security-Policy: The page’s settings blocked a worker script (worker-src) at blob:https://pr-26604.preview.internal.immich.build/ee2903a1-3a59-4637-9b29-6bd425e1289b from being executed because it violates the following directive: “worker-src 'self'”
  • Small map in detail panel of asset viewer: tiles don't load.
  • Panorama viewer does not load. I think the image is loaded in a script and sent to the viewer library as a blob: URL but blob: is missing from connect-src: ontent-Security-Policy: The page’s settings blocked the loading of a resource (connect-src) at blob:https://pr-26604.preview.internal.immich.build/9746f882-b47a-4ede-8b4a-849c27725037 because it violates the following directive: “connect-src 'self' https: wss: https://pay.futo.org/ https://buy.immich.app/”

Comment thread server/src/utils/csp.ts Outdated
Comment thread server/src/utils/csp.ts Outdated
Comment thread server/src/services/api.service.ts Outdated
const { csp: baseCsp, html: indexWithoutCspMeta } = extractCsp(index);
index = indexWithoutCspMeta;
const csp = augmentCsp(baseCsp, {
'connect-src': ['wss:', 'https://pay.futo.org', 'https://buy.immich.app'],
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just put these in the svelte config? It doesn't really hurt to have these in the maintenance mode pages.

Copy link
Copy Markdown
Member

@danieldietzler danieldietzler Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. You'd be able to remove that entire csp utils file, too, I believe

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, we don't need to promote the defined CSP to a header either, since we're not using header-only CSP directives, like report-to or sandbox. I'll revert the entire CSP. I'll leave the scrip that generates the hashes for inline scripts in app.html, since that is still useful to have.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note - the tile URLs are now also hardcoded, instead of trying to parse out the domains from the dark/light tile .json configuration. So if the tile servers changes, the CSP will need to be manually updated.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll leave the scrip that generates the hashes for inline scripts in app.html, since that is still useful to have.

I would prefer not to. I don't see any reason why we should leave unused code in there. We can almost grab it from this PR again if we do need it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note - the tile URLs are now also hardcoded, instead of trying to parse out the domains from the dark/light tile .json configuration. So if the tile servers changes, the CSP will need to be manually updated.

You're aware that we let users customize their tile servers? (https://my.immich.app/admin/system-settings?isOpen=location+map)

Copy link
Copy Markdown
Collaborator Author

@midzelis midzelis Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh - missed that.

Its slightly problematic to parse out all the domains from a mapbox json. It can contain 'sources' that have 'urls' that fetch other domains. i.e. for immich, this is where 'static.immich.cloud' comes from - in addition to the tiles.immich.cloud for the main .json file.

An easy and reliable way to solve this problem is to not have the browser directly contact the map server. Instead, create a rest endpoint on the api server itself to proxy the tile requests. This way, the browser CSP does not need to carve out an exception - as all requests will go back to the same server (self:).

Downsides:

  • May add some latency - uses your servers upstream bandwidth.

Upsides:

  • potentially have the server do some caching
  • you increase some privacy - the map server only sees the ip address of the server, not every browser/mobile device your using.

In general, having every 3rd party request go through the api server would be my preferred solution - mostly for privacy - and for observability - knowing that I (as an admin) am in control all all URLs the browser touches.

Not sure if its possible for gcast/streaming - but it would be awesome if that could be done there too - so google won't see my client IP.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, having every 3rd party request go through the api server would be my preferred solution

I think this is a bit of a double edged sword. On the one hand I agree, yes, but then again some people like to air-gap their instances. Right now, Immich server does not need internet access (the only traffic there is ML downloading the models, which you can also download yourself and put in the directories). I think the point is basically that a web browser already has internet, whereas the server doesn't necessarily has to.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am one of those "some people", I try to deny outgoing traffic from as many containers as possible.
having a CSP header would be nice :)
Couldn't it be as open as it needs for now and be adjusted in future versions?

Comment thread misc/update-csp-hashes.mjs Dismissed
@midzelis midzelis force-pushed the csp-policy branch 3 times, most recently from c608ba1 to 2fc2ac5 Compare March 1, 2026 16:15
@midzelis midzelis requested a review from meesfrensel March 2, 2026 01:51
@midzelis midzelis force-pushed the csp-policy branch 3 times, most recently from 5704ccb to 2de9f20 Compare March 3, 2026 14:27
Copy link
Copy Markdown
Member

@jrasm91 jrasm91 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What impact does adding csp to the svelte config have on the final production build?

@midzelis
Copy link
Copy Markdown
Collaborator Author

midzelis commented Mar 7, 2026

What impact does adding csp to the svelte config have on the final production build?

When csp: { mode: 'hash', directives: {...} } is added to the SvelteKit config with adapter-static, the build output is altered like so:

  1. A <meta http-equiv="content-security-policy"> tag is injected into the <head> of every prerendered/static HTML file. This tag contains the full CSP policy string, including any SHA-256 hashes.

  2. SvelteKit auto-hashes its own inline content — specifically the hydration/bootstrap <script> block and any inlined CSS. These computed hashes are appended to the script-src and style-src directives in the meta tag. Each page gets its own set of hashes because the hydration script differs per page (different JS imports, route IDs, etc.).

In our case, since we're a SPA, and using the static adapter, with no SSR - this is effectively only a single 'fallback' page, which is used for every route.

  1. No other changes to the HTML — the <script> tags themselves are unchanged (no nonce attribute added; that only happens in nonce mode). The JS/CSS asset files in _app/immutable/ are unaffected.

  2. Directives like frame-ancestors, report-uri, and sandbox are silently dropped from the meta tag, since browsers ignore those directives when delivered via <meta> (they only work as HTTP headers). Earlier in this PR, I had a version that extracted the CSP from the static file and promoted them to headers, but since we're not using those directives, and we're not doing anymore dynamic augmentation (well, we might if we want to support tiles by scraping/extrating the tiles config dynamically) - we can just use the static CSP svelte provides.

The only remaining gap was the theme/FOUC script in the fallback/index template - svelte only hashes its own script blocks. Since this is one Immich added, it needs to be hashed manually - which is what that script in this PR does. We can remove that script though, per Daniel's suggestion.

In short: the only build artifact change is the addition of a <meta> CSP tag in each HTML file. The actual JS/CSS bundles are identical.

CSP in general protect against XSS, clickjacking, and other security things.

@jrasm91
Copy link
Copy Markdown
Member

jrasm91 commented Mar 9, 2026

Right. Since there are still so many things that aren't covered with this approach I'm failing to see what value it actually brings. What use case does accepting this PR in it's current state address?

@midzelis
Copy link
Copy Markdown
Collaborator Author

The main thing this gets us is protection against the most common and dangerous web attack — XSS via injected scripts.

Without any CSP, if someone finds a way to inject HTML into the page (say through album names, descriptions, or any user-provided content), the browser will happily run whatever <script> tags come along. With this hash-based CSP, the browser refuses to run any inline script that isn't in the allow-list. That's a really meaningful security win on its own.

Specifically it:

  • Blocks arbitrary inline script execution
  • Blocks eval() and similar dynamic code execution
  • Restricts script sources to self + gstatic (for Chromecast)
  • Blocks object/embed/plugin-based attacks
  • Prevents <base> tag hijacking

You're right that some areas are still permissive — style-src: unsafe-inline is necessary because of how Svelte handles style directives, connect-src: https: and img-src: https: are wide open because of admin-configurable tile servers. But those are much harder to exploit in practice. Script injection is the big one, and that's what this locks down.

The remaining gaps (especially tiles) would be great follow-ups, but I don't think they should block shipping the core protection. Happy to discuss though!

@michelheusschen
Copy link
Copy Markdown
Collaborator

The XSS risk is fairly low in modern web frameworks like Svelte. The main concerns are user input into href/src attributes or values being rendered through {@html ...}. A CSP would still be useful as defense in depth though.

There are some issues:

  • Users may use JS in the welcome message, like our demo does
  • Email previews load external resources and the CSP currently blocks them
  • Keeping the CSP up to date adds maintenance overhead and there are no safeguards to prevent us from shipping an outdated CSP
  • It could break setups for users who inject custom scripts

@AtmosphericIgnition
Copy link
Copy Markdown
Contributor

According to multiple CSP evaluators, this current CSP can be bypassed because script-src https://www.gstatic.com is included. gstatic.com hosts Angular, and JSONP libraries, which can bypass CSPs with script-src 'self'.

It can be mitigated by making the source more specific (https://www.gstatic.com/cv/js/sender/v1/cast_sender.js).

It could also be mitigated with a hash-based approach.
ex:

<script src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.jss"
        integrity="sha256-*hash*"
        crossorigin="anonymous"></script>

and also adding 'sha256-*hash*' to script-src. I don't know how feasible the second option is with SvelteKit.

As a safeguard for shipping changes that break CSP, I set up CSP reporting endpoint in my dev environments. It logs detected CSP violations as JSON, saving me from shipping CSP violations that might be hard to see at first glance.

I do agree that it's still a good idea to ship an imperfect CSP initially, and improve it with time.

See:
https://csper.io/evaluations/69b0e03bae8f8c09c1d6958a
https://csp-evaluator.withgoogle.com/
https://github.com/bhaveshk90/Content-Security-Policy-CSP-Bypass-Techniques

@jrasm91
Copy link
Copy Markdown
Member

jrasm91 commented Mar 11, 2026

I'm not against adding CSP, but as I understand it, the current state of the PR doesn't actually add it. It just configures the static build to include metadata related to CSP in the build output. How are users supposed to use that?

@AtmosphericIgnition
Copy link
Copy Markdown
Contributor

In this case, CSP isn't sent as a header, but is injected as an HTML meta tag (meta http-equiv="content-security-policy). Browsers treat HTML with this tag equivalent to receiving a CSP HTTP header. You can see that the csper report of the preview environment shows the CSP as "Source: meta".

@midzelis midzelis marked this pull request as draft March 12, 2026 02:56
@jrasm91
Copy link
Copy Markdown
Member

jrasm91 commented Mar 19, 2026

The current policy would likely break the existing, custom, functionality that we have in the application. Specifically today we support custom css, custom tiling maps, and allow Immich to be embedded in iframes for dashboards, portals, etc.

I think the goal of a CSP related change should be to enable better security by default while still giving the user full control over the policy. Probably the way to go here is to add CSP headers on the server and have it use some sensible defaults, while providing an option to overwrite it completely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants