diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 3392f6bed9..5e7876f46b 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -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 diff --git a/packages/xo-server/config.toml b/packages/xo-server/config.toml index 0354adf846..cc0a058ab5 100644 --- a/packages/xo-server/config.toml +++ b/packages/xo-server/config.toml @@ -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.` 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' diff --git a/packages/xo-server/package.json b/packages/xo-server/package.json index 0205eb1632..85836f1425 100644 --- a/packages/xo-server/package.json +++ b/packages/xo-server/package.json @@ -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": { diff --git a/packages/xo-server/signin.pug b/packages/xo-server/signin.pug index 29f3492c69..3c5328421c 100644 --- a/packages/xo-server/signin.pug +++ b/packages/xo-server/signin.pug @@ -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 diff --git a/packages/xo-server/src/index.mjs b/packages/xo-server/src/index.mjs index 1b6db446af..d85c2cceea 100644 --- a/packages/xo-server/src/index.mjs +++ b/packages/xo-server/src/index.mjs @@ -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()) diff --git a/packages/xo-web/src/index.pug b/packages/xo-web/src/index.pug index 3c1706cdd0..004f868728 100644 --- a/packages/xo-web/src/index.pug +++ b/packages/xo-web/src/index.pug @@ -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}