diff --git a/.gitignore b/.gitignore index 0b651bca..2428ab44 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ __pycache__ # local Kubernetes overlays /deploy/kubernetes/overlays/local/ + +# Local security audit artifacts +/audit/ diff --git a/back/api/config.ts b/back/api/config.ts index 6eb4d997..c61772f5 100644 --- a/back/api/config.ts +++ b/back/api/config.ts @@ -9,6 +9,7 @@ import { SAMPLE_FILES } from '../config/const'; import { t } from '../shared/i18n'; import ConfigService from '../services/config'; import { writeFileWithLock } from '../shared/utils'; +import { resolveFileAccess } from '../shared/fileAccess'; const route = Router(); export default (app: Router) => { @@ -78,14 +79,12 @@ export default (app: Router) => { basePath = join(config.rootPath, 'data/scripts'); } const cleanName = name.replace(/^data\/scripts\//, ''); - const resolvedPath = join(basePath, cleanName); - const normalized = join(resolvedPath); - // Verify the resolved path stays within allowed directory - if (!normalized.startsWith(basePath)) { - return res.send({ code: 403, message: t('文件路径无效') }); - } - // Check blacklist on actual filename (not user input) - if (config.blackFileList.includes(basename(normalized))) { + const normalized = resolveFileAccess( + basePath, + [cleanName], + config.blackFileList, + ); + if (!normalized) { return res.send({ code: 403, message: t('文件无法访问') }); } await writeFileWithLock(normalized, content); @@ -96,13 +95,10 @@ export default (app: Router) => { }, ); - route.get( - '/:file', - (req: Request, res: Response) => { - return res.send({ - code: 410, - message: t('接口已下线,请使用 /configs/detail 接口'), - }); - }, - ); + route.get('/:file', (req: Request, res: Response) => { + return res.send({ + code: 410, + message: t('接口已下线,请使用 /configs/detail 接口'), + }); + }); }; diff --git a/back/api/script.ts b/back/api/script.ts index 11ba9137..99a63ad3 100644 --- a/back/api/script.ts +++ b/back/api/script.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'crypto'; +import { resolveFileAccess } from '../shared/fileAccess'; import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util'; import { Router, Request, Response, NextFunction } from 'express'; import { Container } from 'typedi'; @@ -14,15 +16,17 @@ const route = Router(); function isPathAllowed(targetPath: string): boolean { const resolved = path.resolve(targetPath); - return config.writePathList.some((x) => resolved.startsWith(x)); + return config.writePathList.some((x) => + Boolean(resolveFileAccess(x, [resolved], config.blackFileList)), + ); } const storage = multer.diskStorage({ destination: function (req, file, cb) { - cb(null, config.scriptPath); + cb(null, config.tmpPath); }, filename: function (req, file, cb) { - cb(null, file.originalname); + cb(null, randomUUID()); }, }); const upload = multer({ storage: storage }); @@ -50,6 +54,15 @@ export default (app: Router) => { 'package-lock.json', ]; if (req.query.path) { + if ( + !resolveFileAccess( + config.scriptPath, + [req.query.path as string], + config.blackFileList, + ) + ) { + return res.send({ code: 403, message: t('暂无权限') }); + } result = await readDir( req.query.path as string, config.scriptPath, @@ -77,7 +90,8 @@ export default (app: Router) => { logger.error('🔥 error: %o', e); return next(e); } - }); + }, + ); route.get( '/detail', @@ -91,7 +105,7 @@ export default (app: Router) => { try { const scriptService = Container.get(ScriptService); const content = await scriptService.getFile( - req.query?.path as string || '', + (req.query?.path as string) || '', req.query.file as string, ); res.send({ code: 200, data: content }); @@ -101,18 +115,21 @@ export default (app: Router) => { }, ); - route.get( - '/:file', - (req: Request, res: Response) => { - return res.send({ - code: 410, - message: t('接口已下线,请使用 /scripts/detail 接口'), - }); - }, - ); + route.get('/:file', (req: Request, res: Response) => { + return res.send({ + code: 410, + message: t('接口已下线,请使用 /scripts/detail 接口'), + }); + }); route.post( '/', + (req: Request, res: Response, next: NextFunction) => { + res.on('finish', () => { + if (req.file?.path) fs.unlink(req.file.path).catch(() => undefined); + }); + next(); + }, upload.single('file'), celebrate({ body: Joi.object({ @@ -156,7 +173,8 @@ export default (app: Router) => { if (!isPathAllowed(uploadPath)) { return res.send({ code: 403, message: t('暂无权限') }); } - await fs.rename(req.file.path, uploadPath); + await fs.copyFile(req.file.path, uploadPath); + await fs.unlink(req.file.path); return res.send({ code: 200 }); } diff --git a/back/api/user.ts b/back/api/user.ts index 4d526389..4e92b39b 100644 --- a/back/api/user.ts +++ b/back/api/user.ts @@ -22,7 +22,25 @@ const storage = multer.diskStorage({ cb(null, key + ext); }, }); -const upload = multer({ storage: storage }); +const upload = multer({ + storage, + limits: { fileSize: 5 * 1024 * 1024, files: 1 }, + fileFilter: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + const imageTypes: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + }; + if (imageTypes[ext] !== file.mimetype) { + return cb(new Error(t('仅支持 PNG、JPEG、GIF、WebP、AVIF 图片'))); + } + cb(null, true); + }, +}); export default (app: Router) => { app.use('/user', route); @@ -35,8 +53,8 @@ export default (app: Router) => { }), celebrate({ body: Joi.object({ - username: Joi.string().required(), - password: Joi.string().required(), + username: Joi.string().max(1024).required(), + password: Joi.string().max(1024).required(), }), }), async (req: Request, res: Response, next: NextFunction) => { @@ -70,8 +88,8 @@ export default (app: Router) => { '/', celebrate({ body: Joi.object({ - username: Joi.string().required(), - password: Joi.string().required(), + username: Joi.string().max(1024).required(), + password: Joi.string().max(1024).required(), }), }), async (req: Request, res: Response, next: NextFunction) => { @@ -156,11 +174,12 @@ export default (app: Router) => { route.put( '/two-factor/login', + rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }), celebrate({ body: Joi.object({ code: Joi.string().required(), - username: Joi.string().required(), - password: Joi.string().required(), + username: Joi.string().max(1024).required(), + password: Joi.string().max(1024).required(), }), }), async (req: Request, res: Response, next: NextFunction) => { @@ -272,17 +291,18 @@ export default (app: Router) => { route.put( '/init', + rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }), celebrate({ body: Joi.object({ - username: Joi.string().required(), - password: Joi.string().required(), + username: Joi.string().max(1024).required(), + password: Joi.string().max(1024).required(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); - const result = await userService.updateUsernameAndPassword(req.body); + const result = await userService.initializeUser(req.body); res.send(result); } catch (e) { return next(e); diff --git a/back/config/share.ts b/back/config/share.ts index 8577feea..24c52fa7 100644 --- a/back/config/share.ts +++ b/back/config/share.ts @@ -1,3 +1,5 @@ +import { randomInt } from 'crypto'; + export function createRandomString(min: number, max: number): string { const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; const english = [ @@ -65,20 +67,20 @@ export function createRandomString(min: number, max: number): string { arr.push(getOne(ENGLISH)); arr.push(getOne(special)); - const len = min + Math.floor(Math.random() * (max - min + 1)); + const len = min + randomInt(max - min + 1); for (let i = 4; i < len; i++) { - arr.push(config[Math.floor(Math.random() * config.length)]); + arr.push(config[randomInt(config.length)]); } const newArr = []; for (let j = 0; j < len; j++) { - newArr.push(arr.splice(Math.random() * arr.length, 1)[0]); + newArr.push(arr.splice(randomInt(arr.length), 1)[0]); } function getOne(arr: any[]) { - return arr[Math.floor(Math.random() * arr.length)]; + return arr[randomInt(arr.length)]; } return newArr.join(''); -} \ No newline at end of file +} diff --git a/back/data/system.ts b/back/data/system.ts index b61c837b..af20e8ba 100644 --- a/back/data/system.ts +++ b/back/data/system.ts @@ -75,6 +75,8 @@ export interface AuthInfo { lastaddr: string; platform: string; isTwoFactorChecking: boolean; + twoFactorExpiresAt?: number; + lastTwoFactorStep?: number; token: string; tokens: Record; twoFactorActivated: boolean; diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 12bca534..20c05774 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -48,7 +48,26 @@ export default ({ app }: { app: Application }) => { } app.get(`${config.api.prefix}/env.js`, serveEnv); - app.use(`${config.api.prefix}/static`, express.static(config.uploadPath)); + app.use( + `${config.api.prefix}/static`, + express.static(config.uploadPath, { + setHeaders: (res) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Security-Policy', "sandbox; default-src 'none'"); + }, + }), + ); + + const credentialPaths = ['/api', '/open'].flatMap((prefix) => + ['/user/login', '/user/init', '/user/two-factor/login'].map( + (route) => `${prefix}${route}`, + ), + ); + app.use( + credentialPaths, + bodyParser.json({ limit: '16kb' }), + bodyParser.urlencoded({ limit: '16kb', extended: false }), + ); app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); @@ -123,7 +142,7 @@ export default ({ app }: { app: Application }) => { } const authInfo = await shareStore.getAuthInfo(); - if (isValidToken(authInfo, headerToken, req.platform)) { + if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) { return next(); } diff --git a/back/loaders/sock.ts b/back/loaders/sock.ts index 5ab0b497..b99b721f 100644 --- a/back/loaders/sock.ts +++ b/back/loaders/sock.ts @@ -8,26 +8,44 @@ import { isValidToken } from '../shared/auth'; import config from '../config'; export default async ({ server }: { server: Server }) => { - const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } }); + const echo = sockJs.createServer({ + prefix: `${config.baseUrl}/api/ws`, + log: () => {}, + }); const sockService = Container.get(SockService); echo.on('connection', async (conn) => { if (!conn.headers || !conn.url || !conn.pathname) { conn.close('404'); + return; } const authInfo = await shareStore.getAuthInfo(); const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop'; const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); - if (isValidToken(authInfo, headerToken, platform)) { + if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) { sockService.addClient(conn); + const checkSession = setInterval(async () => { + try { + const current = await shareStore.getAuthInfo(); + if ( + !isValidToken(current, headerToken, platform, config.jwt.secret) + ) { + conn.close('401'); + } + } catch { + conn.close('401'); + } + }, 1000); + checkSession.unref(); conn.on('data', (message) => { conn.write(message); }); conn.on('close', function () { + clearInterval(checkSession); sockService.removeClient(conn); }); diff --git a/back/services/config.ts b/back/services/config.ts index 0c5ea30a..55335bd3 100644 --- a/back/services/config.ts +++ b/back/services/config.ts @@ -1,10 +1,10 @@ -import { Service, Inject } from 'typedi'; -import path, { join } from 'path'; +import { Service } from 'typedi'; import config from '../config'; import { getFileContentByName } from '../config/util'; import { t } from '../shared/i18n'; import { Response } from 'express'; import { request } from 'undici'; +import { resolveFileAccess } from '../shared/fileAccess'; @Service() export default class ConfigService { @@ -15,21 +15,13 @@ export default class ConfigService { if (!filePath) { return res.send({ code: 403, message: t('文件无法访问') }); } - const normalized = path.normalize(filePath); - if (normalized.startsWith('..') || path.isAbsolute(normalized)) { - return res.send({ code: 403, message: t('文件无法访问') }); - } - const resolvedRoot = path.resolve(config.rootPath, normalized); - const resolvedConfig = path.resolve(config.configPath, normalized); - const isValidPath = - resolvedRoot.startsWith(config.scriptPath) || - resolvedRoot.startsWith(config.configPath) || - resolvedConfig.startsWith(config.scriptPath) || - resolvedConfig.startsWith(config.configPath); - if (!isValidPath) { - return res.send({ code: 403, message: t('文件无法访问') }); - } - if (config.blackFileList.includes(path.basename(normalized))) { + const scriptFile = filePath.startsWith('data/scripts/'); + const resolved = resolveFileAccess( + scriptFile ? config.scriptPath : config.configPath, + [scriptFile ? filePath.slice('data/scripts/'.length) : filePath], + config.blackFileList, + ); + if (!resolved) { return res.send({ code: 403, message: t('文件无法访问') }); } @@ -38,10 +30,8 @@ export default class ConfigService { `https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`, ); content = await res.body.text(); - } else if (filePath.startsWith('data/scripts/')) { - content = await getFileContentByName(join(config.rootPath, filePath)); } else { - content = await getFileContentByName(join(config.configPath, filePath)); + content = await getFileContentByName(resolved); } res.send({ code: 200, data: content }); diff --git a/back/services/grpc.ts b/back/services/grpc.ts index 2a69843f..2e8c6bb6 100644 --- a/back/services/grpc.ts +++ b/back/services/grpc.ts @@ -40,7 +40,7 @@ export class GrpcServerService { const grpcPort = config.grpcPort; const hostsToTry = [ config.bindHostGrpc, - ...(config.bindHostGrpc !== '0.0.0.0' ? ['0.0.0.0'] : []) + ...(config.bindHostGrpc === '::' ? ['0.0.0.0'] : []) ]; const bindAsync = promisify(this.server.bindAsync).bind(this.server); diff --git a/back/services/http.ts b/back/services/http.ts index a47d49c8..f6d6dba3 100644 --- a/back/services/http.ts +++ b/back/services/http.ts @@ -12,7 +12,7 @@ export class HttpServerService { async initialize(expressApp: express.Application, port: number) { const hostsToTry = [ config.bindHost, - ...(config.bindHost !== '0.0.0.0' ? ['0.0.0.0'] : []) + ...(config.bindHost === '::' ? ['0.0.0.0'] : []) ]; let lastError: Error | null = null; diff --git a/back/services/log.ts b/back/services/log.ts index ab3f16e4..cd40116d 100644 --- a/back/services/log.ts +++ b/back/services/log.ts @@ -1,3 +1,4 @@ +import { resolveFileAccess } from '../shared/fileAccess'; import path from 'path'; import { Inject, Service } from 'typedi'; import winston from 'winston'; @@ -8,7 +9,10 @@ export default class LogService { constructor(@Inject('logger') private logger: winston.Logger) {} public checkFilePath(filePath: string, fileName: string) { - const finalPath = path.resolve(config.logPath, filePath, fileName); - return finalPath.startsWith(config.logPath) ? finalPath : ''; + return resolveFileAccess( + config.logPath, + [filePath || '', fileName], + config.blackFileList, + ); } } diff --git a/back/services/script.ts b/back/services/script.ts index 52921c6c..ef4a5e2c 100644 --- a/back/services/script.ts +++ b/back/services/script.ts @@ -1,3 +1,4 @@ +import { resolveFileAccess } from '../shared/fileAccess'; import { Service, Inject } from 'typedi'; import winston from 'winston'; import path, { join } from 'path'; @@ -65,8 +66,11 @@ export default class ScriptService { } public checkFilePath(filePath: string, fileName: string) { - const finalPath = path.resolve(config.scriptPath, filePath, fileName); - return finalPath.startsWith(config.scriptPath) ? finalPath : ''; + return resolveFileAccess( + config.scriptPath, + [filePath || '', fileName], + config.blackFileList, + ); } public async getFile(filePath: string, fileName: string) { diff --git a/back/services/user.ts b/back/services/user.ts index 73ed8dd7..b7f5cf9b 100644 --- a/back/services/user.ts +++ b/back/services/user.ts @@ -26,6 +26,13 @@ import isNil from 'lodash/isNil'; import { shareStore } from '../shared/store'; import { t, tf } from '../shared/i18n'; import { getClientIp, normalizeClientIp } from '../shared/clientIp'; +import { isDefaultAuthInfo } from '../shared/auth'; +import { + hashPassword, + isPasswordHash, + verifyPassword, +} from '../shared/password'; +import { serializeAuthMutation } from '../shared/authMutation'; @Service() export default class UserService { @@ -38,7 +45,15 @@ export default class UserService { private sockService: SockService, ) {} + @serializeAuthMutation public async login( + payloads: { username: string; password: string }, + req: Request, + ): Promise { + return this.authenticate(payloads, req); + } + + private async authenticate( payloads: { username: string; password: string; @@ -48,6 +63,9 @@ export default class UserService { ): Promise { let { username, password } = payloads; const content = await this.getAuthInfo(); + if (isDefaultAuthInfo(content)) { + return { code: 450, message: t('请先初始化') }; + } const timestamp = Date.now(); const ip = getClientIp(req); const query = new IP2Region(); @@ -100,14 +118,13 @@ export default class UserService { }; } - if ( - username === cUsername && - password === cPassword && - twoFactorActivated && - needTwoFactor - ) { + const passwordMatches = + username === cUsername && (await verifyPassword(password, cPassword)); + + if (passwordMatches && twoFactorActivated && needTwoFactor) { await this.updateAuthInfo(content, { isTwoFactorChecking: true, + twoFactorExpiresAt: timestamp + 5 * 60 * 1000, }); return { code: 420, @@ -115,7 +132,7 @@ export default class UserService { }; } - if (username === cUsername && password === cPassword) { + if (passwordMatches) { const data = createRandomString(50, 100); const expiration = twoFactorActivated ? '60d' : '20d'; let token = jwt.sign({ data }, config.jwt.secret, { @@ -138,6 +155,9 @@ export default class UserService { ); await this.updateAuthInfo(content, { + password: isPasswordHash(cPassword) + ? cPassword + : await hashPassword(password), token, tokens: updatedTokens, lastlogon: timestamp, @@ -146,6 +166,7 @@ export default class UserService { lastaddr: address, platform: req.platform, isTwoFactorChecking: false, + twoFactorExpiresAt: 0, }); this.notificationService.notify( t('登录通知'), @@ -231,6 +252,7 @@ export default class UserService { } } + @serializeAuthMutation public async logout(platform: string, tokenValue: string): Promise { if (!platform || !tokenValue) { this.logger.warn('Invalid logout parameters - empty platform or token'); @@ -291,6 +313,7 @@ export default class UserService { ); } + @serializeAuthMutation public async blockIp(ip: string): Promise { const authInfo = await this.getAuthInfo(); const blockedIps = uniq([ @@ -301,6 +324,7 @@ export default class UserService { return blockedIps; } + @serializeAuthMutation public async unblockIp(ip: string): Promise { const authInfo = await this.getAuthInfo(); const normalizedIp = normalizeClientIp(ip); @@ -316,6 +340,33 @@ export default class UserService { return doc; } + @serializeAuthMutation + public async initializeUser({ + username, + password, + }: { + username: string; + password: string; + }) { + const authInfo = await this.getAuthInfo(); + if (!isDefaultAuthInfo(authInfo)) { + return { code: 450, message: t('未知错误') }; + } + if (password === 'admin') { + return { code: 400, message: t('密码不能设置为admin') }; + } + await this.updateAuthInfo(authInfo, { + username, + password: await hashPassword(password), + token: '', + tokens: {}, + isTwoFactorChecking: false, + twoFactorExpiresAt: 0, + }); + return { code: 200, message: t('更新成功') }; + } + + @serializeAuthMutation public async updateUsernameAndPassword({ username, password, @@ -327,24 +378,37 @@ export default class UserService { return { code: 400, message: t('密码不能设置为admin') }; } const authInfo = await this.getAuthInfo(); - await this.updateAuthInfo(authInfo, { username, password }); + await this.updateAuthInfo(authInfo, { + username, + password: await hashPassword(password), + token: '', + tokens: {}, + isTwoFactorChecking: false, + twoFactorExpiresAt: 0, + }); return { code: 200, message: t('更新成功') }; } + @serializeAuthMutation public async updateAvatar(avatar: string) { const authInfo = await this.getAuthInfo(); await this.updateAuthInfo(authInfo, { avatar }); return { code: 200, data: avatar, message: t('更新成功') }; } + @serializeAuthMutation public async initTwoFactor() { const secret = authenticator.generateSecret(); const authInfo = await this.getAuthInfo(); + if (authInfo.twoFactorActivated) { + throw new Error(t('请先关闭两步验证')); + } const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret); await this.updateAuthInfo(authInfo, { twoFactorSecret: secret }); return { secret, url: otpauth }; } + @serializeAuthMutation public async activeTwoFactor(code: string) { const authInfo = await this.getAuthInfo(); const isValid = authenticator.verify({ @@ -352,11 +416,18 @@ export default class UserService { secret: authInfo.twoFactorSecret, }); if (isValid) { - await this.updateAuthInfo(authInfo, { twoFactorActivated: true }); + await this.updateAuthInfo(authInfo, { + twoFactorActivated: true, + token: '', + tokens: {}, + isTwoFactorChecking: false, + twoFactorExpiresAt: 0, + }); } return isValid; } + @serializeAuthMutation public async twoFactorLogin( { username, @@ -367,15 +438,28 @@ export default class UserService { ) { const authInfo = await this.getAuthInfo(); const { isTwoFactorChecking, twoFactorSecret } = authInfo; - if (!isTwoFactorChecking) { + const now = Date.now(); + const retries = authInfo.retries || 0; + if (retries > 2 && now - authInfo.lastlogon < Math.pow(3, retries) * 1000) { + return { code: 410, message: t('失败次数过多,请稍后重试') }; + } + if ( + !isTwoFactorChecking || + !authInfo.twoFactorActivated || + !authInfo.twoFactorExpiresAt || + authInfo.twoFactorExpiresAt <= now + ) { return { code: 450, message: t('未知错误') }; } - const isValid = authenticator.verify({ - token: code, - secret: twoFactorSecret, - }); + const step = Math.floor(now / 30000); + const isValid = + username === authInfo.username && + (await verifyPassword(password, authInfo.password)) && + authInfo.lastTwoFactorStep !== step && + authenticator.verify({ token: code, secret: twoFactorSecret }); if (isValid) { - return this.login({ username, password }, req, false); + await this.updateAuthInfo(authInfo, { lastTwoFactorStep: step }); + return this.authenticate({ username, password }, req, false); } else { const ip = getClientIp(req); const query = new IP2Region(); @@ -388,6 +472,9 @@ export default class UserService { .join(' '); } await this.updateAuthInfo(authInfo, { + retries: retries + 1, + lastlogon: now, + isTwoFactorChecking: retries + 1 < 5, lastip: ip, lastaddr: address, platform: req.platform, @@ -396,11 +483,16 @@ export default class UserService { } } + @serializeAuthMutation public async deactivateTwoFactor() { const authInfo = await this.getAuthInfo(); await this.updateAuthInfo(authInfo, { twoFactorActivated: false, twoFactorSecret: '', + token: '', + tokens: {}, + isTwoFactorChecking: false, + twoFactorExpiresAt: 0, }); return true; } @@ -421,6 +513,9 @@ export default class UserService { type: AuthDataType.authConfig, info: result, }); + if (info.tokens && Object.keys(info.tokens).length === 0) { + this.sockService.getClients().forEach((conn) => conn.close('401')); + } } public async getNotificationMode(): Promise { @@ -562,6 +657,7 @@ export default class UserService { return undefined; } + @serializeAuthMutation public async resetAuthInfo(info: Partial) { const { retries, twoFactorActivated, password, username } = info; if (password === 'admin') { @@ -578,6 +674,21 @@ export default class UserService { (x) => !isNil(x), ); + if (password !== undefined) { + payload.password = await hashPassword(password); + } + if ( + password !== undefined || + username !== undefined || + twoFactorActivated !== undefined + ) { + Object.assign(payload, { + token: '', + tokens: {}, + isTwoFactorChecking: false, + twoFactorExpiresAt: 0, + }); + } await this.updateAuthInfo(authInfo, payload); } } diff --git a/back/shared/auth.ts b/back/shared/auth.ts index 5bfd694a..2a0e7fcf 100644 --- a/back/shared/auth.ts +++ b/back/shared/auth.ts @@ -1,11 +1,8 @@ import { AuthInfo, TokenInfo } from '../data/system'; +import jwt from 'jsonwebtoken'; export function isDefaultAuthInfo(authInfo: AuthInfo): boolean { - return ( - Object.keys(authInfo).length === 2 && - authInfo.username === 'admin' && - authInfo.password === 'admin' - ); + return authInfo.username === 'admin' && authInfo.password === 'admin'; } /** @@ -21,11 +18,21 @@ export function isValidToken( authInfo: AuthInfo | null | undefined, headerToken: string, platform: string, + secret: string, ): boolean { if (!authInfo || !headerToken) { return false; } + try { + const claims = jwt.verify(headerToken, secret, { algorithms: ['HS384'] }); + if (typeof claims === 'string' || typeof claims.exp !== 'number') { + return false; + } + } catch { + return false; + } + const { token = '', tokens = {} } = authInfo; // Check legacy token field @@ -46,7 +53,12 @@ export function isValidToken( return headerToken === platformTokens; } else if (Array.isArray(platformTokens)) { // New format: array of TokenInfo objects - return platformTokens.some((t: TokenInfo) => t && t.value === headerToken); + return platformTokens.some( + (t: TokenInfo) => + t && + t.value === headerToken && + (t.expiration === undefined || t.expiration > Date.now() / 1000), + ); } // Unexpected type - log warning and reject diff --git a/back/shared/authMutation.ts b/back/shared/authMutation.ts new file mode 100644 index 00000000..b041ddd7 --- /dev/null +++ b/back/shared/authMutation.ts @@ -0,0 +1,17 @@ +// All account mutations in the HTTP service share one queue. In particular, +// a login that read old credentials must finish before a password reset revokes +// its session, and two initialization requests must not both claim the account. +let pending: Promise = Promise.resolve(); + +export function serializeAuthMutation( + _target: object, + _key: string, + descriptor: PropertyDescriptor, +) { + const method = descriptor.value; + descriptor.value = function (this: unknown, ...args: unknown[]) { + const result = pending.then(() => method.apply(this, args)); + pending = result.catch(() => undefined); + return result; + }; +} diff --git a/back/shared/fileAccess.ts b/back/shared/fileAccess.ts new file mode 100644 index 00000000..2cda7e88 --- /dev/null +++ b/back/shared/fileAccess.ts @@ -0,0 +1,57 @@ +import fs from 'fs'; +import path from 'path'; + +function isWithin(root: string, target: string): boolean { + const relative = path.relative(root, target); + return ( + relative === '' || + (!relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative)) + ); +} + +/** Resolve existing files and not-yet-created children without following an + * existing symlink outside the root. Blacklisted directories cover descendants. */ +export function resolveFileAccess( + root: string, + parts: string[], + blacklist: string[] = [], +): string { + if (parts.some((part) => typeof part !== 'string' || part.includes('\0'))) { + return ''; + } + const resolvedRoot = path.resolve(root); + const target = path.resolve(resolvedRoot, ...parts); + if (target === resolvedRoot || !isWithin(resolvedRoot, target)) return ''; + const isBlocked = (relative: string) => + relative.split(path.sep).some((part) => blacklist.includes(part)); + if (isBlocked(path.relative(resolvedRoot, target))) return ''; + try { + const realRoot = fs.realpathSync(resolvedRoot); + let existing = target; + const missing: string[] = []; + while (!fs.existsSync(existing)) { + // existsSync is false for a dangling symlink; never treat one as absent. + try { + fs.lstatSync(existing); + return ''; + } catch (error: any) { + if (error.code !== 'ENOENT') return ''; + } + if (existing === resolvedRoot) return ''; + missing.unshift(path.basename(existing)); + existing = path.dirname(existing); + } + const realTarget = path.resolve(fs.realpathSync(existing), ...missing); + if ( + !isWithin(realRoot, realTarget) || + isBlocked(path.relative(realRoot, realTarget)) + ) { + return ''; + } + return target; + } catch { + return ''; + } +} diff --git a/back/shared/password.ts b/back/shared/password.ts new file mode 100644 index 00000000..f59150df --- /dev/null +++ b/back/shared/password.ts @@ -0,0 +1,30 @@ +import { randomBytes, scrypt, timingSafeEqual } from 'crypto'; +import { promisify } from 'util'; + +const deriveKey = promisify(scrypt); +const HASH_PREFIX = 'scrypt$'; + +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16).toString('hex'); + const key = (await deriveKey(password, salt, 64)) as Buffer; + return `${HASH_PREFIX}${salt}$${key.toString('hex')}`; +} + +export function isPasswordHash(password: string): boolean { + return /^scrypt\$[a-f0-9]{32}\$[a-f0-9]{128}$/.test(password); +} + +export async function verifyPassword( + password: string, + stored: string, +): Promise { + if (!isPasswordHash(stored)) { + // Existing installations migrate after a successful password check. + const input = Buffer.from(password); + const expected = Buffer.from(stored); + return input.length === expected.length && timingSafeEqual(input, expected); + } + const [, salt, hash] = stored.split('$'); + const key = (await deriveKey(password, salt, 64)) as Buffer; + return timingSafeEqual(key, Buffer.from(hash, 'hex')); +} diff --git a/test/back/auth-security.test.cjs b/test/back/auth-security.test.cjs new file mode 100644 index 00000000..9c483f7f --- /dev/null +++ b/test/back/auth-security.test.cjs @@ -0,0 +1,74 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const path = require('node:path'); +const jwt = require('jsonwebtoken'); +const load = require('../helpers/load-security-module.cjs'); +const { isValidToken } = load( + path.join(__dirname, '../../back/shared/auth.ts'), +); +const { hashPassword, verifyPassword } = load( + path.join(__dirname, '../../back/shared/password.ts'), +); +const secret = 'security-test-secret'; +const token = (payload = {}, options = {}) => + jwt.sign(payload, secret, { + algorithm: 'HS384', + expiresIn: '1h', + ...options, + }); +const info = (value) => ({ token: value, tokens: {} }); + +test('valid legacy and platform sessions retain compatibility', () => { + const value = token(); + for (const auth of [ + info(value), + { tokens: { desktop: value } }, + { tokens: { desktop: [{ value }] } }, + ]) { + assert.equal(isValidToken(auth, value, 'desktop', secret), true); + } + assert.equal( + isValidToken({ tokens: { mobile: value } }, value, 'desktop', secret), + false, + ); +}); + +test('session membership never bypasses JWT signature, expiry, algorithm or nbf', () => { + const values = [ + token({}, { expiresIn: -1 }), + token({}, { algorithm: 'HS256' }), + token({ nbf: Math.floor(Date.now() / 1000) + 60 }), + jwt.sign({}, 'wrong-secret', { algorithm: 'HS384', expiresIn: '1h' }), + jwt.sign({}, secret, { algorithm: 'HS384' }), + 'not-a-token', + ]; + for (const value of values) + assert.equal(isValidToken(info(value), value, 'desktop', secret), false); + assert.equal(isValidToken({ tokens: {} }, token(), 'desktop', secret), false); + assert.equal(isValidToken(null, token(), 'desktop', secret), false); + assert.equal(isValidToken(info(''), '', 'desktop', secret), false); +}); + +test('platform token metadata can shorten but cannot extend JWT expiration', () => { + const value = token(); + assert.equal( + isValidToken( + { tokens: { desktop: [{ value, expiration: 1 }] } }, + value, + 'desktop', + secret, + ), + false, + ); +}); + +test('passwords are salted and legacy plaintext can migrate without changing the password', async () => { + const first = await hashPassword('owner-password'); + const second = await hashPassword('owner-password'); + assert.notEqual(first, second); + assert.equal(await verifyPassword('owner-password', first), true); + assert.equal(await verifyPassword('wrong-password', first), false); + assert.equal(await verifyPassword(first, first), false); + assert.equal(await verifyPassword('old-password', 'old-password'), true); + assert.equal(await verifyPassword('wrong', 'old-password'), false); +}); diff --git a/test/back/file-access-security.test.cjs b/test/back/file-access-security.test.cjs new file mode 100644 index 00000000..0536ad38 --- /dev/null +++ b/test/back/file-access-security.test.cjs @@ -0,0 +1,47 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const load = require('../helpers/load-security-module.cjs'); +const { resolveFileAccess } = load( + path.join(__dirname, '../../back/shared/fileAccess.ts'), +); + +test('file access enforces directory boundaries, blacklist descendants and symlinks', (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-file-security-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + const root = path.join(tmp, 'config'); + fs.mkdirSync(path.join(root, 'grpc'), { recursive: true }); + fs.mkdirSync(path.join(tmp, 'config-other')); + fs.writeFileSync(path.join(root, 'normal.txt'), 'normal'); + fs.writeFileSync(path.join(root, 'grpc', 'client.key'), 'test-only'); + fs.symlinkSync(path.join(tmp, 'config-other'), path.join(root, 'outside')); + fs.symlinkSync(path.join(root, 'grpc'), path.join(root, 'alias')); + fs.symlinkSync(path.join(root, 'normal.txt'), path.join(root, 'safe-link')); + fs.symlinkSync(path.join(tmp, 'missing'), path.join(root, 'dangling')); + for (const input of [ + '../config-other/secret', + '/etc/passwd', + 'grpc/client.key', + 'alias/client.key', + 'outside/new.txt', + 'dangling', + 'auth.json', + 'nested/auth.json', + '', + ]) { + assert.equal( + resolveFileAccess(root, [input], ['grpc', 'auth.json']), + '', + input, + ); + } + for (const input of ['normal.txt', 'safe-link', 'new-dir/new.txt']) { + assert.equal( + resolveFileAccess(root, [input], ['grpc']), + path.join(root, input), + input, + ); + } +}); diff --git a/test/back/http-security.test.cjs b/test/back/http-security.test.cjs new file mode 100644 index 00000000..3c9b6b8f --- /dev/null +++ b/test/back/http-security.test.cjs @@ -0,0 +1,213 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const express = require('express'); +const jwt = require('jsonwebtoken'); +const load = require('../helpers/load-security-module.cjs'); + +test('HTTP authentication protects init, scopes, expired sessions and config secrets', async (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-http-security-')); + for (const dir of ['config/grpc', 'scripts', 'upload', 'tmp']) + fs.mkdirSync(path.join(tmp, dir), { recursive: true }); + fs.writeFileSync(path.join(tmp, 'config/grpc/client.key'), 'SENTINEL'); + fs.writeFileSync(path.join(tmp, 'config/normal.txt'), 'normal'); + const secret = 'http-security-test'; + const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' }); + const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 }); + const auth = { + username: 'owner', + password: 'configured', + token: valid, + tokens: { desktop: [{ value: expired }] }, + }; + const configSource = fs.readFileSync( + path.join(__dirname, '../../back/config/index.ts'), + 'utf8', + ); + const whitelistSource = configSource + .slice(configSource.indexOf('apiWhiteList:')) + .match(/apiWhiteList:\s*\[([\s\S]*?)\]/)[1]; + const config = { + api: { prefix: '/api' }, + apiWhiteList: [...whitelistSource.matchAll(/['"]([^'"]+)['"]/g)].map( + (x) => x[1], + ), + jwt: { secret }, + rootPath: tmp, + configPath: path.join(tmp, 'config/'), + scriptPath: path.join(tmp, 'scripts/'), + uploadPath: path.join(tmp, 'upload'), + tmpPath: path.join(tmp, 'tmp'), + blackFileList: ['auth.json', 'grpc'], + baseUrl: '/panel', + }; + const apps = [ + { + scopes: ['configs'], + tokens: [{ value: 'config-app', expiration: Date.now() / 1000 + 3600 }], + }, + { + scopes: ['envs'], + tokens: [{ value: 'env-app', expiration: Date.now() / 1000 + 3600 }], + }, + ]; + let initialized = 0; + const User = class {}; + const user = { + initializeUser: async () => { + initialized++; + return { code: 200 }; + }, + getAuthInfo: async () => auth, + }; + const mocks = { + '../config': config, + '../config/util': { + getToken: (r) => (r.headers.authorization || '').replace(/^Bearer /, ''), + getPlatform: () => 'desktop', + getFileContentByName: (p) => fs.promises.readFile(p, 'utf8'), + }, + '../shared/i18n': { t: (x) => x }, + '../shared/store': { + shareStore: { getAuthInfo: async () => auth, getApps: async () => apps }, + }, + '../config/serverEnv': { serveEnv: (_req, res) => res.end() }, + '../services/user': User, + '../data/open': {}, + '../data/system': {}, + '../shared/utils': { + writeFileWithLock: (p, content) => fs.promises.writeFile(p, content), + }, + }; + const Config = load(path.join(__dirname, '../../back/services/config.ts'), { + ...mocks, + typedi: { Service: () => (x) => x }, + }).default; + const configService = new Config(); + mocks['../services/config'] = Config; + mocks.typedi = { + Container: { get: (x) => (x === User ? user : configService) }, + }; + mocks['../api'] = () => { + const router = express.Router(); + load(path.join(__dirname, '../../back/api/user.ts'), mocks).default(router); + load(path.join(__dirname, '../../back/api/config.ts'), mocks).default( + router, + ); + router.get('/envs', (_req, res) => res.json({ code: 200 })); + return router; + }; + const app = express(); + load(path.join(__dirname, '../../back/loaders/express.ts'), mocks).default({ + app, + }); + const server = app.listen(0, '127.0.0.1'); + t.after(async () => { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + await new Promise((resolve, reject) => { + server.on('listening', resolve); + server.on('error', reject); + }); + const request = async (url, token, method = 'GET', body) => { + const response = await fetch( + `http://127.0.0.1:${server.address().port}${url}`, + { + method, + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + 'Content-Type': 'application/json', + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }, + ); + return { status: response.status, body: await response.json() }; + }; + for (const url of [ + '/api/user/init', + '/open/user/init', + '/panel/api/user/init', + '/panel/open/user/init', + ]) { + const response = await request(url, undefined, 'PUT', { + username: 'attacker', + password: 'changed', + }); + assert.equal(response.body.code, 450, url); + } + assert.equal(initialized, 0); + assert.equal( + ( + await request('/api/user/login', undefined, 'POST', { + username: 'x', + password: 'x'.repeat(20000), + }) + ).status, + 413, + ); + fs.writeFileSync( + path.join(tmp, 'upload/legacy.html'), + '', + ); + const legacyUpload = await fetch( + `http://127.0.0.1:${server.address().port}/api/static/legacy.html`, + ); + assert.equal(legacyUpload.headers.get('x-content-type-options'), 'nosniff'); + assert.equal( + legacyUpload.headers.get('content-security-policy'), + "sandbox; default-src 'none'", + ); + for (const prefix of ['/api', '/open', '/panel/api', '/panel/open']) { + assert.equal( + (await request(`${prefix}/envs`, expired)).status, + 401, + prefix, + ); + assert.equal( + (await request(`${prefix}/envs`, valid)).body.code, + 200, + prefix, + ); + assert.equal((await request(`${prefix}/envs`)).status, 401, prefix); + } + assert.equal((await request('/open/envs', 'env-app')).body.code, 200); + assert.equal( + (await request('/open/configs/detail?path=normal.txt', 'env-app')).status, + 401, + ); + assert.equal( + (await request('/open/configs/detail?path=normal.txt', 'config-app')).body + .data, + 'normal', + ); + assert.equal( + (await request('/open/configs/detail?path=grpc/client.key', 'config-app')) + .body.code, + 403, + ); + assert.equal( + ( + await request('/open/configs/save', 'config-app', 'POST', { + name: 'grpc/client.key', + content: 'changed', + }) + ).body.code, + 403, + ); + assert.equal( + fs.readFileSync(path.join(tmp, 'config/grpc/client.key'), 'utf8'), + 'SENTINEL', + ); + assert.equal( + ( + await request('/Api/user/init', undefined, 'PUT', { + username: 'x', + password: 'y', + }) + ).status, + 400, + ); +}); diff --git a/test/back/listen-security.test.cjs b/test/back/listen-security.test.cjs new file mode 100644 index 00000000..f21092ab --- /dev/null +++ b/test/back/listen-security.test.cjs @@ -0,0 +1,67 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const path = require('node:path'); +const load = require('../helpers/load-security-module.cjs'); +const logger = { debug() {}, warn() {}, error() {} }; + +for (const host of ['127.0.0.1', '::1', '192.0.2.1', '::']) { + test(`HTTP binding ${host} never broadens an explicit private address`, async () => { + const Http = load(path.join(__dirname, '../../back/services/http.ts'), { + '../config': { bindHost: host }, + '../loaders/logger': logger, + './metrics': { metricsService: { record() {} } }, + typedi: { Service: () => (x) => x }, + }).HttpServerService; + const instance = new Http(); + const attempted = []; + instance.tryListen = async (_app, _port, address) => { + attempted.push(address); + throw new Error('unavailable'); + }; + await assert.rejects(instance.initialize({}, 5700)); + assert.deepEqual(attempted, host === '::' ? ['::', '0.0.0.0'] : [host]); + }); + test(`gRPC binding ${host} never broadens an explicit private address`, async () => { + const attempted = []; + const Grpc = load(path.join(__dirname, '../../back/services/grpc.ts'), { + '../config': { bindHostGrpc: host, grpcPort: 5500 }, + '../loaders/logger': logger, + './metrics': { metricsService: { record() {} } }, + typedi: { Service: () => (x) => x }, + '@grpc/grpc-js': { + Server: class { + addService() {} + bindAsync(address, _credentials, cb) { + attempted.push(address); + cb(new Error('unavailable')); + } + }, + ServerCredentials: { + createSsl(_ca, _certs, requireClientCert) { + assert.equal(requireClientCert, true); + return {}; + }, + }, + }, + '../protos/cron': { CronService: {} }, + '../protos/health': { HealthService: {} }, + '../protos/api': { ApiService: {} }, + '../schedule/addCron': {}, + '../schedule/delCron': {}, + '../schedule/health': {}, + '../schedule/api': {}, + '../config/grpcCerts': { + initGrpcCerts: async () => ({ + caCert: 'test', + serverCert: 'test', + serverKey: 'test', + }), + }, + }).GrpcServerService; + await assert.rejects(new Grpc().initialize()); + assert.deepEqual( + attempted, + host === '::' ? ['[::]:5500', '0.0.0.0:5500'] : [`${host}:5500`], + ); + }); +} diff --git a/test/back/sock-security.test.cjs b/test/back/sock-security.test.cjs new file mode 100644 index 00000000..e7ba59bf --- /dev/null +++ b/test/back/sock-security.test.cjs @@ -0,0 +1,72 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const path = require('node:path'); +const { EventEmitter } = require('node:events'); +const jwt = require('jsonwebtoken'); +const load = require('../helpers/load-security-module.cjs'); + +test('WebSocket connections reject expired tokens and close when sessions are revoked', async () => { + const secret = 'sock-test'; + const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' }); + const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 }); + let auth = { token: valid, tokens: { desktop: [{ value: expired }] } }; + let onConnection; + const clients = new Set(); + const Sock = class {}; + const loader = load(path.join(__dirname, '../../back/loaders/sock.ts'), { + sockjs: { + createServer: () => ({ + on: (_event, fn) => { + onConnection = fn; + }, + installHandlers() {}, + }), + }, + typedi: { + Container: { + get: () => ({ + addClient: (c) => clients.add(c), + removeClient: (c) => clients.delete(c), + }), + }, + }, + '../services/sock': Sock, + '../config': { jwt: { secret }, baseUrl: '' }, + '../config/util': { getPlatform: () => 'desktop' }, + '../shared/store': { shareStore: { getAuthInfo: async () => auth } }, + }).default; + await loader({ server: {} }); + const connection = (token) => { + const c = new EventEmitter(); + Object.assign(c, { + headers: {}, + pathname: '/api/ws/a/b/websocket', + url: `/api/ws/a/b/websocket?token=${token}`, + close() { + this.closed = true; + this.emit('close'); + }, + write() {}, + }); + return c; + }; + const rejected = connection(expired); + await onConnection(rejected); + assert.equal(rejected.closed, true); + assert.equal(clients.size, 0); + const accepted = connection(valid); + await onConnection(accepted); + assert.equal(clients.size, 1); + auth = { token: '', tokens: {} }; + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + accepted.close(); + reject(new Error('revoked socket remained open')); + }, 3000); + accepted.once('close', () => { + clearTimeout(timeout); + resolve(); + }); + }); + assert.equal(clients.size, 0); +}); diff --git a/test/back/user-api.test.cjs b/test/back/user-api.test.cjs index 42e6a69f..f0c275a6 100644 --- a/test/back/user-api.test.cjs +++ b/test/back/user-api.test.cjs @@ -46,7 +46,7 @@ test('initialization returns the username and password validation result', async paths: [], }; Container.get = () => ({ - updateUsernameAndPassword: async () => ({ + initializeUser: async () => ({ code: 400, message: 'password rejected', }), diff --git a/test/back/user-security.test.cjs b/test/back/user-security.test.cjs new file mode 100644 index 00000000..963aadcd --- /dev/null +++ b/test/back/user-security.test.cjs @@ -0,0 +1,248 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const path = require('node:path'); +const jwt = require('jsonwebtoken'); +const { authenticator } = require('@otplib/preset-default'); +const load = require('../helpers/load-security-module.cjs'); +const { verifyPassword, isPasswordHash } = load( + path.join(__dirname, '../../back/shared/password.ts'), +); +const req = { + platform: 'desktop', + headers: {}, + socket: { remoteAddress: '127.0.0.1' }, +}; + +function fixture(initial) { + let auth = structuredClone(initial); + let closed = 0; + const model = { + findOne: async () => ({ id: 1 }), + update: async () => [1, [{}]], + create: async (x) => x, + findAll: async () => [], + destroy: async () => {}, + }; + const mocks = { + typedi: { Service: () => (x) => x, Inject: () => () => {} }, + '../config': { jwt: { secret: 'test-secret' }, maxTokensPerPlatform: 10 }, + '../data/system': { + AuthDataType: { authConfig: 'authConfig', loginLog: 'loginLog' }, + LoginStatus: { fail: 0, success: 1 }, + SystemModel: model, + }, + '../config/util': { + createRandomString: () => + require('node:crypto').randomBytes(32).toString('hex'), + }, + './notify': class {}, + './schedule': class {}, + './sock': class {}, + '../shared/store': { + shareStore: { + getAuthInfo: async () => auth, + updateAuthInfo: async (x) => { + auth = x; + }, + }, + }, + '../shared/i18n': { t: (x) => x, tf: (x, y) => x.replace('%s', y) }, + '../shared/clientIp': { + getClientIp: () => '127.0.0.1', + normalizeClientIp: (x) => x, + }, + ip2region: class { + search() { + return null; + } + }, + }; + const User = load( + path.join(__dirname, '../../back/services/user.ts'), + mocks, + ).default; + const user = new User( + { warn() {}, info() {} }, + {}, + { getClients: () => [{ close: () => closed++ }] }, + ); + user.notificationService = { notify() {} }; + return { + user, + get auth() { + return auth; + }, + get closed() { + return closed; + }, + }; +} +const initialized = () => ({ + username: 'owner', + password: 'old-password', + token: 'stolen-token', + tokens: { desktop: [{ value: 'stolen-token' }] }, + retries: 0, + lastlogon: 0, +}); + +test('initialization checks state inside a serialized mutation', async () => { + const f = fixture({ username: 'admin', password: 'admin' }); + assert.equal( + (await f.user.login({ username: 'admin', password: 'admin' }, req)).code, + 450, + ); + assert.deepEqual(f.auth, { username: 'admin', password: 'admin' }); + const results = await Promise.all([ + f.user.initializeUser({ username: 'owner', password: 'first-password' }), + f.user.initializeUser({ + username: 'attacker', + password: 'second-password', + }), + ]); + assert.deepEqual( + results.map((x) => x.code), + [200, 450], + ); + assert.equal(f.auth.username, 'owner'); + assert.equal(await verifyPassword('first-password', f.auth.password), true); + assert.equal( + ( + await f.user.initializeUser({ + username: 'attacker', + password: 'third-password', + }) + ).code, + 450, + ); +}); + +test('password change revokes sessions and closes connected clients', async () => { + const f = fixture(initialized()); + await f.user.updateUsernameAndPassword({ + username: 'owner', + password: 'new-password', + }); + assert.equal(f.auth.token, ''); + assert.deepEqual(f.auth.tokens, {}); + assert.equal(f.closed, 1); + assert.equal(await verifyPassword('new-password', f.auth.password), true); + assert.equal( + (await f.user.login({ username: 'owner', password: 'old-password' }, req)) + .code, + 400, + ); + const login = await f.user.login( + { username: 'owner', password: 'new-password' }, + req, + ); + assert.equal(login.code, 200); + jwt.verify(login.data.token, 'test-secret', { algorithms: ['HS384'] }); +}); + +test('a concurrent old-password login cannot restore a session after reset', async () => { + const f = fixture(initialized()); + const results = await Promise.all([ + f.user.login({ username: 'owner', password: 'old-password' }, req), + f.user.resetAuthInfo({ password: 'new-password' }), + ]); + assert.equal(results[0].code, 200); + assert.equal(f.auth.token, ''); + assert.deepEqual(f.auth.tokens, {}); + assert.equal(await verifyPassword('new-password', f.auth.password), true); +}); + +test('legacy plaintext migrates after a successful login', async () => { + const f = fixture(initialized()); + assert.equal( + (await f.user.login({ username: 'owner', password: 'old-password' }, req)) + .code, + 200, + ); + assert.equal(isPasswordHash(f.auth.password), true); + assert.equal(await verifyPassword('old-password', f.auth.password), true); +}); + +test('TOTP failures are counted serially and further attempts are throttled', async () => { + const secret = authenticator.generateSecret(); + const f = fixture({ + ...initialized(), + twoFactorActivated: true, + twoFactorSecret: secret, + }); + assert.equal( + (await f.user.login({ username: 'owner', password: 'old-password' }, req)) + .code, + 420, + ); + const badCode = + authenticator.generate(secret) === '000000' ? '111111' : '000000'; + const results = await Promise.all( + Array.from({ length: 120 }, () => + f.user.twoFactorLogin( + { username: 'owner', password: 'old-password', code: badCode }, + req, + ), + ), + ); + assert.equal(f.auth.retries, 3); + assert.equal(results.filter((x) => x.code === 430).length, 3); + assert.equal(results.filter((x) => x.code === 410).length, 117); +}); + +test('TOTP challenge expires and valid codes cannot be reused in the same step', async () => { + const secret = authenticator.generateSecret(); + const f = fixture({ + ...initialized(), + twoFactorActivated: true, + twoFactorSecret: secret, + }); + await f.user.login({ username: 'owner', password: 'old-password' }, req); + f.auth.twoFactorExpiresAt = Date.now() - 1; + const payload = { + username: 'owner', + password: 'old-password', + code: authenticator.generate(secret), + }; + assert.equal((await f.user.twoFactorLogin(payload, req)).code, 450); + await f.user.login({ username: 'owner', password: 'old-password' }, req); + assert.equal((await f.user.twoFactorLogin(payload, req)).code, 200); + await f.user.login({ username: 'owner', password: 'old-password' }, req); + assert.equal((await f.user.twoFactorLogin(payload, req)).code, 430); +}); + +test('active two-factor secret cannot be silently replaced and disabling revokes sessions', async () => { + const f = fixture({ + ...initialized(), + twoFactorActivated: true, + twoFactorSecret: authenticator.generateSecret(), + }); + await assert.rejects(f.user.initTwoFactor()); + await f.user.deactivateTwoFactor(); + assert.equal(f.auth.token, ''); + assert.deepEqual(f.auth.tokens, {}); + assert.equal(f.auth.twoFactorSecret, ''); +}); + +test('default credentials with historical metadata still require initialization', async () => { + const f = fixture({ + username: 'admin', + password: 'admin', + retries: 1, + token: 'old-default-session', + }); + assert.equal( + (await f.user.login({ username: 'admin', password: 'admin' }, req)).code, + 450, + ); + assert.equal( + ( + await f.user.initializeUser({ + username: 'owner', + password: 'new-password', + }) + ).code, + 200, + ); + assert.equal(f.auth.token, ''); +}); diff --git a/test/helpers/load-security-module.cjs b/test/helpers/load-security-module.cjs new file mode 100644 index 00000000..fd286ab6 --- /dev/null +++ b/test/helpers/load-security-module.cjs @@ -0,0 +1,42 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const ts = require('typescript'); + +module.exports = function loadSecurityModule( + file, + mocks = {}, + cache = new Map(), +) { + file = path.resolve(file); + if (cache.has(file)) return cache.get(file).exports; + const module = { exports: {} }; + cache.set(file, module); + const localRequire = createRequire(file); + const requireModule = (name) => { + if (Object.hasOwn(mocks, name)) return mocks[name]; + if (name.startsWith('.')) { + const target = path.resolve(path.dirname(file), name); + if (fs.existsSync(`${target}.ts`)) { + return loadSecurityModule(`${target}.ts`, mocks, cache); + } + } + return localRequire(name); + }; + const { outputText } = ts.transpileModule(fs.readFileSync(file, 'utf8'), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + experimentalDecorators: true, + esModuleInterop: true, + }, + }); + // Use the host realm: express-unless checks RegExp with instanceof. + new Function('require', 'module', 'exports', '__dirname', outputText)( + requireModule, + module, + module.exports, + path.dirname(file), + ); + return module.exports; +};