diff --git a/.pnp.cjs b/.pnp.cjs index 73a92f923b..6abc9e071b 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -1285,6 +1285,7 @@ const RAW_RUNTIME_STATE = ["eslint-plugin-jest", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:26.9.0"],\ ["eslint-plugin-n", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:15.7.0"],\ ["eslint-plugin-promise", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:6.6.0"],\ + ["eslint-plugin-security", "npm:3.0.1"],\ ["express", "npm:4.21.0"],\ ["express-static-gzip", "npm:2.1.8"],\ ["fast-json-patch", "npm:3.1.1"],\ @@ -1368,6 +1369,7 @@ const RAW_RUNTIME_STATE = ["eslint-plugin-import-newlines", "virtual:8d919ffb8fd728f827df3f6a566e8e923223ffcec68f7450d83bbbc2dc25d6b8c987e111cbab484b209f253bdf2f2e00663b01a986262c44511128466462a76f#npm:1.4.0"],\ ["eslint-plugin-n", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:15.7.0"],\ ["eslint-plugin-promise", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:6.6.0"],\ + ["eslint-plugin-security", "npm:3.0.1"],\ ["eslint-plugin-vitest", "virtual:8d919ffb8fd728f827df3f6a566e8e923223ffcec68f7450d83bbbc2dc25d6b8c987e111cbab484b209f253bdf2f2e00663b01a986262c44511128466462a76f#npm:0.4.1"],\ ["eslint-plugin-vue", "virtual:8d919ffb8fd728f827df3f6a566e8e923223ffcec68f7450d83bbbc2dc25d6b8c987e111cbab484b209f253bdf2f2e00663b01a986262c44511128466462a76f#npm:9.28.0"],\ ["eventemitter3", "npm:5.0.1"],\ @@ -1424,6 +1426,7 @@ const RAW_RUNTIME_STATE = ["eslint-plugin-jest", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:26.9.0"],\ ["eslint-plugin-n", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:15.7.0"],\ ["eslint-plugin-promise", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:6.6.0"],\ + ["eslint-plugin-security", "npm:3.0.1"],\ ["express", "npm:4.21.0"],\ ["http-errors", "npm:2.0.0"],\ ["jest", "virtual:f3f18773c1f2811e8d448670abfc3fed18cdffc11b444f7cbc3548ae5868e74f3c4ee449327c1fc9c24ce0732ee02505411a07539789bec8257188d17bbada1f#npm:29.7.0"],\ @@ -1449,6 +1452,7 @@ const RAW_RUNTIME_STATE = ["eslint-plugin-jest", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:26.9.0"],\ ["eslint-plugin-n", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:15.7.0"],\ ["eslint-plugin-promise", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:6.6.0"],\ + ["eslint-plugin-security", "npm:3.0.1"],\ ["gtoken", "npm:7.1.0"],\ ["jest", "virtual:f3f18773c1f2811e8d448670abfc3fed18cdffc11b444f7cbc3548ae5868e74f3c4ee449327c1fc9c24ce0732ee02505411a07539789bec8257188d17bbada1f#npm:29.7.0"],\ ["js-yaml", "npm:4.1.0"],\ @@ -1469,6 +1473,7 @@ const RAW_RUNTIME_STATE = ["eslint-plugin-jest", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:26.9.0"],\ ["eslint-plugin-n", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:15.7.0"],\ ["eslint-plugin-promise", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:6.6.0"],\ + ["eslint-plugin-security", "npm:3.0.1"],\ ["jest", "virtual:f3f18773c1f2811e8d448670abfc3fed18cdffc11b444f7cbc3548ae5868e74f3c4ee449327c1fc9c24ce0732ee02505411a07539789bec8257188d17bbada1f#npm:29.7.0"],\ ["jest-date-mock", "npm:1.0.10"]\ ],\ @@ -1487,6 +1492,7 @@ const RAW_RUNTIME_STATE = ["eslint-plugin-jest", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:26.9.0"],\ ["eslint-plugin-n", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:15.7.0"],\ ["eslint-plugin-promise", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:6.6.0"],\ + ["eslint-plugin-security", "npm:3.0.1"],\ ["express", "npm:4.21.0"],\ ["http-errors", "npm:2.0.0"],\ ["jest", "virtual:f3f18773c1f2811e8d448670abfc3fed18cdffc11b444f7cbc3548ae5868e74f3c4ee449327c1fc9c24ce0732ee02505411a07539789bec8257188d17bbada1f#npm:29.7.0"],\ @@ -1511,6 +1517,7 @@ const RAW_RUNTIME_STATE = ["eslint-plugin-jest", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:26.9.0"],\ ["eslint-plugin-n", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:15.7.0"],\ ["eslint-plugin-promise", "virtual:feaa032e1ffbff8da5dad8429b8494744ade8373389ef8e26f3d1f1980ceff327ab996fdc7c1977df285edeb918372fa01d7c87d79c9d7218f8701c70203bfe5#npm:6.6.0"],\ + ["eslint-plugin-security", "npm:3.0.1"],\ ["http-errors", "npm:2.0.0"],\ ["jest", "virtual:f3f18773c1f2811e8d448670abfc3fed18cdffc11b444f7cbc3548ae5868e74f3c4ee449327c1fc9c24ce0732ee02505411a07539789bec8257188d17bbada1f#npm:29.7.0"],\ ["lodash", "npm:4.17.21"],\ @@ -6055,6 +6062,16 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["eslint-plugin-security", [\ + ["npm:3.0.1", {\ + "packageLocation": "./.yarn/cache/eslint-plugin-security-npm-3.0.1-c5165134bf-6b85feabe3.zip/node_modules/eslint-plugin-security/",\ + "packageDependencies": [\ + ["eslint-plugin-security", "npm:3.0.1"],\ + ["safe-regex", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["eslint-plugin-vitest", [\ ["npm:0.4.1", {\ "packageLocation": "./.yarn/cache/eslint-plugin-vitest-npm-0.4.1-77fbd88a02-d06a7efec2.zip/node_modules/eslint-plugin-vitest/",\ @@ -10205,6 +10222,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["regexp-tree", [\ + ["npm:0.1.27", {\ + "packageLocation": "./.yarn/cache/regexp-tree-npm-0.1.27-e0324e6a9c-f636f44b4a.zip/node_modules/regexp-tree/",\ + "packageDependencies": [\ + ["regexp-tree", "npm:0.1.27"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["regexp.prototype.flags", [\ ["npm:1.5.2", {\ "packageLocation": "./.yarn/cache/regexp.prototype.flags-npm-1.5.2-a44e05d7d9-0f3fc4f580.zip/node_modules/regexp.prototype.flags/",\ @@ -10474,6 +10500,16 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["safe-regex", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/safe-regex-npm-2.1.1-4438cded67-53eb5d3ecf.zip/node_modules/safe-regex/",\ + "packageDependencies": [\ + ["safe-regex", "npm:2.1.1"],\ + ["regexp-tree", "npm:0.1.27"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["safe-regex-test", [\ ["npm:1.0.3", {\ "packageLocation": "./.yarn/cache/safe-regex-test-npm-1.0.3-97fe5cc608-900bf7c98d.zip/node_modules/safe-regex-test/",\ diff --git a/.yarn/cache/eslint-plugin-security-npm-3.0.1-c5165134bf-6b85feabe3.zip b/.yarn/cache/eslint-plugin-security-npm-3.0.1-c5165134bf-6b85feabe3.zip new file mode 100644 index 0000000000..0fe59aba08 Binary files /dev/null and b/.yarn/cache/eslint-plugin-security-npm-3.0.1-c5165134bf-6b85feabe3.zip differ diff --git a/.yarn/cache/regexp-tree-npm-0.1.27-e0324e6a9c-f636f44b4a.zip b/.yarn/cache/regexp-tree-npm-0.1.27-e0324e6a9c-f636f44b4a.zip new file mode 100644 index 0000000000..b3d9f6388a Binary files /dev/null and b/.yarn/cache/regexp-tree-npm-0.1.27-e0324e6a9c-f636f44b4a.zip differ diff --git a/.yarn/cache/safe-regex-npm-2.1.1-4438cded67-53eb5d3ecf.zip b/.yarn/cache/safe-regex-npm-2.1.1-4438cded67-53eb5d3ecf.zip new file mode 100644 index 0000000000..35177266a3 Binary files /dev/null and b/.yarn/cache/safe-regex-npm-2.1.1-4438cded67-53eb5d3ecf.zip differ diff --git a/backend/lib/app.js b/backend/lib/app.js index e578f5f6a2..7f820ba9e9 100644 --- a/backend/lib/app.js +++ b/backend/lib/app.js @@ -23,6 +23,12 @@ const { healthCheck } = require('./healthz') const { port, metricsPort } = config const periodSeconds = config.readinessProbe?.periodSeconds || 10 +// protect against Prototype Pollution vulnerabilities +for (const ctor of [Object, Function, Array, String, Number, Boolean]) { + Object.freeze(ctor) + Object.freeze(ctor.prototype) +} + // resolve pathnames const PUBLIC_DIRNAME = resolve(join(__dirname, '..', 'public')) const INDEX_FILENAME = join(PUBLIC_DIRNAME, 'index.html') diff --git a/backend/lib/cache/tickets.js b/backend/lib/cache/tickets.js index a25411a382..13bf0178aa 100644 --- a/backend/lib/cache/tickets.js +++ b/backend/lib/cache/tickets.js @@ -9,8 +9,8 @@ const _ = require('lodash') const logger = require('../logger') function init () { - const issues = {} - const commentsForIssues = {} // we could also think of getting rid of the comments cache + const issues = new Map() + const commentsForIssues = new Map() // we could also think of getting rid of the comments cache const emitter = new EventEmitter() function on (eventName, listener) { @@ -38,15 +38,16 @@ function init () { } function getIssues () { - return _.values(issues) + return Array.from(issues.values()) } function getCommentsForIssue ({ issueNumber }) { - return _.values(getCommentsForIssueCache({ issueNumber })) + const comments = getCommentsForIssueCache({ issueNumber }) + return Array.from(comments.values()) } function getIssue (number) { - return issues[number] + return issues.get(number) } function getIssueNumbers () { @@ -62,23 +63,25 @@ function init () { } function getCommentsForIssueCache ({ issueNumber }) { - if (!commentsForIssues[issueNumber]) { - commentsForIssues[issueNumber] = {} + if (!commentsForIssues.has(issueNumber)) { + commentsForIssues.set(issueNumber, new Map()) } - return commentsForIssues[issueNumber] + return commentsForIssues.get(issueNumber) } function addOrUpdateIssues ({ issues }) { - _.forEach(issues, issue => addOrUpdateIssue({ issue })) + for (const issue of issues) { + addOrUpdateIssue({ issue }) + } } function addOrUpdateIssue ({ issue }) { - updateIfNewer('issue', issues, issue, 'number') + updateIfNewer('issue', issues, issue) } function addOrUpdateComment ({ issueNumber, comment }) { const comments = getCommentsForIssueCache({ issueNumber }) - updateIfNewer('comment', comments, comment, 'id') + updateIfNewer('comment', comments, comment) } function removeIssue ({ issue }) { @@ -87,29 +90,33 @@ function init () { const comments = getCommentsForIssueCache({ issueNumber }) - _.unset(issues, issueNumber) - _.unset(commentsForIssues, issueNumber) + issues.delete(issueNumber) + commentsForIssues.delete(issueNumber) emitIssueDeleted(issue) - _.forEach(comments, emitCommmentDeleted) + for (const comment of comments.values()) { + emitCommmentDeleted(comment) + } } function removeComment ({ issueNumber, comment }) { const identifier = comment.metadata.id logger.trace('removing comment', identifier, 'of issue', issueNumber) const commentsForIssuesCache = getCommentsForIssueCache({ issueNumber }) - _.unset(commentsForIssuesCache, identifier) + commentsForIssuesCache.delete(identifier) emitCommmentDeleted(comment) } - function updateIfNewer (kind, cachedList, item, itemIdentifier) { - const identifier = item.metadata[itemIdentifier] - const cachedItem = cachedList[identifier] + function updateIfNewer (kind, cachedMap, item) { + const identifier = kind === 'issue' + ? item.metadata.number + : item.metadata.id + const cachedItem = cachedMap.get(identifier) if (cachedItem) { if (isCachedItemOlder(cachedItem, item)) { if (!_.isEqual(cachedItem, item)) { logger.trace('updating', kind, identifier) - cachedList[identifier] = item + cachedMap.set(identifier, item) emitModified(kind, item) } } else { @@ -117,7 +124,7 @@ function init () { } } else { logger.trace('adding new', kind, identifier) - cachedList[identifier] = item + cachedMap.set(identifier, item) emitAdded(kind, item) } return item diff --git a/backend/lib/config/gardener.js b/backend/lib/config/gardener.js index 093579b370..1be47f2fa6 100644 --- a/backend/lib/config/gardener.js +++ b/backend/lib/config/gardener.js @@ -131,30 +131,22 @@ function parseConfigValue (value, type) { } } -function getValueFromFile (filePath, type) { - try { - const value = fs.readFileSync(filePath, 'utf8') - return parseConfigValue(value, type) - } catch (error) { - return undefined - } -} - -function getValueFromEnvironmentOrFile (env, environmentVariableName, filePath, type) { - const value = parseConfigValue(env[environmentVariableName], type) - if (value !== undefined) { - return value - } - - if (filePath) { - return getValueFromFile(filePath, type) - } -} - module.exports = { assignConfigFromEnvironmentAndFileSystem (config, env) { - for (const { environmentVariableName, configPath, filePath, type = 'String' } of configMappings) { - const value = getValueFromEnvironmentOrFile(env, environmentVariableName, filePath, type) + for (const configMapping of configMappings) { + const { + environmentVariableName, + configPath, + filePath, + type = 'String' + } = configMapping + let rawValue = env[environmentVariableName] // eslint-disable-line security/detect-object-injection + if (!rawValue && filePath) { + try { + rawValue = fs.readFileSync(filePath, 'utf8') // eslint-disable-line security/detect-non-literal-fs-filename + } catch (err) { /* ignore error */ } + } + const value = parseConfigValue(rawValue, type) if (value !== undefined) { _.set(config, configPath, value) @@ -224,6 +216,7 @@ module.exports = { return config }, readConfig (path) { - return yaml.load(fs.readFileSync(path, 'utf8')) + const data = fs.readFileSync(path, 'utf8') // eslint-disable-line security/detect-non-literal-fs-filename + return yaml.load(data) } } diff --git a/backend/lib/hooks.js b/backend/lib/hooks.js index e9b68edeac..a89626972d 100644 --- a/backend/lib/hooks.js +++ b/backend/lib/hooks.js @@ -50,11 +50,12 @@ class LifecycleHooks { this.io = io(server, cache) // register watches for (const [key, watch] of Object.entries(watches)) { - if (informers[key]) { + const informer = informers[key] // eslint-disable-line security/detect-object-injection + if (informer) { if (key === 'leases') { - watch(this.io, informers[key], { signal: this.ac.signal }) + watch(this.io, informer, { signal: this.ac.signal }) } else { - watch(this.io, informers[key]) + watch(this.io, informer) } } } diff --git a/backend/lib/middleware.js b/backend/lib/middleware.js index 541f451849..5a4a7cba27 100644 --- a/backend/lib/middleware.js +++ b/backend/lib/middleware.js @@ -55,10 +55,10 @@ function errorToLocals (err, req) { ? { name, stack } : { name } let code = 500 - let reason = STATUS_CODES[code] + let reason = _.get(STATUS_CODES, [code]) if (isHttpError(err)) { code = err.statusCode - reason = STATUS_CODES[code] + reason = _.get(STATUS_CODES, [code]) } if (code < 100 || code >= 600) { code = 500 diff --git a/backend/lib/routes/config.js b/backend/lib/routes/config.js index c9c6734318..38bf74a398 100644 --- a/backend/lib/routes/config.js +++ b/backend/lib/routes/config.js @@ -41,8 +41,9 @@ router.route('/') function sanitizeFrontendConfig (frontendConfig) { const converter = markdown.createConverter() const convertAndSanitize = (obj, key) => { - if (obj[key]) { - obj[key] = converter.makeSanitizedHtml(obj[key]) + const value = obj[key] // eslint-disable-line security/detect-object-injection -- key is a local fixed string + if (value) { + obj[key] = converter.makeSanitizedHtml(value) // eslint-disable-line security/detect-object-injection -- key is a local fixed string } } diff --git a/backend/lib/routes/terminals.js b/backend/lib/routes/terminals.js index 104e5f0298..344bd5da82 100644 --- a/backend/lib/routes/terminals.js +++ b/backend/lib/routes/terminals.js @@ -8,7 +8,6 @@ const express = require('express') const { terminals, authorization } = require('../services') -const _ = require('lodash') const { UnprocessableEntity } = require('http-errors') const { metricsRoute } = require('../middleware') @@ -41,10 +40,33 @@ router.route('/') const { method, params: body } = req.body - if (!_.includes(['create', 'fetch', 'list', 'config', 'remove', 'heartbeat', 'listProjectTerminalShortcuts'], method)) { - throw new UnprocessableEntity(`${method} not allowed for terminals`) + let terminalOperation + switch (method) { + case 'create': + terminalOperation = terminals.create + break + case 'fetch': + terminalOperation = terminals.fetch + break + case 'list': + terminalOperation = terminals.list + break + case 'config': + terminalOperation = terminals.config + break + case 'remove': + terminalOperation = terminals.remove + break + case 'heartbeat': + terminalOperation = terminals.heartbeat + break + case 'listProjectTerminalShortcuts': + terminalOperation = terminals.listProjectTerminalShortcuts + break + default: + throw new UnprocessableEntity(`${method} not allowed for terminals`) } - res.send(await terminals[method]({ user, body })) + res.send(await terminalOperation.call(terminals, { user, body })) } catch (err) { next(err) } diff --git a/backend/lib/security/index.js b/backend/lib/security/index.js index 658f97103e..b25735185c 100644 --- a/backend/lib/security/index.js +++ b/backend/lib/security/index.js @@ -280,9 +280,9 @@ async function authorizationCallback (req, res) { secure: true, path: '/' } - const stateObject = {} + let stateObject = {} if (COOKIE_STATE in req.cookies) { - Object.assign(stateObject, req.cookies[COOKIE_STATE]) + stateObject = req.cookies[COOKIE_STATE] // eslint-disable-line security/detect-object-injection -- COOKIE_STATE is a constant res.clearCookie(COOKIE_STATE, options) } const { @@ -299,7 +299,7 @@ async function authorizationCallback (req, res) { state } if (COOKIE_CODE_VERIFIER in req.cookies) { - checks.code_verifier = req.cookies[COOKIE_CODE_VERIFIER] + checks.code_verifier = req.cookies[COOKIE_CODE_VERIFIER] // eslint-disable-line security/detect-object-injection -- COOKIE_CODE_VERIFIER is a constant res.clearCookie(COOKIE_CODE_VERIFIER, options) } const tokenSet = await authorizationCodeExchange(backendRedirectUri, parameters, checks) @@ -316,8 +316,9 @@ function isXmlHttpRequest ({ headers = {} }) { } function getAccessToken (cookies) { - const [header, payload] = split(cookies[COOKIE_HEADER_PAYLOAD], '.') - const signature = cookies[COOKIE_SIGNATURE] + const headerAndPayload = cookies[COOKIE_HEADER_PAYLOAD] // eslint-disable-line security/detect-object-injection -- COOKIE_HEADER_PAYLOAD is a constant + const [header, payload] = split(headerAndPayload, '.') + const signature = cookies[COOKIE_SIGNATURE] // eslint-disable-line security/detect-object-injection -- COOKIE_SIGNATURE is a constant if (header && payload && signature) { return join([header, payload, signature], '.') } @@ -360,7 +361,7 @@ function csrfProtection (req) { async function getTokenSet (cookies) { const accessToken = getAccessToken(cookies) - const encryptedValues = cookies[COOKIE_TOKEN] + const encryptedValues = cookies[COOKIE_TOKEN] // eslint-disable-line security/detect-object-injection -- COOKIE_TOKEN is a constant if (!encryptedValues) { throw createError(401, 'No bearer token found in request', { code: 'ERR_JWE_NOT_FOUND' }) } diff --git a/backend/lib/services/members/Member.js b/backend/lib/services/members/Member.js index 0929a1a557..32c85a61e9 100644 --- a/backend/lib/services/members/Member.js +++ b/backend/lib/services/members/Member.js @@ -8,7 +8,14 @@ const { pick } = require('lodash') -const PROPERTY_NAMES = ['createdBy', 'creationTimestamp', 'deletionTimestamp', 'description', 'kubeconfig', 'orphaned'] +const PROPERTY_NAMES = Object.freeze([ + 'createdBy', + 'creationTimestamp', + 'deletionTimestamp', + 'description', + 'kubeconfig', + 'orphaned' +]) class Member { constructor (username, { roles, extensions } = {}) { @@ -32,6 +39,10 @@ class Member { name: username } } + + static get allowedExtensionProperties () { + return PROPERTY_NAMES + } } module.exports = Member diff --git a/backend/lib/services/members/SubjectList.js b/backend/lib/services/members/SubjectList.js index 8c1799e3cd..caf3280cf3 100644 --- a/backend/lib/services/members/SubjectList.js +++ b/backend/lib/services/members/SubjectList.js @@ -50,30 +50,43 @@ class SubjectList { const id = item.id if (_.startsWith(id, `system:serviceaccount:${namespace}:`)) { const extensions = {} - if (serviceAccountItems[id]) { - _.assign(extensions, serviceAccountItems[id].extensions) - delete serviceAccountItems[id] + const serviceAccountItem = serviceAccountItemMap.get(id) + if (serviceAccountItem) { + Object.assign(extensions, serviceAccountItem.extensions) + serviceAccountItemMap.delete(id) } else { - _.set(extensions, 'orphaned', true) + extensions.orphaned = true } item.extend(extensions) } } - const serviceAccountItems = _ + const toMap = obj => new Map(Object.entries(obj)) + + const serviceAccountItemMap = _ .chain(serviceAccounts) .map(createServiceAccountItem) .keyBy('id') + .thru(toMap) .value() - this.subjectListItems = _ + const subjectListItemMap = _ .chain(subjects) .map(createItem) .groupBy('id') .mapValues(createGroup) .forEach(extendItem) - .assign(serviceAccountItems) + .thru(toMap) .value() + + this.subjectListItemMap = new Map([ + ...subjectListItemMap, + ...serviceAccountItemMap + ]) + } + + get subjectListItems () { + return Object.fromEntries(this.subjectListItemMap) } get subjects () { @@ -96,19 +109,19 @@ class SubjectList { } get (id) { - return this.subjectListItems[id] + return this.subjectListItemMap.get(id) } set (id, item) { - this.subjectListItems[id] = item + this.subjectListItemMap.set(id, item) } delete (id) { - delete this.subjectListItems[id] + this.subjectListItemMap.delete(id) } has (id) { - return !!this.subjectListItems[id] + return this.subjectListItemMap.has(id) } } diff --git a/backend/lib/services/members/SubjectListItem.js b/backend/lib/services/members/SubjectListItem.js index df14be53f4..037ae7b10d 100644 --- a/backend/lib/services/members/SubjectListItem.js +++ b/backend/lib/services/members/SubjectListItem.js @@ -10,6 +10,11 @@ const _ = require('lodash') const Member = require('./Member') +const allowedExtensionProperties = Object.freeze([ + ...Member.allowedExtensionProperties, + 'secrets' +]) + class SubjectListItem { constructor (index = SubjectListItem.NOT_IN_LIST) { this.index = index @@ -32,7 +37,7 @@ class SubjectListItem { extend (obj) { const before = _.cloneDeep(this.extensions) - const after = _.assign(this.extensions, obj) + const after = _.assign(this.extensions, _.pick(obj, allowedExtensionProperties)) return !_.isEqual(before, after) } diff --git a/backend/lib/services/shoots.js b/backend/lib/services/shoots.js index 5bb1b8a2c2..60020a475d 100644 --- a/backend/lib/services/shoots.js +++ b/backend/lib/services/shoots.js @@ -47,12 +47,24 @@ exports.list = async function ({ user, namespace, labelSelector }) { .filter(projectFilter(user, false)) .map('spec.namespace') .value() - const statuses = await Promise.allSettled(namespaces.map(namespace => authorization.canListShoots(user, namespace))) + + const results = await Promise.allSettled(namespaces.map(async namespace => { + const allowed = await authorization.canListShoots(user, namespace) + return [namespace, allowed] + })) + + const allowedNamespaceMap = _ + .chain(results) + .filter(['status', 'fulfilled']) + .map('value') + .thru(value => new Map(value)) + .value() + return { apiVersion: 'v1', kind: 'List', items: namespaces - .filter((_, i) => statuses[i].status === 'fulfilled' && statuses[i].value) + .filter(namespace => allowedNamespaceMap.get(namespace)) .flatMap(namespace => cache.getShoots(namespace, query)) } } @@ -340,7 +352,7 @@ exports.info = async function ({ user, namespace, name }) { if (key === 'kubeconfig') { try { const kubeconfigObject = cleanKubeconfig(value) - data[key] = kubeconfigObject.toYAML() + data.kubeconfig = kubeconfigObject.toYAML() } catch (err) { logger.error('failed to clean kubeconfig', err) } diff --git a/backend/lib/services/terminals/index.js b/backend/lib/services/terminals/index.js index 851ce25b6e..f2f02922e4 100644 --- a/backend/lib/services/terminals/index.js +++ b/backend/lib/services/terminals/index.js @@ -102,7 +102,7 @@ function findImageDescription (containerImage, containerImageDescriptions) { .find(({ image }) => { if (_.startsWith(image, '/') && _.endsWith(image, '/')) { image = image.substring(1, image.length - 1) - return new RegExp(image).test(containerImage) + return new RegExp(image).test(containerImage) // eslint-disable-line security/detect-non-literal-regexp } return image === containerImage }) diff --git a/backend/lib/utils/index.js b/backend/lib/utils/index.js index e96b9aa947..d3375646d6 100644 --- a/backend/lib/utils/index.js +++ b/backend/lib/utils/index.js @@ -113,20 +113,63 @@ function trimProject (project) { return project } +function parseSelector (selector = '') { + let notOperator + let key + let operator + let value = '' + + if (selector.startsWith('!')) { + notOperator = '!' + selector = selector.slice(1) + } + + const index = selector.search(/[=!]/) + if (index !== -1) { + key = selector.slice(0, index) + const remainingPart = selector.slice(index) + if (remainingPart.startsWith('==')) { + operator = '==' + value = remainingPart.slice(2) + } else if (remainingPart.startsWith('=')) { + operator = '=' + value = remainingPart.slice(1) + } else if (remainingPart.startsWith('!=')) { + operator = '!=' + value = remainingPart.slice(2) + } else { + operator = '' + value = remainingPart + } + } else { + key = selector + } + + const isValidPart = part => /^[a-zA-Z0-9._/-]*$/.test(part) + + if (!isValidPart(key) || !isValidPart(value)) { + return + } + + if (notOperator) { + if (!operator) { + return { op: NOT_EXISTS, key } + } + } else if (!operator) { + return { op: EXISTS, key } + } else if (operator === '!=') { + return { op: NOT_EQUAL, key, value } + } else if (operator === '=' || operator === '==') { + return { op: EQUAL, key, value } + } +} + function parseSelectors (selectors) { const items = [] for (const selector of selectors) { - const [, notOperator, key, operator, value = ''] = /^(!)?([a-zA-Z0-9._/-]+)(=|==|!=)?([a-zA-Z0-9._-]+)?$/.exec(selector) ?? [] - if (notOperator) { - if (!operator) { - items.push({ op: NOT_EXISTS, key }) - } - } else if (!operator) { - items.push({ op: EXISTS, key }) - } else if (operator === '!=') { - items.push({ op: NOT_EQUAL, key, value }) - } else if (operator === '=' || operator === '==') { - items.push({ op: EQUAL, key, value }) + const item = parseSelector(selector) + if (item) { + items.push(item) } } return items @@ -136,7 +179,7 @@ function filterBySelectors (selectors) { return item => { const labels = item.metadata.labels ?? {} for (const { op, key, value } of selectors) { - const labelValue = labels[key] ?? '' + const labelValue = _.get(labels, [key], '') switch (op) { case NOT_EXISTS: { if (key in labels) { diff --git a/backend/package.json b/backend/package.json index acee9ab7ff..df0c39a77d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -82,6 +82,7 @@ "eslint-plugin-jest": "^26.9.0", "eslint-plugin-n": "^15.7.0", "eslint-plugin-promise": "^6.6.0", + "eslint-plugin-security": "^3.0.1", "fast-json-patch": "^3.1.1", "jest": "^29.7.0", "p-event": "^4.2.0", @@ -100,6 +101,7 @@ ], "extends": [ "standard", + "plugin:security/recommended-legacy", "plugin:jest/recommended" ], "globals": { @@ -117,7 +119,21 @@ "overrides": [ { "files": [ - "**/__tests__/*.js", + "**/__fixtures__/**", + "**/__mocks__/**", + "**/__tests__/**", + "**/test/**", + "jest.setup.js" + ], + "rules": { + "security/detect-object-injection": "off", + "security/detect-possible-timing-attacks": "off", + "security/detect-unsafe-regex": "off" + } + }, + { + "files": [ + "**/__tests__/**", "**/test/**/*.spec.js" ], "env": { diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 1da1401246..3c902d68ad 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -17,6 +17,7 @@ module.exports = { extends: [ 'eslint:recommended', 'standard', + 'plugin:security/recommended-legacy', 'plugin:vue/vue3-recommended', 'plugin:vitest/recommended', ], @@ -101,4 +102,19 @@ module.exports = { 'vue/require-default-prop': 'off', 'vue/order-in-components': 'error', }, + overrides: [ + { + files: [ + '**/__fixtures__/**', + '**/__mocks__/**', + '**/__tests__/**', + 'vite.config.js', + ], + rules: { + 'security/detect-object-injection': 'off', + 'security/detect-non-literal-fs-filename': 'off', + 'security/detect-unsafe-regex': 'off', + }, + }, + ], } diff --git a/frontend/__tests__/utils/hibernationSchedule.spec.js b/frontend/__tests__/utils/hibernationSchedule.spec.js index 98192d4bbc..d974fef567 100644 --- a/frontend/__tests__/utils/hibernationSchedule.spec.js +++ b/frontend/__tests__/utils/hibernationSchedule.spec.js @@ -5,6 +5,7 @@ // import { + parseCronExpression, scheduleEventsFromCrontabBlock, crontabBlocksFromScheduleEvents, } from '@/utils/hibernationSchedule' @@ -14,6 +15,28 @@ const currentLocation = moment.tz.guess() describe('utils', () => { describe('hibernationSchedule', () => { + describe('#parseCronExpression', () => { + it('should parse a crontab expressions', () => { + expect(parseCronExpression('0 8 * * 1')).toEqual({ + hour: '8', + minute: '0', + weekdays: '1', + }) + expect(parseCronExpression('00 12 * * 1,2,3,4,5').weekdays).toBe('1,2,3,4,5') + expect(parseCronExpression('00 12 * * MON,TUE,WED,THU,FRI').weekdays).toBe('1,2,3,4,5') + expect(parseCronExpression('00 12 * * 1-5').weekdays).toBe('1,2,3,4,5') + expect(parseCronExpression('00 12 * * 7').weekdays).toBe('0') + expect(parseCronExpression('00 12 * * *').weekdays).toBe('1,2,3,4,5,6,0') + expect(parseCronExpression('00 12 * * SAT,SUN').weekdays).toBe('6,0') + }) + + it('should fail to parse a crontab expressions', () => { + expect(parseCronExpression('00 12 * * FR')).toBeUndefined() + expect(parseCronExpression('00 12 * * 8')).toBeUndefined() + expect(parseCronExpression('00 12 0 * 1')).toBeUndefined() + expect(parseCronExpression('00 12 * 0 1')).toBeUndefined() + }) + }) describe('#scheduleEventsFromCrontabBlock', () => { it('should parse a simple crontab block', () => { const crontabBlock = { diff --git a/frontend/__tests__/utils/index.spec.js b/frontend/__tests__/utils/index.spec.js index 856d475cfc..7fcc543e73 100644 --- a/frontend/__tests__/utils/index.spec.js +++ b/frontend/__tests__/utils/index.spec.js @@ -16,6 +16,7 @@ import { getTimeStringFrom, parseNumberWithMagnitudeSuffix, normalizeVersion, + isEmail, } from '@/utils' import { @@ -471,6 +472,29 @@ describe('utils', () => { }) }) + describe('isEmail', () => { + it('should return true for valid emails', () => { + expect(isEmail('a@b.de')).toBe(true) + expect(isEmail('a@b.a.r.com')).toBe(true) + expect(isEmail('abcdefghijklmnopqrstuvwxyz0123456789.!#$%&’*+/=?^_`{|}~-@bar.com')).toBe(true) + }) + + it('should return false for valid emails', () => { + expect(isEmail(undefined)).toBe(false) + expect(isEmail('')).toBe(false) + expect(isEmail('a'.repeat(321))).toBe(false) + expect(isEmail('bar.com')).toBe(false) + expect(isEmail('a@b@bar.com')).toBe(false) + expect(isEmail('a@b@bar.com')).toBe(false) + expect(isEmail('@bar.com')).toBe(false) + expect(isEmail('a@bar')).toBe(false) + expect(isEmail('@bar.com')).toBe(false) + expect(isEmail('a@bar.c')).toBe(false) + expect(isEmail('a@bar..com')).toBe(false) + expect(isEmail('abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789@bar.com')).toBe(false) + }) + }) + describe('normalizeVersion', () => { it('should fill missing segments', () => { expect(normalizeVersion('1.12')).toBe('1.12.0') diff --git a/frontend/package.json b/frontend/package.json index 3a8aad9626..082a325458 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -87,6 +87,7 @@ "eslint-plugin-import-newlines": "^1.4.0", "eslint-plugin-n": "^15.7.0", "eslint-plugin-promise": "^6.6.0", + "eslint-plugin-security": "^3.0.1", "eslint-plugin-vitest": "^0.4.0", "eslint-plugin-vue": "^9.23.0", "jsdom": "^25.0.0", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 69cf43ebbd..a2994b84ef 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -33,6 +33,8 @@ import { useProjectStore } from '@/store/project' import { useCustomColors } from '@/composables/useCustomColors' +import { get } from '@/lodash' + const theme = useTheme() const route = useRoute() const localStorageStore = useLocalStorageStore() @@ -62,7 +64,9 @@ const { system } = useColorMode({ }, }) -provide('getColorCode', value => theme.current.value?.colors[value]) +provide('getColorCode', value => { + return get(theme.current.value, ['colors', value]) +}) const bus = useEventBus('esc-pressed') diff --git a/frontend/src/components/GMainNavigation.vue b/frontend/src/components/GMainNavigation.vue index 0055641622..cffc8ac2f4 100644 --- a/frontend/src/components/GMainNavigation.vue +++ b/frontend/src/components/GMainNavigation.vue @@ -491,7 +491,7 @@ function highlightProjectWithKeys (keyDirection) { currentHighlightedIndex++ } - const newHighlightedProject = sortedAndFilteredProjectList.value[currentHighlightedIndex] + const newHighlightedProject = sortedAndFilteredProjectList.value[currentHighlightedIndex] // eslint-disable-line security/detect-object-injection highlightedProjectName.value = newHighlightedProject.metadata.name if (currentHighlightedIndex >= numberOfVisibleProjects.value - 1) { diff --git a/frontend/src/components/GPositionalDropzone.vue b/frontend/src/components/GPositionalDropzone.vue index 347b608e85..7024cc13c1 100644 --- a/frontend/src/components/GPositionalDropzone.vue +++ b/frontend/src/components/GPositionalDropzone.vue @@ -188,10 +188,12 @@ export default { }, methods: { fillOnPosition (position) { - return this.mouseDown ? this.rect[position] : undefined + const { [position]: value } = this.rect + return this.mouseDown ? value : undefined }, strokeRectOnPosition (position) { - return this.mouseDown ? this.strokeRect[position] : undefined + const { [position]: value } = this.strokeRect + return this.mouseDown ? value : undefined }, dragOver ({ detail: { mouseOverId: position } }) { this.showIt = true @@ -199,13 +201,9 @@ export default { return } this.mouseDown = true - const newRect = {} - newRect[position] = '#d71e00' - this.rect = newRect + this.rect = { [position]: '#d71e00' } - const newStrokeRect = {} - newStrokeRect[position] = '#fff' - this.strokeRect = newStrokeRect + this.strokeRect = { [position]: '#fff' } this.currentPosition = position }, dragLeaveZone () { diff --git a/frontend/src/components/GShootSubscriptionStatus.vue b/frontend/src/components/GShootSubscriptionStatus.vue index da70b32ced..9bd6fd1466 100644 --- a/frontend/src/components/GShootSubscriptionStatus.vue +++ b/frontend/src/components/GShootSubscriptionStatus.vue @@ -125,7 +125,7 @@ const components = { } function resolveComponent (name) { - return components[name] + return components[name] // eslint-disable-line security/detect-object-injection } const { diff --git a/frontend/src/components/GTerminal.vue b/frontend/src/components/GTerminal.vue index 76318ebbe7..580b5805f0 100644 --- a/frontend/src/components/GTerminal.vue +++ b/frontend/src/components/GTerminal.vue @@ -601,7 +601,7 @@ export default { this.$emit('terminated') }, async configure (refName) { - this.loading[refName] = true + this.loading[refName] = true // eslint-disable-line security/detect-object-injection const { namespace, name, target } = this.data try { const { data: config } = await this.api.terminalConfig({ name, namespace, target }) @@ -610,7 +610,7 @@ export default { } catch (err) { this.showErrorSnackbarBottom(get(err, 'response.data.message', err.message)) } finally { - this.loading[refName] = false + this.loading[refName] = false // eslint-disable-line security/detect-object-injection } const initialState = { diff --git a/frontend/src/components/Secrets/GSecretDialogDDns.vue b/frontend/src/components/Secrets/GSecretDialogDDns.vue index 210569b3bb..41bfe77758 100644 --- a/frontend/src/components/Secrets/GSecretDialogDDns.vue +++ b/frontend/src/components/Secrets/GSecretDialogDDns.vue @@ -182,7 +182,10 @@ export default { zone: withFieldName('Zone', { required, format: withMessage('Must be fully qualified', value => { - return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.$/.test(value) + if (typeof value !== 'string' || value.length > 255) { + return false + } + return /^[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*\.$/.test(value) // eslint-disable-line security/detect-unsafe-regex }), }), } diff --git a/frontend/src/components/ShootAccessRestrictions/GAccessRestrictions.vue b/frontend/src/components/ShootAccessRestrictions/GAccessRestrictions.vue index e076774826..f329ef3fab 100644 --- a/frontend/src/components/ShootAccessRestrictions/GAccessRestrictions.vue +++ b/frontend/src/components/ShootAccessRestrictions/GAccessRestrictions.vue @@ -97,7 +97,7 @@ const { function getDisabled (key) { const value = getAccessRestrictionValue(key) - const { input } = accessRestrictionDefinitions.value[key] + const { input } = accessRestrictionDefinitions.value[key] // eslint-disable-line security/detect-object-injection const inverted = !!input?.inverted return !NAND(value, inverted) } diff --git a/frontend/src/components/ShootAddons/GManageAddons.vue b/frontend/src/components/ShootAddons/GManageAddons.vue index f93e943627..a7e6d05574 100644 --- a/frontend/src/components/ShootAddons/GManageAddons.vue +++ b/frontend/src/components/ShootAddons/GManageAddons.vue @@ -65,7 +65,7 @@ const { } = useShootContext() function getDisabledForbidden (name) { - const { forbidDisable = false } = addonDefinitions.value[name] + const { forbidDisable = false } = addonDefinitions.value[name] // eslint-disable-line security/detect-object-injection return !props.createMode && forbidDisable && getAddonEnabled(name) } diff --git a/frontend/src/components/ShootDetails/GGardenctlCommands.vue b/frontend/src/components/ShootDetails/GGardenctlCommands.vue index 08817f1c37..eca6be1f43 100644 --- a/frontend/src/components/ShootDetails/GGardenctlCommands.vue +++ b/frontend/src/components/ShootDetails/GGardenctlCommands.vue @@ -155,13 +155,17 @@ export default { }, methods: { visibilityIcon (index) { - return this.expansionPanel[index] ? 'mdi-eye-off' : 'mdi-eye' + return this.expansionPanel[index] // eslint-disable-line security/detect-object-injection + ? 'mdi-eye-off' + : 'mdi-eye' }, visibilityTitle (index) { - return this.expansionPanel[index] ? 'Hide Command' : 'Show Command' + return this.expansionPanel[index] // eslint-disable-line security/detect-object-injection + ? 'Hide Command' + : 'Show Command' }, toggle (index) { - this.expansionPanel[index] = !this.expansionPanel[index] + this.expansionPanel[index] = !this.expansionPanel[index] // eslint-disable-line security/detect-object-injection }, }, } diff --git a/frontend/src/components/ShootDetails/GShootDetailsCard.vue b/frontend/src/components/ShootDetails/GShootDetailsCard.vue index cbde6996c5..1786439c87 100644 --- a/frontend/src/components/ShootDetails/GShootDetailsCard.vue +++ b/frontend/src/components/ShootDetails/GShootDetailsCard.vue @@ -245,6 +245,7 @@ import utils, { } from '@/utils' import { + get, filter, map, } from '@/lodash' @@ -284,14 +285,12 @@ const isValidTerminationDate = computed(() => { return utils.isValidTerminationDate(shootExpirationTimestamp.value) }) -const addon = computed(() => { - return name => { - return shootAddons.value[name] || {} - } -}) +function getAddon (name) { + return get(shootAddons.value, [name], {}) +} const shootAddonNames = computed(() => { - return map(filter(shootAddonList, item => addon.value(item.name).enabled), 'title') + return map(filter(shootAddonList, item => getAddon(item.name).enabled), 'title') }) const slaDescriptionHtml = computed(() => { diff --git a/frontend/src/components/ShootMessages/GShootMessages.vue b/frontend/src/components/ShootMessages/GShootMessages.vue index 6fab1cc897..c2b172c009 100644 --- a/frontend/src/components/ShootMessages/GShootMessages.vue +++ b/frontend/src/components/ShootMessages/GShootMessages.vue @@ -94,6 +94,10 @@ const components = { 'g-force-delete-message': GForceDeleteMessage, } +function resolveComponent (name) { + return components[name] // eslint-disable-line security/detect-object-injection +} + const props = defineProps({ small: { type: Boolean, @@ -477,10 +481,6 @@ function colorForSeverity (severity) { return 'primary' } } - -function resolveComponent (name) { - return components[name] -}