Compare commits

...

13 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] ce1a68871a Address code review: use transactions for label updates, fix variable naming
Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/1436272f-a03a-45af-b57d-869a48ab537d

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2026-04-25 06:52:03 +00:00
copilot-swe-agent[bot] d4930faedd Add labels/tags feature for environment variables
Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/1436272f-a03a-45af-b57d-869a48ab537d

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2026-04-25 06:50:23 +00:00
copilot-swe-agent[bot] 0952dabbe4 Initial plan 2026-04-25 06:45:10 +00:00
Copilot 07bf0c705b fix: respect QlPort env var in Docker health check (#2963)
* Initial plan

* fix: use QlPort env variable in health check with fallback to default 5700

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2026-03-11 20:43:56 +08:00
whyour fd516977e3 chore: upgrade nodemailer 2026-03-07 22:35:18 +08:00
whyour c39f4ef846 chore: 更新 multer,解决 cve 漏洞 2026-03-07 21:31:24 +08:00
whyour 275d8af4e2 更新版本 v2.20.2 2026-03-01 20:35:25 +08:00
whyour 544c432f49 修复 PATH 环境变量 2026-03-01 20:35:19 +08:00
Copilot 6bec52dca1 Fix /open/user/init auth bypass allowing credential reset on initialized systems (#2941)
* Initial plan

* fix: add /open/user/init paths to init guard to prevent auth bypass

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
Co-authored-by: whyour <imwhyour@gmail.com>
2026-03-01 18:02:21 +08:00
rockymelody ce599d306f 青龙面板鉴权绕过漏洞已修复 (#2935)
已实施的安全加固措施
第一层防御:启用Express严格路由(第17-18行)
app.set('case sensitive routing', true);  // 路由大小写敏感
app.set('strict routing', true);           // 严格路由匹配
第二层防御:路径标准化检查中间件(第23-37行)
app.use((req, res, next) => {
  const originalPath = req.path;
  const normalizedPath = originalPath.toLowerCase();

  // 检测并拦截大小写混淆攻击
  if (originalPath !== normalizedPath &&
      (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
    return res.status(400).json({
      code: 400,
      message: 'Invalid path format'
    });
  }

  next();
});
作用:主动检测并拒绝含有大小写变体的恶意请求
第三层防御:JWT中间件正则表达式修复(第59行)
// 修复前:
path: [...config.apiWhiteList, /^\/(?!api\/).*/],

// 修复后:添加大小写不敏感标志 'i'
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
作用:防御正则匹配层面的绕过
第四层防御:自定义Token中间件路径标准化(第74-87行)
// 修复前:
if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) {

// 修复后:统一转小写比较
const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
}
作用:确保Token验证逻辑对所有路径变体生效

第五层防御:初始化接口路径检查修复(第122-123行)
// 修复前:
if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) {

// 修复后:
const pathLower = req.path.toLowerCase();
if (!['/api/user/init', '/api/user/notification/init'].includes(pathLower)) {
2026-03-01 17:44:03 +08:00
whyour d53437d169 更新 2.20.1 2025-12-26 21:17:30 +08:00
whyour d526602d19 修复运行中任务停止操作 2025-12-26 01:07:08 +08:00
whyour 91b44914f6 修复环境变量排序 2025-12-26 00:41:32 +08:00
14 changed files with 813 additions and 332 deletions
+42
View File
@@ -44,6 +44,7 @@ export default (app: Router) => {
.required()
.pattern(/^[a-zA-Z_][0-9a-zA-Z_]*$/),
remarks: Joi.string().optional().allow(''),
labels: Joi.array().items(Joi.string()).optional(),
}),
),
}),
@@ -70,6 +71,7 @@ export default (app: Router) => {
name: Joi.string().required(),
remarks: Joi.string().optional().allow('').allow(null),
id: Joi.number().required(),
labels: Joi.array().items(Joi.string()).optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -230,6 +232,46 @@ export default (app: Router) => {
},
);
route.post(
'/labels',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()),
labels: Joi.array().items(Joi.string().required()),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.addLabels(req.body.ids, req.body.labels);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/labels',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()),
labels: Joi.array().items(Joi.string().required()),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.removeLabels(req.body.ids, req.body.labels);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.post(
'/upload',
upload.single('env'),
+3
View File
@@ -10,6 +10,7 @@ export class Env {
name?: string;
remarks?: string;
isPinned?: 1 | 0;
labels?: string[];
constructor(options: Env) {
this.value = options.value;
@@ -23,6 +24,7 @@ export class Env {
this.name = options.name;
this.remarks = options.remarks || '';
this.isPinned = options.isPinned || 0;
this.labels = options.labels || [];
}
}
@@ -45,4 +47,5 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING,
isPinned: DataTypes.NUMBER,
labels: DataTypes.JSON,
});
+34 -5
View File
@@ -13,9 +13,29 @@ import { isValidToken } from '../shared/auth';
import path from 'path';
export default ({ app }: { app: Application }) => {
// Security: Enable strict routing to prevent case-insensitive path bypass
app.set('case sensitive routing', true);
app.set('strict routing', true);
app.set('trust proxy', 'loopback');
app.use(cors());
// Security: Path normalization middleware to prevent case variation attacks
app.use((req, res, next) => {
const originalPath = req.path;
const normalizedPath = originalPath.toLowerCase();
// Block requests with case variations on protected paths
if (originalPath !== normalizedPath &&
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
return res.status(400).json({
code: 400,
message: 'Invalid path format'
});
}
next();
});
// Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness
if (config.baseUrl) {
@@ -36,7 +56,7 @@ export default ({ app }: { app: Application }) => {
secret: config.jwt.secret,
algorithms: ['HS384'],
}).unless({
path: [...config.apiWhiteList, /^\/(?!api\/).*/],
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
}),
);
@@ -51,19 +71,20 @@ export default ({ app }: { app: Application }) => {
});
app.use(async (req: Request, res, next) => {
if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) {
const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
return next();
}
const headerToken = getToken(req);
if (req.path.startsWith('/open/')) {
if (pathLower.startsWith('/open/')) {
const apps = await shareStore.getApps();
const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken),
)?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken);
const keyMatch = req.path.match(/\/open\/([a-z]+)\/*/);
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1];
if (
doc.scopes.includes(key as any) &&
@@ -98,7 +119,15 @@ export default ({ app }: { app: Application }) => {
});
app.use(async (req, res, next) => {
if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) {
const pathLower = req.path.toLowerCase();
if (
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(req.path)
) {
return next();
}
const authInfo =
+2 -2
View File
@@ -13,7 +13,7 @@ import { AuthDataType, SystemModel } from '../data/system';
import SystemService from '../services/system';
import UserService from '../services/user';
import { writeFile, readFile } from 'fs/promises';
import { createRandomString, fileExist, safeJSONParse } from '../config/util';
import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util';
import OpenService from '../services/open';
import { shareStore } from '../shared/store';
import Logger from './logger';
@@ -50,7 +50,7 @@ export default async () => {
const [authConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.authConfig },
});
if (!authConfig?.info) {
if (!authConfig?.info || isDemoEnv()) {
let authInfo = {
username: 'admin',
password: 'admin',
+6 -3
View File
@@ -29,7 +29,7 @@ import { logStreamManager } from '../shared/logStreamManager';
@Service()
export default class CronService {
constructor(@Inject('logger') private logger: winston.Logger) {}
constructor(@Inject('logger') private logger: winston.Logger) { }
private isNodeCron(cron: Crontab) {
const { schedule, extra_schedules } = cron;
@@ -165,7 +165,7 @@ export default class CronService {
let cron;
try {
cron = await this.getDb({ id });
} catch (err) {}
} catch (err) { }
if (!cron) {
continue;
}
@@ -467,7 +467,10 @@ export default class CronService {
for (const doc of docs) {
// Kill all running instances of this task
try {
const command = this.makeCommand(doc);
if (doc.pid) {
await killTask(doc.pid);
}
const command = doc.command.replace(/\s+/g, ' ').trim();
await killAllTasks(command);
this.logger.info(
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
+31 -2
View File
@@ -13,10 +13,11 @@ import {
stepPosition,
} from '../data/env';
import { writeFileWithLock } from '../shared/utils';
import { sequelize } from '../data';
@Service()
export default class EnvService {
constructor(@Inject('logger') private logger: winston.Logger) {}
constructor(@Inject('logger') private logger: winston.Logger) { }
public async create(payloads: Env[]): Promise<Env[]> {
const envs = await this.envs();
@@ -146,7 +147,7 @@ export default class EnvService {
}
try {
const result = await this.find(condition, [
['isPinned', 'DESC'],
[sequelize.literal('COALESCE(`isPinned`, 0)'), 'DESC'],
['position', 'DESC'],
['createdAt', 'ASC'],
]);
@@ -198,6 +199,34 @@ export default class EnvService {
await EnvModel.update({ isPinned: 0 }, { where: { id: ids } });
}
public async addLabels(ids: number[], labels: string[]) {
const docs = await EnvModel.findAll({ where: { id: ids } });
await sequelize.transaction(async (t) => {
for (const doc of docs) {
const env = doc.get({ plain: true });
await EnvModel.update(
{ labels: Array.from(new Set((env.labels || []).concat(labels))) },
{ where: { id: env.id }, transaction: t },
);
}
});
return await EnvModel.findAll({ where: { id: ids } });
}
public async removeLabels(ids: number[], labels: string[]) {
const docs = await EnvModel.findAll({ where: { id: ids } });
await sequelize.transaction(async (t) => {
for (const doc of docs) {
const env = doc.get({ plain: true });
await EnvModel.update(
{ labels: (env.labels || []).filter((label: string) => !labels.includes(label)) },
{ where: { id: env.id }, transaction: t },
);
}
});
return await EnvModel.findAll({ where: { id: ids } });
}
public async set_envs() {
const envs = await this.envs('', {
name: { [Op.not]: null },
+4 -3
View File
@@ -69,9 +69,10 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
HOME=/root
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
@@ -83,6 +84,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+4 -3
View File
@@ -69,9 +69,10 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
HOME=/root
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
@@ -83,6 +84,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
-2
View File
@@ -1,7 +1,5 @@
#!/bin/bash
export PATH="$HOME/bin:$PATH"
dir_shell=/ql/shell
. $dir_shell/share.sh
+2 -2
View File
@@ -77,9 +77,9 @@
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"multer": "1.4.5-lts.1",
"multer": "2.1.1",
"node-schedule": "^2.1.0",
"nodemailer": "^6.9.16",
"nodemailer": "^8.0.1",
"p-queue-cjs": "7.3.4",
"@bufbuild/protobuf": "^2.10.0",
"ps-tree": "^1.2.0",
+568 -259
View File
File diff suppressed because it is too large Load Diff
+34 -1
View File
@@ -36,7 +36,7 @@ import { useVT } from 'virtualizedtableforantd4';
import Copy from '../../components/copy';
import EditNameModal from './editNameModal';
import './index.less';
import EnvModal from './modal';
import EnvModal, { EnvLabelModal } from './modal';
const { Paragraph } = Typography;
const { Search } = Input;
@@ -121,6 +121,22 @@ const Env = () => {
);
},
},
{
title: intl.get('标签'),
dataIndex: 'labels',
key: 'labels',
render: (labels: string[], record: any) => {
return (
<Space size={[0, 4]} wrap>
{labels?.filter((label) => label).map((label) => (
<Tag key={label} color="blue">
{label}
</Tag>
))}
</Space>
);
},
},
{
title: intl.get('更新时间'),
dataIndex: 'timestamp',
@@ -238,6 +254,7 @@ const Env = () => {
const [loading, setLoading] = useState(true);
const [isModalVisible, setIsModalVisible] = useState(false);
const [isEditNameModalVisible, setIsEditNameModalVisible] = useState(false);
const [isLabelModalVisible, setIsLabelModalVisible] = useState(false);
const [editedEnv, setEditedEnv] = useState();
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
const [searchText, setSearchText] = useState('');
@@ -622,6 +639,13 @@ const Env = () => {
>
{intl.get('批量修改变量名称')}
</Button>
<Button
type="primary"
style={{ marginBottom: 5, marginLeft: 8 }}
onClick={() => setIsLabelModalVisible(true)}
>
{intl.get('批量修改标签')}
</Button>
<Button
type="primary"
style={{ marginBottom: 5, marginLeft: 8 }}
@@ -700,6 +724,15 @@ const Env = () => {
ids={selectedRowIds}
/>
)}
{isLabelModalVisible && (
<EnvLabelModal
handleCancel={(needUpdate) => {
setIsLabelModalVisible(false);
if (needUpdate) getEnvs();
}}
ids={selectedRowIds}
/>
)}
</PageContainer>
);
};
+78 -4
View File
@@ -1,8 +1,9 @@
import intl from 'react-intl-universal';
import React, { useEffect, useState } from 'react';
import { Modal, message, Input, Form, Radio } from 'antd';
import { Modal, message, Input, Form, Radio, Button } from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import EditableTagGroup from '@/components/tag';
const EnvModal = ({
env,
@@ -16,7 +17,7 @@ const EnvModal = ({
const handleOk = async (values: any) => {
setLoading(true);
const { value, split, name, remarks } = values;
const { value, split, name, remarks, labels } = values;
const method = env ? 'put' : 'post';
let payload;
if (!env) {
@@ -27,10 +28,11 @@ const EnvModal = ({
name: name,
value: x,
remarks: remarks,
labels: labels || [],
};
});
} else {
payload = [{ value, name, remarks }];
payload = [{ value, name, remarks, labels: labels || [] }];
}
} else {
payload = { ...values, id: env.id };
@@ -123,9 +125,81 @@ const EnvModal = ({
<Form.Item name="remarks" label={intl.get('备注')}>
<Input placeholder={intl.get('请输入备注')} />
</Form.Item>
<Form.Item name="labels" label={intl.get('标签')}>
<EditableTagGroup />
</Form.Item>
</Form>
</Modal>
);
};
export default EnvModal;
export { EnvModal as default };
export const EnvLabelModal = ({
ids,
handleCancel,
}: {
ids: Array<string>;
handleCancel: (needUpdate?: boolean) => void;
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const update = async (action: 'delete' | 'post') => {
form
.validateFields()
.then(async (values) => {
setLoading(true);
const payload = { ids, labels: values.labels };
try {
const { code, data } = await request[action](
`${config.apiPrefix}envs/labels`,
payload,
);
if (code === 200) {
message.success(
action === 'post'
? intl.get('添加Labels成功')
: intl.get('删除Labels成功'),
);
handleCancel(true);
}
setLoading(false);
} catch (error) {
setLoading(false);
}
})
.catch((info) => {
console.log('Validate Failed:', info);
});
};
const buttons = [
<Button key="cancel" onClick={() => handleCancel(false)}>{intl.get('取消')}</Button>,
<Button key="delete" type="primary" danger onClick={() => update('delete')}>
{intl.get('删除')}
</Button>,
<Button key="add" type="primary" onClick={() => update('post')}>
{intl.get('添加')}
</Button>,
];
return (
<Modal
title={intl.get('批量修改标签')}
open={true}
footer={buttons}
centered
maskClosable={false}
forceRender
onCancel={() => handleCancel(false)}
confirmLoading={loading}
>
<Form form={form} layout="vertical" name="form_in_env_label_modal">
<Form.Item name="labels" label={intl.get('标签')}>
<EditableTagGroup />
</Form.Item>
</Form>
</Modal>
);
};
+5 -46
View File
@@ -1,47 +1,6 @@
version: 2.20.0
changeLogLink: https://t.me/jiao_long/432
publishTime: 2025-12-10 01:05
version: 2.20.2
changeLogLink: https://t.me/jiao_long/434
publishTime: 2026-03-01 1800
changeLog: |
1. 定时任务(cron / task)相关的大量修复 & 增强
修复 cron 解析错误(修复 parse cron / 升级 cron-parser
修复集群模式下定时任务可能不执行(race condition
定时任务支持订阅筛选
定时任务支持排序调整
定时任务支持自定义日志文件或无日志
修复任务实例默认值
任务支持单实例 / 多实例模式
修复 task 命令软链可能失败问题
2. 日志系统相关的大更新
修复日志目录逻辑
修复 pm2 日志目录
优化日志写入(stream pooling
3. 环境变量(env)系统的改进与修复
修复环境变量复制到剪贴板时可能失败
添加环境变量“置顶”功能
修复 QlPort 与 QlGrpcPort 环境变量在 host network 模式下被忽略
增加全局 SSH 私钥配置
4. Docker / 非 root 用户 / Alpine 兼容性增强
新增非 root Docker 用户支持,自动初始化命令
修复 Alpine 容器 DNS 解析失败(设置 ndots:0
修复 PM2 在 ARM 路由器(Node.js 不兼容)上的启动失败
移除 nginx(可能是考虑更轻量的镜像运行)
5. API 安全与校验增强
Dependencies GET endpoint 增加校验
Script API routes 增加输入校验
修复 JWT 认证问题
Feishu 机器人通知增加签名校验
QLAPI 增加 cron task 管理功能
修复 URIError(错误 cookie 导致白屏)
6. 系统设置
新增多终端/多平台的并发登录会话支持
1. 修复 path 安全漏洞(重要)