feat(xo-server): add content security policy (#10101)

This commit is contained in:
Grandalf
2026-07-22 12:15:16 +02:00
committed by GitHub
parent 0a5693345f
commit 8e8afeb1b5
6 changed files with 37 additions and 11 deletions

View File

@@ -9,6 +9,7 @@
- xo-server : Permissions were ignored for some pool functions ([#10093](https://github.com/vatesfr/xen-orchestra/pull/10093))
- [Users/Create] Removed default user's password from the logs during account creation (PR [#10094](https://github.com/vatesfr/xen-orchestra/pull/10094))
- [Security] Serve a default `Content-Security-Policy` to mitigate cross-site scripting and related attacks. It can be extended or disabled through the `http.helmet` configuration (PR [#10101](https://github.com/vatesfr/xen-orchestra/pull/10101))
### Enhancements

View File

@@ -58,7 +58,7 @@ loadBalancerReEnableDelay = '30 minutes'
# https:#github.com/websockets/ws#websocket-compression
[apiWebSocketOptions]
perMessageDeflate = { threshold = 524288 } # 512kiB
perMessageDeflate = {threshold = 524288}# 512kiB
[authentication]
defaultTokenValidity = '30 days'
@@ -145,9 +145,13 @@ useForwardedHeaders = false
# Helmet handles HTTP security via headers
#
# https://helmetjs.github.io/docs/
#
# A Content-Security-Policy is applied by default (defined in xo-server). Values
# set here are merged on top: array values (e.g. CSP directives) extend the
# defaults, other values override them.
#[http.helmet.hsts]
#includeSubDomains = false
#
# Each `http.listen.<name>` entry defines a specific listening configuration for
# the HTTP server.
#
@@ -189,11 +193,11 @@ requestTimeout = 0
[http.proxies]
# [port] is used to reuse the same port declared in [http.listen.0]
'/openmetrics' = 'http://localhost:9004'
'/v5/api' = '[protocol]//localhost:[port]/api'
'/v5/api/updater' = 'ws://localhost:9001'
'/v5/rest' = '[protocol]//localhost:[port]/rest'
'/v5/netdata' = '[protocol]//localhost:[port]/netdata'
'/openmetrics' = 'http://localhost:9004'
'/v5/rest' = '[protocol]//localhost:[port]/rest'
[logs]
# Display all logs matching this filter, regardless of their level
@@ -229,15 +233,16 @@ timeout = 600e3
ignoreVmSnapshotResources = false
[xapiOptions]
maxUncoalescedVdis = 1
# The duration XO will wait for a host to be live before assuming it failed to
# restart
restartHostTimeout = '20 minutes'
maxUncoalescedVdis = 1
vdiDelayBeforeRemovingCloudConfigDrive = '5 min'
vdiExportConcurrency = 12
vmEvacuationConcurrency = 3
vmExportConcurrency = 2
vmSnapshotConcurrency = 2
vdiDelayBeforeRemovingCloudConfigDrive = '5 min'
poolMarkingInterval = '6 hours'
poolMarkingMaxAge = '48 hours'
@@ -265,13 +270,12 @@ vmTag = 'XOA Proxy'
xoaUpgradeTimeout = '5 min'
[rest-api]
dashboardCacheTimeout = '1 min'
dashboardCacheExpiresIn = '1 min'
dashboardCacheTimeout = '1 min'
# Allow to set a limit of memory per SSE client
# before destroying the client. In MiB
maxRamAllocatedPerSseClient = 20
[jsonrpc-api]
xvaImportFromUrlTimeout = '6s'

View File

@@ -174,6 +174,7 @@
"dev": "cross-env NODE_ENV=development yarn run _build --watch",
"prepublishOnly": "yarn run build",
"start": "node dist/cli.mjs",
"start:dev": "cross-env NODE_ENV=development node dist/cli.mjs",
"test": "cd dist && node --test"
},
"author": {

View File

@@ -116,6 +116,7 @@ html
div
button.btn.btn-block
| Sign in with password
//- Note: if this script changes, make sure to update the hash in xo-server/src/index.mjs to match the new content.
script.
(function () {
var d = document

View File

@@ -11,6 +11,7 @@ import httpProxy from 'http-proxy'
import includes from 'lodash/includes.js'
import memoryStoreFactory from 'memorystore'
import merge from 'lodash/merge.js'
import mergeWith from 'lodash/mergeWith.js'
import ms from 'ms'
import once from 'lodash/once.js'
import proxyAddr from 'proxy-addr'
@@ -62,7 +63,6 @@ const [APP_NAME, APP_VERSION] = (() => {
const { name, version } = JSON.parse(fse.readFileSync(new URL('../package.json', import.meta.url)))
return [name, version]
})()
// ===================================================================
configure([
@@ -146,6 +146,21 @@ async function updateLocalConfig(diff) {
// ===================================================================
const DEFAULT_HELMET_CONFIG = {
contentSecurityPolicy: {
directives: {
'default-src': ["'self'"],
// Hashes of XO's own inline scripts. If either changes, update its hash.
'script-src': [
"'self'",
"'sha256-sIj1FFjxCJxsQo5Hw5ZkhT+9Gc+Q6LmIkJzdjARCn0o='", // xo-web/src/index.pug
"'sha256-Z5hWOtGcISU7nkyObsPm3ZZvPpAYxzoiQutkJucVkm8='", // xo-server/signin.pug
],
'style-src': ["'self'", "'unsafe-inline'"],
'img-src': ["'self'", 'data:'],
},
},
}
async function createExpressApp(config) {
const app = createExpress()
@@ -153,8 +168,11 @@ async function createExpressApp(config) {
//
// https://expressjs.com/en/api.html#app.set
app.set('json spaces', 2)
app.use(helmet(config.http.helmet))
const isDev = (process.env.NODE_ENV ?? '').trim().toLowerCase() === 'development'
const helmetConfig = mergeWith({}, isDev ? {} : DEFAULT_HELMET_CONFIG, config.http.helmet, (dst, src) =>
Array.isArray(dst) ? dst.concat(src) : undefined
)
app.use(helmet(helmetConfig))
app.use(compression())

View File

@@ -20,6 +20,7 @@ html.no-js(
//- .visible-js to display content only when JavaScript is ENABLED.
//- .hidden-js to display content only when JavaScript is DISABLED.
//- Note: if this line changes, make sure to update the hash in xo-server/src/index.mjs to match the new content.
script !function(d){d.className=d.className.replace(/\bno-js\b/,'js')}(document.documentElement)
style .no-js .visible-js,.js .hidden-js{display:none}