mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-15 19:57:07 +08:00
fix: harden authentication and file access security
This commit is contained in:
@@ -35,3 +35,6 @@ __pycache__
|
|||||||
|
|
||||||
# local Kubernetes overlays
|
# local Kubernetes overlays
|
||||||
/deploy/kubernetes/overlays/local/
|
/deploy/kubernetes/overlays/local/
|
||||||
|
|
||||||
|
# Local security audit artifacts
|
||||||
|
/audit/
|
||||||
|
|||||||
+13
-17
@@ -9,6 +9,7 @@ import { SAMPLE_FILES } from '../config/const';
|
|||||||
import { t } from '../shared/i18n';
|
import { t } from '../shared/i18n';
|
||||||
import ConfigService from '../services/config';
|
import ConfigService from '../services/config';
|
||||||
import { writeFileWithLock } from '../shared/utils';
|
import { writeFileWithLock } from '../shared/utils';
|
||||||
|
import { resolveFileAccess } from '../shared/fileAccess';
|
||||||
const route = Router();
|
const route = Router();
|
||||||
|
|
||||||
export default (app: Router) => {
|
export default (app: Router) => {
|
||||||
@@ -78,14 +79,12 @@ export default (app: Router) => {
|
|||||||
basePath = join(config.rootPath, 'data/scripts');
|
basePath = join(config.rootPath, 'data/scripts');
|
||||||
}
|
}
|
||||||
const cleanName = name.replace(/^data\/scripts\//, '');
|
const cleanName = name.replace(/^data\/scripts\//, '');
|
||||||
const resolvedPath = join(basePath, cleanName);
|
const normalized = resolveFileAccess(
|
||||||
const normalized = join(resolvedPath);
|
basePath,
|
||||||
// Verify the resolved path stays within allowed directory
|
[cleanName],
|
||||||
if (!normalized.startsWith(basePath)) {
|
config.blackFileList,
|
||||||
return res.send({ code: 403, message: t('文件路径无效') });
|
);
|
||||||
}
|
if (!normalized) {
|
||||||
// Check blacklist on actual filename (not user input)
|
|
||||||
if (config.blackFileList.includes(basename(normalized))) {
|
|
||||||
return res.send({ code: 403, message: t('文件无法访问') });
|
return res.send({ code: 403, message: t('文件无法访问') });
|
||||||
}
|
}
|
||||||
await writeFileWithLock(normalized, content);
|
await writeFileWithLock(normalized, content);
|
||||||
@@ -96,13 +95,10 @@ export default (app: Router) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
route.get(
|
route.get('/:file', (req: Request, res: Response) => {
|
||||||
'/:file',
|
return res.send({
|
||||||
(req: Request, res: Response) => {
|
code: 410,
|
||||||
return res.send({
|
message: t('接口已下线,请使用 /configs/detail 接口'),
|
||||||
code: 410,
|
});
|
||||||
message: t('接口已下线,请使用 /configs/detail 接口'),
|
});
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
+33
-15
@@ -1,3 +1,5 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { resolveFileAccess } from '../shared/fileAccess';
|
||||||
import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
|
import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
|
||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
@@ -14,15 +16,17 @@ const route = Router();
|
|||||||
|
|
||||||
function isPathAllowed(targetPath: string): boolean {
|
function isPathAllowed(targetPath: string): boolean {
|
||||||
const resolved = path.resolve(targetPath);
|
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({
|
const storage = multer.diskStorage({
|
||||||
destination: function (req, file, cb) {
|
destination: function (req, file, cb) {
|
||||||
cb(null, config.scriptPath);
|
cb(null, config.tmpPath);
|
||||||
},
|
},
|
||||||
filename: function (req, file, cb) {
|
filename: function (req, file, cb) {
|
||||||
cb(null, file.originalname);
|
cb(null, randomUUID());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const upload = multer({ storage: storage });
|
const upload = multer({ storage: storage });
|
||||||
@@ -50,6 +54,15 @@ export default (app: Router) => {
|
|||||||
'package-lock.json',
|
'package-lock.json',
|
||||||
];
|
];
|
||||||
if (req.query.path) {
|
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(
|
result = await readDir(
|
||||||
req.query.path as string,
|
req.query.path as string,
|
||||||
config.scriptPath,
|
config.scriptPath,
|
||||||
@@ -77,7 +90,8 @@ export default (app: Router) => {
|
|||||||
logger.error('🔥 error: %o', e);
|
logger.error('🔥 error: %o', e);
|
||||||
return next(e);
|
return next(e);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
route.get(
|
route.get(
|
||||||
'/detail',
|
'/detail',
|
||||||
@@ -91,7 +105,7 @@ export default (app: Router) => {
|
|||||||
try {
|
try {
|
||||||
const scriptService = Container.get(ScriptService);
|
const scriptService = Container.get(ScriptService);
|
||||||
const content = await scriptService.getFile(
|
const content = await scriptService.getFile(
|
||||||
req.query?.path as string || '',
|
(req.query?.path as string) || '',
|
||||||
req.query.file as string,
|
req.query.file as string,
|
||||||
);
|
);
|
||||||
res.send({ code: 200, data: content });
|
res.send({ code: 200, data: content });
|
||||||
@@ -101,18 +115,21 @@ export default (app: Router) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
route.get(
|
route.get('/:file', (req: Request, res: Response) => {
|
||||||
'/:file',
|
return res.send({
|
||||||
(req: Request, res: Response) => {
|
code: 410,
|
||||||
return res.send({
|
message: t('接口已下线,请使用 /scripts/detail 接口'),
|
||||||
code: 410,
|
});
|
||||||
message: t('接口已下线,请使用 /scripts/detail 接口'),
|
});
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
route.post(
|
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'),
|
upload.single('file'),
|
||||||
celebrate({
|
celebrate({
|
||||||
body: Joi.object({
|
body: Joi.object({
|
||||||
@@ -156,7 +173,8 @@ export default (app: Router) => {
|
|||||||
if (!isPathAllowed(uploadPath)) {
|
if (!isPathAllowed(uploadPath)) {
|
||||||
return res.send({ code: 403, message: t('暂无权限') });
|
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 });
|
return res.send({ code: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-10
@@ -22,7 +22,25 @@ const storage = multer.diskStorage({
|
|||||||
cb(null, key + ext);
|
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<string, string> = {
|
||||||
|
'.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) => {
|
export default (app: Router) => {
|
||||||
app.use('/user', route);
|
app.use('/user', route);
|
||||||
@@ -35,8 +53,8 @@ export default (app: Router) => {
|
|||||||
}),
|
}),
|
||||||
celebrate({
|
celebrate({
|
||||||
body: Joi.object({
|
body: Joi.object({
|
||||||
username: Joi.string().required(),
|
username: Joi.string().max(1024).required(),
|
||||||
password: Joi.string().required(),
|
password: Joi.string().max(1024).required(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -70,8 +88,8 @@ export default (app: Router) => {
|
|||||||
'/',
|
'/',
|
||||||
celebrate({
|
celebrate({
|
||||||
body: Joi.object({
|
body: Joi.object({
|
||||||
username: Joi.string().required(),
|
username: Joi.string().max(1024).required(),
|
||||||
password: Joi.string().required(),
|
password: Joi.string().max(1024).required(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -156,11 +174,12 @@ export default (app: Router) => {
|
|||||||
|
|
||||||
route.put(
|
route.put(
|
||||||
'/two-factor/login',
|
'/two-factor/login',
|
||||||
|
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
|
||||||
celebrate({
|
celebrate({
|
||||||
body: Joi.object({
|
body: Joi.object({
|
||||||
code: Joi.string().required(),
|
code: Joi.string().required(),
|
||||||
username: Joi.string().required(),
|
username: Joi.string().max(1024).required(),
|
||||||
password: Joi.string().required(),
|
password: Joi.string().max(1024).required(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -272,17 +291,18 @@ export default (app: Router) => {
|
|||||||
|
|
||||||
route.put(
|
route.put(
|
||||||
'/init',
|
'/init',
|
||||||
|
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
|
||||||
celebrate({
|
celebrate({
|
||||||
body: Joi.object({
|
body: Joi.object({
|
||||||
username: Joi.string().required(),
|
username: Joi.string().max(1024).required(),
|
||||||
password: Joi.string().required(),
|
password: Joi.string().max(1024).required(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
const logger: Logger = Container.get('logger');
|
||||||
try {
|
try {
|
||||||
const userService = Container.get(UserService);
|
const userService = Container.get(UserService);
|
||||||
const result = await userService.updateUsernameAndPassword(req.body);
|
const result = await userService.initializeUser(req.body);
|
||||||
res.send(result);
|
res.send(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(e);
|
return next(e);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { randomInt } from 'crypto';
|
||||||
|
|
||||||
export function createRandomString(min: number, max: number): string {
|
export function createRandomString(min: number, max: number): string {
|
||||||
const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||||
const english = [
|
const english = [
|
||||||
@@ -65,19 +67,19 @@ export function createRandomString(min: number, max: number): string {
|
|||||||
arr.push(getOne(ENGLISH));
|
arr.push(getOne(ENGLISH));
|
||||||
arr.push(getOne(special));
|
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++) {
|
for (let i = 4; i < len; i++) {
|
||||||
arr.push(config[Math.floor(Math.random() * config.length)]);
|
arr.push(config[randomInt(config.length)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newArr = [];
|
const newArr = [];
|
||||||
for (let j = 0; j < len; j++) {
|
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[]) {
|
function getOne(arr: any[]) {
|
||||||
return arr[Math.floor(Math.random() * arr.length)];
|
return arr[randomInt(arr.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
return newArr.join('');
|
return newArr.join('');
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export interface AuthInfo {
|
|||||||
lastaddr: string;
|
lastaddr: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
isTwoFactorChecking: boolean;
|
isTwoFactorChecking: boolean;
|
||||||
|
twoFactorExpiresAt?: number;
|
||||||
|
lastTwoFactorStep?: number;
|
||||||
token: string;
|
token: string;
|
||||||
tokens: Record<string, string | TokenInfo[]>;
|
tokens: Record<string, string | TokenInfo[]>;
|
||||||
twoFactorActivated: boolean;
|
twoFactorActivated: boolean;
|
||||||
|
|||||||
+21
-2
@@ -48,7 +48,26 @@ export default ({ app }: { app: Application }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.get(`${config.api.prefix}/env.js`, serveEnv);
|
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.json({ limit: '50mb' }));
|
||||||
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
||||||
@@ -123,7 +142,7 @@ export default ({ app }: { app: Application }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authInfo = await shareStore.getAuthInfo();
|
const authInfo = await shareStore.getAuthInfo();
|
||||||
if (isValidToken(authInfo, headerToken, req.platform)) {
|
if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-2
@@ -8,26 +8,44 @@ import { isValidToken } from '../shared/auth';
|
|||||||
import config from '../config';
|
import config from '../config';
|
||||||
|
|
||||||
export default async ({ server }: { server: Server }) => {
|
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);
|
const sockService = Container.get(SockService);
|
||||||
|
|
||||||
echo.on('connection', async (conn) => {
|
echo.on('connection', async (conn) => {
|
||||||
if (!conn.headers || !conn.url || !conn.pathname) {
|
if (!conn.headers || !conn.url || !conn.pathname) {
|
||||||
conn.close('404');
|
conn.close('404');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authInfo = await shareStore.getAuthInfo();
|
const authInfo = await shareStore.getAuthInfo();
|
||||||
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
|
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
|
||||||
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
|
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
|
||||||
|
|
||||||
if (isValidToken(authInfo, headerToken, platform)) {
|
if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) {
|
||||||
sockService.addClient(conn);
|
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.on('data', (message) => {
|
||||||
conn.write(message);
|
conn.write(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
conn.on('close', function () {
|
conn.on('close', function () {
|
||||||
|
clearInterval(checkSession);
|
||||||
sockService.removeClient(conn);
|
sockService.removeClient(conn);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+10
-20
@@ -1,10 +1,10 @@
|
|||||||
import { Service, Inject } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
import path, { join } from 'path';
|
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { getFileContentByName } from '../config/util';
|
import { getFileContentByName } from '../config/util';
|
||||||
import { t } from '../shared/i18n';
|
import { t } from '../shared/i18n';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { request } from 'undici';
|
import { request } from 'undici';
|
||||||
|
import { resolveFileAccess } from '../shared/fileAccess';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class ConfigService {
|
export default class ConfigService {
|
||||||
@@ -15,21 +15,13 @@ export default class ConfigService {
|
|||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return res.send({ code: 403, message: t('文件无法访问') });
|
return res.send({ code: 403, message: t('文件无法访问') });
|
||||||
}
|
}
|
||||||
const normalized = path.normalize(filePath);
|
const scriptFile = filePath.startsWith('data/scripts/');
|
||||||
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
|
const resolved = resolveFileAccess(
|
||||||
return res.send({ code: 403, message: t('文件无法访问') });
|
scriptFile ? config.scriptPath : config.configPath,
|
||||||
}
|
[scriptFile ? filePath.slice('data/scripts/'.length) : filePath],
|
||||||
const resolvedRoot = path.resolve(config.rootPath, normalized);
|
config.blackFileList,
|
||||||
const resolvedConfig = path.resolve(config.configPath, normalized);
|
);
|
||||||
const isValidPath =
|
if (!resolved) {
|
||||||
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))) {
|
|
||||||
return res.send({ code: 403, message: t('文件无法访问') });
|
return res.send({ code: 403, message: t('文件无法访问') });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,10 +30,8 @@ export default class ConfigService {
|
|||||||
`https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`,
|
`https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`,
|
||||||
);
|
);
|
||||||
content = await res.body.text();
|
content = await res.body.text();
|
||||||
} else if (filePath.startsWith('data/scripts/')) {
|
|
||||||
content = await getFileContentByName(join(config.rootPath, filePath));
|
|
||||||
} else {
|
} else {
|
||||||
content = await getFileContentByName(join(config.configPath, filePath));
|
content = await getFileContentByName(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.send({ code: 200, data: content });
|
res.send({ code: 200, data: content });
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class GrpcServerService {
|
|||||||
const grpcPort = config.grpcPort;
|
const grpcPort = config.grpcPort;
|
||||||
const hostsToTry = [
|
const hostsToTry = [
|
||||||
config.bindHostGrpc,
|
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);
|
const bindAsync = promisify(this.server.bindAsync).bind(this.server);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class HttpServerService {
|
|||||||
async initialize(expressApp: express.Application, port: number) {
|
async initialize(expressApp: express.Application, port: number) {
|
||||||
const hostsToTry = [
|
const hostsToTry = [
|
||||||
config.bindHost,
|
config.bindHost,
|
||||||
...(config.bindHost !== '0.0.0.0' ? ['0.0.0.0'] : [])
|
...(config.bindHost === '::' ? ['0.0.0.0'] : [])
|
||||||
];
|
];
|
||||||
|
|
||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveFileAccess } from '../shared/fileAccess';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { Inject, Service } from 'typedi';
|
import { Inject, Service } from 'typedi';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
@@ -8,7 +9,10 @@ export default class LogService {
|
|||||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
constructor(@Inject('logger') private logger: winston.Logger) {}
|
||||||
|
|
||||||
public checkFilePath(filePath: string, fileName: string) {
|
public checkFilePath(filePath: string, fileName: string) {
|
||||||
const finalPath = path.resolve(config.logPath, filePath, fileName);
|
return resolveFileAccess(
|
||||||
return finalPath.startsWith(config.logPath) ? finalPath : '';
|
config.logPath,
|
||||||
|
[filePath || '', fileName],
|
||||||
|
config.blackFileList,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveFileAccess } from '../shared/fileAccess';
|
||||||
import { Service, Inject } from 'typedi';
|
import { Service, Inject } from 'typedi';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import path, { join } from 'path';
|
import path, { join } from 'path';
|
||||||
@@ -65,8 +66,11 @@ export default class ScriptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public checkFilePath(filePath: string, fileName: string) {
|
public checkFilePath(filePath: string, fileName: string) {
|
||||||
const finalPath = path.resolve(config.scriptPath, filePath, fileName);
|
return resolveFileAccess(
|
||||||
return finalPath.startsWith(config.scriptPath) ? finalPath : '';
|
config.scriptPath,
|
||||||
|
[filePath || '', fileName],
|
||||||
|
config.blackFileList,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFile(filePath: string, fileName: string) {
|
public async getFile(filePath: string, fileName: string) {
|
||||||
|
|||||||
+126
-15
@@ -26,6 +26,13 @@ import isNil from 'lodash/isNil';
|
|||||||
import { shareStore } from '../shared/store';
|
import { shareStore } from '../shared/store';
|
||||||
import { t, tf } from '../shared/i18n';
|
import { t, tf } from '../shared/i18n';
|
||||||
import { getClientIp, normalizeClientIp } from '../shared/clientIp';
|
import { getClientIp, normalizeClientIp } from '../shared/clientIp';
|
||||||
|
import { isDefaultAuthInfo } from '../shared/auth';
|
||||||
|
import {
|
||||||
|
hashPassword,
|
||||||
|
isPasswordHash,
|
||||||
|
verifyPassword,
|
||||||
|
} from '../shared/password';
|
||||||
|
import { serializeAuthMutation } from '../shared/authMutation';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class UserService {
|
export default class UserService {
|
||||||
@@ -38,7 +45,15 @@ export default class UserService {
|
|||||||
private sockService: SockService,
|
private sockService: SockService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@serializeAuthMutation
|
||||||
public async login(
|
public async login(
|
||||||
|
payloads: { username: string; password: string },
|
||||||
|
req: Request,
|
||||||
|
): Promise<any> {
|
||||||
|
return this.authenticate(payloads, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async authenticate(
|
||||||
payloads: {
|
payloads: {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -48,6 +63,9 @@ export default class UserService {
|
|||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
let { username, password } = payloads;
|
let { username, password } = payloads;
|
||||||
const content = await this.getAuthInfo();
|
const content = await this.getAuthInfo();
|
||||||
|
if (isDefaultAuthInfo(content)) {
|
||||||
|
return { code: 450, message: t('请先初始化') };
|
||||||
|
}
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const ip = getClientIp(req);
|
const ip = getClientIp(req);
|
||||||
const query = new IP2Region();
|
const query = new IP2Region();
|
||||||
@@ -100,14 +118,13 @@ export default class UserService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const passwordMatches =
|
||||||
username === cUsername &&
|
username === cUsername && (await verifyPassword(password, cPassword));
|
||||||
password === cPassword &&
|
|
||||||
twoFactorActivated &&
|
if (passwordMatches && twoFactorActivated && needTwoFactor) {
|
||||||
needTwoFactor
|
|
||||||
) {
|
|
||||||
await this.updateAuthInfo(content, {
|
await this.updateAuthInfo(content, {
|
||||||
isTwoFactorChecking: true,
|
isTwoFactorChecking: true,
|
||||||
|
twoFactorExpiresAt: timestamp + 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
code: 420,
|
code: 420,
|
||||||
@@ -115,7 +132,7 @@ export default class UserService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (username === cUsername && password === cPassword) {
|
if (passwordMatches) {
|
||||||
const data = createRandomString(50, 100);
|
const data = createRandomString(50, 100);
|
||||||
const expiration = twoFactorActivated ? '60d' : '20d';
|
const expiration = twoFactorActivated ? '60d' : '20d';
|
||||||
let token = jwt.sign({ data }, config.jwt.secret, {
|
let token = jwt.sign({ data }, config.jwt.secret, {
|
||||||
@@ -138,6 +155,9 @@ export default class UserService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await this.updateAuthInfo(content, {
|
await this.updateAuthInfo(content, {
|
||||||
|
password: isPasswordHash(cPassword)
|
||||||
|
? cPassword
|
||||||
|
: await hashPassword(password),
|
||||||
token,
|
token,
|
||||||
tokens: updatedTokens,
|
tokens: updatedTokens,
|
||||||
lastlogon: timestamp,
|
lastlogon: timestamp,
|
||||||
@@ -146,6 +166,7 @@ export default class UserService {
|
|||||||
lastaddr: address,
|
lastaddr: address,
|
||||||
platform: req.platform,
|
platform: req.platform,
|
||||||
isTwoFactorChecking: false,
|
isTwoFactorChecking: false,
|
||||||
|
twoFactorExpiresAt: 0,
|
||||||
});
|
});
|
||||||
this.notificationService.notify(
|
this.notificationService.notify(
|
||||||
t('登录通知'),
|
t('登录通知'),
|
||||||
@@ -231,6 +252,7 @@ export default class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@serializeAuthMutation
|
||||||
public async logout(platform: string, tokenValue: string): Promise<any> {
|
public async logout(platform: string, tokenValue: string): Promise<any> {
|
||||||
if (!platform || !tokenValue) {
|
if (!platform || !tokenValue) {
|
||||||
this.logger.warn('Invalid logout parameters - empty platform or token');
|
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<string[]> {
|
public async blockIp(ip: string): Promise<string[]> {
|
||||||
const authInfo = await this.getAuthInfo();
|
const authInfo = await this.getAuthInfo();
|
||||||
const blockedIps = uniq([
|
const blockedIps = uniq([
|
||||||
@@ -301,6 +324,7 @@ export default class UserService {
|
|||||||
return blockedIps;
|
return blockedIps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@serializeAuthMutation
|
||||||
public async unblockIp(ip: string): Promise<string[]> {
|
public async unblockIp(ip: string): Promise<string[]> {
|
||||||
const authInfo = await this.getAuthInfo();
|
const authInfo = await this.getAuthInfo();
|
||||||
const normalizedIp = normalizeClientIp(ip);
|
const normalizedIp = normalizeClientIp(ip);
|
||||||
@@ -316,6 +340,33 @@ export default class UserService {
|
|||||||
return doc;
|
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({
|
public async updateUsernameAndPassword({
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
@@ -327,24 +378,37 @@ export default class UserService {
|
|||||||
return { code: 400, message: t('密码不能设置为admin') };
|
return { code: 400, message: t('密码不能设置为admin') };
|
||||||
}
|
}
|
||||||
const authInfo = await this.getAuthInfo();
|
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('更新成功') };
|
return { code: 200, message: t('更新成功') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@serializeAuthMutation
|
||||||
public async updateAvatar(avatar: string) {
|
public async updateAvatar(avatar: string) {
|
||||||
const authInfo = await this.getAuthInfo();
|
const authInfo = await this.getAuthInfo();
|
||||||
await this.updateAuthInfo(authInfo, { avatar });
|
await this.updateAuthInfo(authInfo, { avatar });
|
||||||
return { code: 200, data: avatar, message: t('更新成功') };
|
return { code: 200, data: avatar, message: t('更新成功') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@serializeAuthMutation
|
||||||
public async initTwoFactor() {
|
public async initTwoFactor() {
|
||||||
const secret = authenticator.generateSecret();
|
const secret = authenticator.generateSecret();
|
||||||
const authInfo = await this.getAuthInfo();
|
const authInfo = await this.getAuthInfo();
|
||||||
|
if (authInfo.twoFactorActivated) {
|
||||||
|
throw new Error(t('请先关闭两步验证'));
|
||||||
|
}
|
||||||
const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret);
|
const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret);
|
||||||
await this.updateAuthInfo(authInfo, { twoFactorSecret: secret });
|
await this.updateAuthInfo(authInfo, { twoFactorSecret: secret });
|
||||||
return { secret, url: otpauth };
|
return { secret, url: otpauth };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@serializeAuthMutation
|
||||||
public async activeTwoFactor(code: string) {
|
public async activeTwoFactor(code: string) {
|
||||||
const authInfo = await this.getAuthInfo();
|
const authInfo = await this.getAuthInfo();
|
||||||
const isValid = authenticator.verify({
|
const isValid = authenticator.verify({
|
||||||
@@ -352,11 +416,18 @@ export default class UserService {
|
|||||||
secret: authInfo.twoFactorSecret,
|
secret: authInfo.twoFactorSecret,
|
||||||
});
|
});
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
await this.updateAuthInfo(authInfo, { twoFactorActivated: true });
|
await this.updateAuthInfo(authInfo, {
|
||||||
|
twoFactorActivated: true,
|
||||||
|
token: '',
|
||||||
|
tokens: {},
|
||||||
|
isTwoFactorChecking: false,
|
||||||
|
twoFactorExpiresAt: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return isValid;
|
return isValid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@serializeAuthMutation
|
||||||
public async twoFactorLogin(
|
public async twoFactorLogin(
|
||||||
{
|
{
|
||||||
username,
|
username,
|
||||||
@@ -367,15 +438,28 @@ export default class UserService {
|
|||||||
) {
|
) {
|
||||||
const authInfo = await this.getAuthInfo();
|
const authInfo = await this.getAuthInfo();
|
||||||
const { isTwoFactorChecking, twoFactorSecret } = authInfo;
|
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('未知错误') };
|
return { code: 450, message: t('未知错误') };
|
||||||
}
|
}
|
||||||
const isValid = authenticator.verify({
|
const step = Math.floor(now / 30000);
|
||||||
token: code,
|
const isValid =
|
||||||
secret: twoFactorSecret,
|
username === authInfo.username &&
|
||||||
});
|
(await verifyPassword(password, authInfo.password)) &&
|
||||||
|
authInfo.lastTwoFactorStep !== step &&
|
||||||
|
authenticator.verify({ token: code, secret: twoFactorSecret });
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
return this.login({ username, password }, req, false);
|
await this.updateAuthInfo(authInfo, { lastTwoFactorStep: step });
|
||||||
|
return this.authenticate({ username, password }, req, false);
|
||||||
} else {
|
} else {
|
||||||
const ip = getClientIp(req);
|
const ip = getClientIp(req);
|
||||||
const query = new IP2Region();
|
const query = new IP2Region();
|
||||||
@@ -388,6 +472,9 @@ export default class UserService {
|
|||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
await this.updateAuthInfo(authInfo, {
|
await this.updateAuthInfo(authInfo, {
|
||||||
|
retries: retries + 1,
|
||||||
|
lastlogon: now,
|
||||||
|
isTwoFactorChecking: retries + 1 < 5,
|
||||||
lastip: ip,
|
lastip: ip,
|
||||||
lastaddr: address,
|
lastaddr: address,
|
||||||
platform: req.platform,
|
platform: req.platform,
|
||||||
@@ -396,11 +483,16 @@ export default class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@serializeAuthMutation
|
||||||
public async deactivateTwoFactor() {
|
public async deactivateTwoFactor() {
|
||||||
const authInfo = await this.getAuthInfo();
|
const authInfo = await this.getAuthInfo();
|
||||||
await this.updateAuthInfo(authInfo, {
|
await this.updateAuthInfo(authInfo, {
|
||||||
twoFactorActivated: false,
|
twoFactorActivated: false,
|
||||||
twoFactorSecret: '',
|
twoFactorSecret: '',
|
||||||
|
token: '',
|
||||||
|
tokens: {},
|
||||||
|
isTwoFactorChecking: false,
|
||||||
|
twoFactorExpiresAt: 0,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -421,6 +513,9 @@ export default class UserService {
|
|||||||
type: AuthDataType.authConfig,
|
type: AuthDataType.authConfig,
|
||||||
info: result,
|
info: result,
|
||||||
});
|
});
|
||||||
|
if (info.tokens && Object.keys(info.tokens).length === 0) {
|
||||||
|
this.sockService.getClients().forEach((conn) => conn.close('401'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getNotificationMode(): Promise<NotificationInfo> {
|
public async getNotificationMode(): Promise<NotificationInfo> {
|
||||||
@@ -562,6 +657,7 @@ export default class UserService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@serializeAuthMutation
|
||||||
public async resetAuthInfo(info: Partial<AuthInfo>) {
|
public async resetAuthInfo(info: Partial<AuthInfo>) {
|
||||||
const { retries, twoFactorActivated, password, username } = info;
|
const { retries, twoFactorActivated, password, username } = info;
|
||||||
if (password === 'admin') {
|
if (password === 'admin') {
|
||||||
@@ -578,6 +674,21 @@ export default class UserService {
|
|||||||
(x) => !isNil(x),
|
(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);
|
await this.updateAuthInfo(authInfo, payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-6
@@ -1,11 +1,8 @@
|
|||||||
import { AuthInfo, TokenInfo } from '../data/system';
|
import { AuthInfo, TokenInfo } from '../data/system';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
export function isDefaultAuthInfo(authInfo: AuthInfo): boolean {
|
export function isDefaultAuthInfo(authInfo: AuthInfo): boolean {
|
||||||
return (
|
return authInfo.username === 'admin' && authInfo.password === 'admin';
|
||||||
Object.keys(authInfo).length === 2 &&
|
|
||||||
authInfo.username === 'admin' &&
|
|
||||||
authInfo.password === 'admin'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,11 +18,21 @@ export function isValidToken(
|
|||||||
authInfo: AuthInfo | null | undefined,
|
authInfo: AuthInfo | null | undefined,
|
||||||
headerToken: string,
|
headerToken: string,
|
||||||
platform: string,
|
platform: string,
|
||||||
|
secret: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!authInfo || !headerToken) {
|
if (!authInfo || !headerToken) {
|
||||||
return false;
|
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;
|
const { token = '', tokens = {} } = authInfo;
|
||||||
|
|
||||||
// Check legacy token field
|
// Check legacy token field
|
||||||
@@ -46,7 +53,12 @@ export function isValidToken(
|
|||||||
return headerToken === platformTokens;
|
return headerToken === platformTokens;
|
||||||
} else if (Array.isArray(platformTokens)) {
|
} else if (Array.isArray(platformTokens)) {
|
||||||
// New format: array of TokenInfo objects
|
// 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
|
// Unexpected type - log warning and reject
|
||||||
|
|||||||
@@ -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<unknown> = 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
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<boolean> {
|
||||||
|
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'));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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'),
|
||||||
|
'<script>window.test=1</script>',
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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`],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -46,7 +46,7 @@ test('initialization returns the username and password validation result', async
|
|||||||
paths: [],
|
paths: [],
|
||||||
};
|
};
|
||||||
Container.get = () => ({
|
Container.get = () => ({
|
||||||
updateUsernameAndPassword: async () => ({
|
initializeUser: async () => ({
|
||||||
code: 400,
|
code: 400,
|
||||||
message: 'password rejected',
|
message: 'password rejected',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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, '');
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user