diff --git a/lib/insecurity.ts b/lib/insecurity.ts new file mode 100644 index 00000000000..0e4e3d99389 --- /dev/null +++ b/lib/insecurity.ts @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2014-2024 Bjoern Kimminich & the OWASP Juice Shop contributors. + * SPDX-License-Identifier: MIT + */ + +import fs from 'fs' +import crypto from 'crypto' +import { type Request, type Response, type NextFunction } from 'express' +import { type UserModel } from 'models/user' +import expressJwt from 'express-jwt' +import jwt from 'jsonwebtoken' +import jws from 'jws' +import sanitizeHtmlLib from 'sanitize-html' +import sanitizeFilenameLib from 'sanitize-filename' +import * as utils from './utils' + +/* jslint node: true */ +// eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error +// @ts-expect-error FIXME no typescript definitions for z85 :( +import * as z85 from 'z85' + +export const publicKey = fs ? fs.readFileSync('encryptionkeys/jwt.pub', 'utf8') : 'placeholder-public-key' +const privateKey = '-----BEGIN RSA PRIVATE KEY-----\r\nMIICXAIBAAKBgQDNwqLEe9wgTXCbC7+RPdDbBbeqjdbs4kOPOIGzqLpXvJXlxxW8iMz0EaM4BKUqYsIa+ndv3NAn2RxCd5ubVdJJcX43zO6Ko0TFEZx/65gY3BE0O6syCEmUP4qbSd6exou/F+WTISzbQ5FBVPVmhnYhG/kpwt/cIxK5iUn5hm+4tQIDAQABAoGBAI+8xiPoOrA+KMnG/T4jJsG6TsHQcDHvJi7o1IKC/hnIXha0atTX5AUkRRce95qSfvKFweXdJXSQ0JMGJyfuXgU6dI0TcseFRfewXAa/ssxAC+iUVR6KUMh1PE2wXLitfeI6JLvVtrBYswm2I7CtY0q8n5AGimHWVXJPLfGV7m0BAkEA+fqFt2LXbLtyg6wZyxMA/cnmt5Nt3U2dAu77MzFJvibANUNHE4HPLZxjGNXN+a6m0K6TD4kDdh5HfUYLWWRBYQJBANK3carmulBwqzcDBjsJ0YrIONBpCAsXxk8idXb8jL9aNIg15Wumm2enqqObahDHB5jnGOLmbasizvSVqypfM9UCQCQl8xIqy+YgURXzXCN+kwUgHinrutZms87Jyi+D8Br8NY0+Nlf+zHvXAomD2W5CsEK7C+8SLBr3k/TsnRWHJuECQHFE9RA2OP8WoaLPuGCyFXaxzICThSRZYluVnWkZtxsBhW2W8z1b8PvWUE7kMy7TnkzeJS2LSnaNHoyxi7IaPQUCQCwWU4U+v4lD7uYBw00Ga/xt+7+UqFPlPVdz1yyr4q24Zxaw0LgmuEvgU5dycq8N7JxjTubX0MIRR+G9fmDBBl8=\r\n-----END RSA PRIVATE KEY-----' + +interface ResponseWithUser { + status: string + data: UserModel + iat: number + exp: number + bid: number +} + +interface IAuthenticatedUsers { + tokenMap: Record + idMap: Record + put: (token: string, user: ResponseWithUser) => void + get: (token: string) => ResponseWithUser | undefined + tokenOf: (user: UserModel) => string | undefined + from: (req: Request) => ResponseWithUser | undefined + updateFrom: (req: Request, user: ResponseWithUser) => any +} + +export const hash = (data: string) => crypto.createHash('md5').update(data).digest('hex') +export const hmac = (data: string) => crypto.createHmac('sha256', 'pa4qacea4VK9t9nGv7yZtwmj').update(data).digest('hex') + +export const cutOffPoisonNullByte = (str: string) => { + const nullByte = '%00' + if (utils.contains(str, nullByte)) { + return str.substring(0, str.indexOf(nullByte)) + } + return str +} + +export const isAuthorized = () => expressJwt(({ secret: publicKey }) as any) +export const denyAll = () => expressJwt({ secret: '' + Math.random() } as any) +export const authorize = (user = {}) => jwt.sign(user, privateKey, { expiresIn: '6h', algorithm: 'RS256' }) +export const verify = (token: string) => token ? (jws.verify as ((token: string, secret: string) => boolean))(token, publicKey) : false +export const decode = (token: string) => { return jws.decode(token)?.payload } + +export const sanitizeHtml = (html: string) => sanitizeHtmlLib(html) +export const sanitizeLegacy = (input = '') => input.replace(/<(?:\w+)\W+?[\w]/gi, '') +export const sanitizeFilename = (filename: string) => sanitizeFilenameLib(filename) +export const sanitizeSecure = (html: string): string => { + const sanitized = sanitizeHtml(html) + if (sanitized === html) { + return html + } else { + return sanitizeSecure(sanitized) + } +} + +export const authenticatedUsers: IAuthenticatedUsers = { + tokenMap: {}, + idMap: {}, + put: function (token: string, user: ResponseWithUser) { + this.tokenMap[token] = user + this.idMap[user.data.id] = token + }, + get: function (token: string) { + return token ? this.tokenMap[utils.unquote(token)] : undefined + }, + tokenOf: function (user: UserModel) { + return user ? this.idMap[user.id] : undefined + }, + from: function (req: Request) { + const token = utils.jwtFrom(req) + return token ? this.get(token) : undefined + }, + updateFrom: function (req: Request, user: ResponseWithUser) { + const token = utils.jwtFrom(req) + this.put(token, user) + } +} + +export const userEmailFrom = ({ headers }: any) => { + return headers ? headers['x-user-email'] : undefined +} + +export const generateCoupon = (discount: number, date = new Date()) => { + const coupon = utils.toMMMYY(date) + '-' + discount + return z85.encode(coupon) +} + +export const discountFromCoupon = (coupon: string) => { + if (coupon) { + const decoded = z85.decode(coupon) + if (decoded && (hasValidFormat(decoded.toString()) != null)) { + const parts = decoded.toString().split('-') + const validity = parts[0] + if (utils.toMMMYY(new Date()) === validity) { + const discount = parts[1] + return parseInt(discount) + } + } + } + return undefined +} + +function hasValidFormat (coupon: string) { + return coupon.match(/(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)[0-9]{2}-[0-9]{2}/) +} + +// vuln-code-snippet start redirectCryptoCurrencyChallenge redirectChallenge +export const redirectAllowlist = new Set([ + 'https://github.com/juice-shop/juice-shop', + 'https://blockchain.info/address/1AbKfgvw9psQ41NbLi8kufDQTezwG8DRZm', // vuln-code-snippet vuln-line redirectCryptoCurrencyChallenge + 'https://explorer.dash.org/address/Xr556RzuwX6hg5EGpkybbv5RanJoZN17kW', // vuln-code-snippet vuln-line redirectCryptoCurrencyChallenge + 'https://etherscan.io/address/0x0f933ab9fcaaa782d0279c300d73750e1311eae6', // vuln-code-snippet vuln-line redirectCryptoCurrencyChallenge + 'http://shop.spreadshirt.com/juiceshop', + 'http://shop.spreadshirt.de/juiceshop', + 'https://www.stickeryou.com/products/owasp-juice-shop/794', + 'http://leanpub.com/juice-shop' +]) + +export const isRedirectAllowed = (url: string) => { + let allowed = false + for (const allowedUrl of redirectAllowlist) { + allowed = allowed || url.includes(allowedUrl) // vuln-code-snippet vuln-line redirectChallenge + } + return allowed +} +// vuln-code-snippet end redirectCryptoCurrencyChallenge redirectChallenge + +export const roles = { + customer: 'customer', + deluxe: 'deluxe', + accounting: 'accounting', + admin: 'admin' +} + +export const deluxeToken = (email: string) => { + const hmac = crypto.createHmac('sha256', privateKey) + return hmac.update(email + roles.deluxe).digest('hex') +} + +export const isAccounting = () => { + return (req: Request, res: Response, next: NextFunction) => { + const decodedToken = verify(utils.jwtFrom(req)) && decode(utils.jwtFrom(req)) + if (decodedToken?.data?.role === roles.accounting) { + next() + } else { + res.status(403).json({ error: 'Malicious activity detected' }) + } + } +} + +export const isDeluxe = (req: Request) => { + const decodedToken = verify(utils.jwtFrom(req)) && decode(utils.jwtFrom(req)) + return decodedToken?.data?.role === roles.deluxe && decodedToken?.data?.deluxeToken && decodedToken?.data?.deluxeToken === deluxeToken(decodedToken?.data?.email) +} + +export const isCustomer = (req: Request) => { + const decodedToken = verify(utils.jwtFrom(req)) && decode(utils.jwtFrom(req)) + return decodedToken?.data?.role === roles.customer +} + +export const appendUserId = () => { + return (req: Request, res: Response, next: NextFunction) => { + try { + req.body.UserId = authenticatedUsers.tokenMap[utils.jwtFrom(req)].data.id + next() + } catch (error: any) { + res.status(401).json({ status: 'error', message: error }) + } + } +} + +export const updateAuthenticatedUsers = () => (req: Request, res: Response, next: NextFunction) => { + const token = req.cookies.token || utils.jwtFrom(req) + if (token) { + jwt.verify(token, publicKey, (err: Error | null, decoded: any) => { + if (err === null) { + if (authenticatedUsers.get(token) === undefined) { + authenticatedUsers.put(token, decoded) + res.cookie('token', token) + } + } + }) + } + next() +} diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 00000000000..29f4ca0a24f --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2014-2024 Bjoern Kimminich & the OWASP Juice Shop contributors. + * SPDX-License-Identifier: MIT + */ + +/* jslint node: true */ +import packageJson from '../package.json' +import fs from 'fs' +import logger from './logger' +import config from 'config' +import jsSHA from 'jssha' +import download from 'download' +import crypto from 'crypto' +import clarinet from 'clarinet' +import type { Challenge } from 'data/types' + +import isHeroku from './is-heroku' +import isDocker from './is-docker' +import isWindows from './is-windows' +export { default as isDocker } from './is-docker' +export { default as isWindows } from './is-windows' +// import isGitpod from 'is-gitpod') // FIXME Roll back to this when https://github.com/dword-design/is-gitpod/issues/94 is resolve +const isGitpod = () => false + +const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'] + +export const queryResultToJson = ( + data: T, + status: string = 'success' +): { data: T, status: string } => { + return { + status, + data + } +} + +export const isUrl = (url: string) => { + return startsWith(url, 'http') +} + +export const startsWith = (str: string, prefix: string) => str ? str.indexOf(prefix) === 0 : false + +export const endsWith = (str?: string, suffix?: string) => (str && suffix) ? str.includes(suffix, str.length - suffix.length) : false + +export const contains = (str: string, element: string) => str ? str.includes(element) : false // TODO Inline all usages as this function is not adding any functionality to String.includes + +export const containsEscaped = function (str: string, element: string) { + return contains(str, element.replace(/"/g, '\\"')) +} + +export const containsOrEscaped = function (str: string, element: string) { + return contains(str, element) || containsEscaped(str, element) +} + +export const unquote = function (str: string) { + if (str && startsWith(str, '"') && endsWith(str, '"')) { + return str.substring(1, str.length - 1) + } else { + return str + } +} + +export const trunc = function (str: string, length: number) { + str = str.replace(/(\r\n|\n|\r)/gm, '') + return (str.length > length) ? str.substr(0, length - 1) + '...' : str +} + +export const version = (module?: string) => { + if (module) { + // @ts-expect-error FIXME Ignoring any type issue on purpose + return packageJson.dependencies[module] + } else { + return packageJson.version + } +} + +let cachedCtfKey: string | undefined +const getCtfKey = () => { + if (!cachedCtfKey) { + if (process.env.CTF_KEY !== undefined && process.env.CTF_KEY !== '') { + cachedCtfKey = process.env.CTF_KEY + } else { + const data = fs.readFileSync('ctf.key', 'utf8') + cachedCtfKey = data + } + } + return cachedCtfKey +} +export const ctfFlag = (text: string) => { + const shaObj = new jsSHA('SHA-1', 'TEXT') // eslint-disable-line new-cap + shaObj.setHMACKey(getCtfKey(), 'TEXT') + shaObj.update(text) + return shaObj.getHMAC('HEX') +} + +export const toMMMYY = (date: Date) => { + const month = date.getMonth() + const year = date.getFullYear() + return months[month] + year.toString().substring(2, 4) +} + +export const toISO8601 = (date: Date) => { + let day = '' + date.getDate() + let month = '' + (date.getMonth() + 1) + const year = date.getFullYear() + + if (month.length < 2) month = '0' + month + if (day.length < 2) day = '0' + day + + return [year, month, day].join('-') +} + +export const extractFilename = (url: string) => { + let file = decodeURIComponent(url.substring(url.lastIndexOf('/') + 1)) + if (contains(file, '?')) { + file = file.substring(0, file.indexOf('?')) + } + return file +} + +export const downloadToFile = async (url: string, dest: string) => { + try { + const data = await download(url) + fs.writeFileSync(dest, data) + } catch (err) { + logger.warn('Failed to download ' + url + ' (' + getErrorMessage(err) + ')') + } +} + +export const jwtFrom = ({ headers }: { headers: any }) => { + if (headers?.authorization) { + const parts = headers.authorization.split(' ') + if (parts.length === 2) { + const scheme = parts[0] + const token = parts[1] + + if (/^Bearer$/i.test(scheme)) { + return token + } + } + } + return undefined +} + +export const randomHexString = (length: number): string => { + return crypto.randomBytes(Math.ceil(length / 2)).toString('hex').slice(0, length) +} + +export interface ChallengeEnablementStatus { + enabled: boolean + disabledBecause: string | null +} + +type SafetyModeSetting = 'enabled' | 'disabled' | 'auto' + +type isEnvironmentFunction = () => boolean + +export function getChallengeEnablementStatus (challenge: Challenge, + safetyModeSetting: SafetyModeSetting = config.get('challenges.safetyMode'), + isEnvironmentFunctions: { + isDocker: isEnvironmentFunction + isHeroku: isEnvironmentFunction + isWindows: isEnvironmentFunction + isGitpod: isEnvironmentFunction + } = { isDocker, isHeroku, isWindows, isGitpod }): ChallengeEnablementStatus { + if (!challenge?.disabledEnv) { + return { enabled: true, disabledBecause: null } + } + + if (safetyModeSetting === 'disabled') { + return { enabled: true, disabledBecause: null } + } + + if (challenge.disabledEnv?.includes('Docker') && isEnvironmentFunctions.isDocker()) { + return { enabled: false, disabledBecause: 'Docker' } + } + if (challenge.disabledEnv?.includes('Heroku') && isEnvironmentFunctions.isHeroku()) { + return { enabled: false, disabledBecause: 'Heroku' } + } + if (challenge.disabledEnv?.includes('Windows') && isEnvironmentFunctions.isWindows()) { + return { enabled: false, disabledBecause: 'Windows' } + } + if (challenge.disabledEnv?.includes('Gitpod') && isEnvironmentFunctions.isGitpod()) { + return { enabled: false, disabledBecause: 'Gitpod' } + } + if (challenge.disabledEnv && safetyModeSetting === 'enabled') { + return { enabled: false, disabledBecause: 'Safety Mode' } + } + + return { enabled: true, disabledBecause: null } +} +export function isChallengeEnabled (challenge: Challenge): boolean { + const { enabled } = getChallengeEnablementStatus(challenge) + return enabled +} + +export const parseJsonCustom = (jsonString: string) => { + const parser = clarinet.parser() + const result: any[] = [] + parser.onkey = parser.onopenobject = (k: any) => { + result.push({ key: k, value: null }) + } + parser.onvalue = (v: any) => { + result[result.length - 1].value = v + } + parser.write(jsonString) + parser.close() + return result +} + +export const toSimpleIpAddress = (ipv6: string) => { + if (startsWith(ipv6, '::ffff:')) { + return ipv6.substr(7) + } else if (ipv6 === '::1') { + return '127.0.0.1' + } else { + return ipv6 + } +} + +export const getErrorMessage = (error: unknown) => { + if (error instanceof Error) return error.message + return String(error) +} + +export const matchesSystemIniFile = (text: string) => { + const match = text.match(/; for 16-bit app support/gi) + return match !== null && match.length >= 1 +} + +export const matchesEtcPasswdFile = (text: string) => { + const match = text.match(/(\w*:\w*:\d*:\d*:\w*:.*)|(Note that this file is consulted directly)/gi) + return match !== null && match.length >= 1 +}