diff --git a/.gitignore b/.gitignore index 6ad16d0..121cda6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /logs/ /data/ .env +conf.json diff --git a/agent/infra/tools/web/http_get.js b/agent/infra/tools/web/http_get.js index 5af87a0..ef82f3b 100644 --- a/agent/infra/tools/web/http_get.js +++ b/agent/infra/tools/web/http_get.js @@ -1,12 +1,13 @@ import { tool } from 'langchain'; import * as z from 'zod'; +import { checkUrlAllowed } from './url_guard.js'; export const httpGetTool = tool( async ({ url, headers = {}, timeout = 30000 }) => { - try { - new URL(url); - } catch (e) { - return JSON.stringify({ success: false, error: `Invalid URL: ${url}` }); + // SSRF 防护:仅允许 http/https 且目标不得为环回/内网地址 + const blocked = checkUrlAllowed(url); + if (blocked) { + return JSON.stringify({ success: false, error: blocked }); } try { diff --git a/agent/infra/tools/web/http_post.js b/agent/infra/tools/web/http_post.js index 8461991..855f0ae 100644 --- a/agent/infra/tools/web/http_post.js +++ b/agent/infra/tools/web/http_post.js @@ -1,12 +1,13 @@ import { tool } from "@langchain/core/tools"; import z from "zod"; +import { checkUrlAllowed } from "./url_guard.js"; const httpPostTool = tool( async ({ url, body, headers = {}, contentType = 'json', timeout = 30000 }) => { - try { - new URL(url); - } catch (e) { - return { success: false, error: `Invalid URL: ${url}` }; + // SSRF 防护:仅允许 http/https 且目标不得为环回/内网地址 + const blocked = checkUrlAllowed(url); + if (blocked) { + return { success: false, error: blocked }; } let requestBody; diff --git a/agent/infra/tools/web/url_guard.js b/agent/infra/tools/web/url_guard.js new file mode 100644 index 0000000..4330de3 --- /dev/null +++ b/agent/infra/tools/web/url_guard.js @@ -0,0 +1,74 @@ +/** + * 出网 URL 安全校验(SSRF 防护) + * + * Agent 的 HTTP 工具处理的 URL 来自模型输出,而模型输入含不可信的用户内容, + * 因此可能被诱导去访问环回/内网地址(如本机 127.0.0.1 上的其他服务、云元数据地址)。 + * 这里集中做白名单式校验:仅允许 http/https,且目标不能是环回/内网/链路本地地址。 + */ + +// 仅允许的协议 +const ALLOWED_PROTOCOLS = ["http:", "https:"]; + +// 本地主机名 +const BLOCKED_HOSTNAMES = new Set(["localhost", "localhost.localdomain", "ip6-localhost"]); + +/** + * 是否为内网/保留 IPv4 地址 + * @param {string} host 形如 10.0.0.1 的地址 + */ +function isPrivateIPv4(host) { + const parts = host.split("."); + if (parts.length !== 4) return false; + const nums = parts.map((p) => (/^\d+$/.test(p) ? Number(p) : NaN)); + if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false; + + const [a, b] = nums; + if (a === 0 || a === 10 || a === 127) return true; // 当前网络/内网/环回 + if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 运营商级 NAT + if (a === 169 && b === 254) return true; // 169.254.0.0/16 链路本地(含云 metadata) + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + return false; +} + +/** + * 校验 URL 是否允许出网访问 + * @param {string} url 待校验的 URL + * @returns {string|null} 不允许时返回原因文案,允许时返回 null + */ +export function checkUrlAllowed(url) { + let parsed; + try { + parsed = new URL(url); + } catch (e) { + return `Invalid URL: ${url}`; + } + + if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) { + return `Blocked protocol: ${parsed.protocol}`; + } + + // URL.hostname 对 IPv6 会带方括号,统一去掉并转小写 + const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + + if (BLOCKED_HOSTNAMES.has(hostname)) { + return `Blocked host: ${hostname}`; + } + if (isPrivateIPv4(hostname)) { + return `Blocked private network address: ${hostname}`; + } + if (hostname.includes(":")) { + // IPv6:环回 ::1 / 未指定 :: / 唯一本地 fc00::、fd00:: / 链路本地 fe80:: + if (hostname === "::1" || hostname === "::") { + return `Blocked private network address: ${hostname}`; + } + if (/^f[cd]/.test(hostname)) { + return `Blocked private network address: ${hostname}`; + } + if (/^fe[89ab]/.test(hostname)) { + return `Blocked private network address: ${hostname}`; + } + } + + return null; +} diff --git a/app.js b/app.js index 3624c44..abcb5a0 100644 --- a/app.js +++ b/app.js @@ -20,6 +20,11 @@ class APP { } setupMiddleware() { + // 服务在 nginx 之后,需信任代理头才能拿到真实客户端 IP + // (否则 ctx.ip 恒为 127.0.0.1,限流会变成全站共享一个桶) + this.app.proxy = true; + this.app.proxyIpHeader = "X-Real-IP"; + this.app.use(cors()); this.app.use(bodyParser()); diff --git a/handler/escort_record.js b/handler/escort_record.js index 1b99f16..06f82aa 100644 --- a/handler/escort_record.js +++ b/handler/escort_record.js @@ -1,4 +1,5 @@ import { DBModel } from "../models/index.js"; +import services from "../resource/services.js"; import ResponseUtil from "../utils/responseUtil.js"; class HandlerEscortRecord { @@ -11,10 +12,40 @@ class HandlerEscortRecord { "attendant", "hospital", "schedule", "payment", "notes", "status", ]; - static pickFields(body) { + // 用户端可自助修改的字段:费用、状态、陪诊员分配等一律由平台控制 + static OWNER_UPDATE_FIELDS = [ + "patient", "healthProfileId", "hospital", "schedule", "notes", + ]; + + // 用户端可自助触发的状态(取消/确认完成),其余状态由平台流转 + static OWNER_ALLOWED_STATUS = ["cancelled", "completed"]; + + // 单页最大条数,避免 pageSize 传超大值一次拖库 + static MAX_PAGE_SIZE = 200; + + /** + * 是否为管理端账号(与 WS 管理端 agent 的校验口径一致) + * @param {Object} user token 解析出的用户 + */ + static isAdmin(user) { + return !!(user && user.app && ("wxapp-escort-admin" in user.app)); + } + + /** + * 记录访问校验:仅记录所属用户本人或管理员可读写 + * @param {Object} user 当前登录用户 + * @param {Object} record 目标记录 + */ + static canAccess(user, record) { + if (!record) return false; + if (HandlerEscortRecord.isAdmin(user)) return true; + return String(record.userId) === String(user?._id); + } + + static pickFields(body, allowFields = HandlerEscortRecord.RECORD_FIELDS) { const picked = {}; for (const key of Object.keys(body || {})) { - const allowed = HandlerEscortRecord.RECORD_FIELDS.some( + const allowed = allowFields.some( (f) => key === f || key.startsWith(f + ".") ); if (allowed) { @@ -24,6 +55,14 @@ class HandlerEscortRecord { return picked; } + // 解析分页参数并夹紧上限,防止超大 pageSize 拖库 + static parsePaging(query = {}) { + const page = Math.max(1, parseInt(query.page) || 1); + const rawSize = parseInt(query.pageSize) || 20; + const pageSize = Math.min(Math.max(1, rawSize), HandlerEscortRecord.MAX_PAGE_SIZE); + return { page, pageSize }; + } + // 按条件查询记录(管理端,可查全部) async getRecords(ctx) { return this._listRecords(ctx, false); @@ -36,7 +75,8 @@ class HandlerEscortRecord { async _listRecords(ctx, forceSelf) { try { - const { page = 1, pageSize = 20, status, userId, appointmentDate, healthProfileId } = ctx.request.query; + const { status, userId, appointmentDate, healthProfileId } = ctx.request.query; + const { page, pageSize } = HandlerEscortRecord.parsePaging(ctx.request.query); const effectiveUserId = forceSelf ? ctx.state.user?._id : userId; // status解析成数组 @@ -46,8 +86,8 @@ class HandlerEscortRecord { } const records = await DBModel.EscortRecord.findRecords({ - page: parseInt(page), - pageSize: parseInt(pageSize), + page, + pageSize, status: statusArray, userId: effectiveUserId, appointmentDate, @@ -68,10 +108,11 @@ class HandlerEscortRecord { return ResponseUtil.badRequest(ctx, "缺少陪诊员ID"); } - const { page = 1, pageSize = 20, status } = ctx.request.query; + const { status } = ctx.request.query; + const { page, pageSize } = HandlerEscortRecord.parsePaging(ctx.request.query); const records = await DBModel.EscortRecord.findRecordsByAttendant(attendantId, { - page: parseInt(page), - pageSize: parseInt(pageSize), + page, + pageSize, status, }); @@ -92,6 +133,10 @@ class HandlerEscortRecord { if (!record) { return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404); } + // 归属校验:非本人订单按无权限处理,避免凭 id 遍历他人订单 + if (!HandlerEscortRecord.canAccess(ctx.state.user, record)) { + return ResponseUtil.forbidden(ctx, "无权访问该订单"); + } return ResponseUtil.success(ctx, { record }, "查询成功"); } catch (err) { @@ -101,16 +146,39 @@ class HandlerEscortRecord { async createRecord(ctx) { try { - const record = ctx.request.body; - if (!record.userId) { - return ResponseUtil.badRequest(ctx, "缺少患者信息"); + const body = ctx.request.body || {}; + const userId = ctx.state.user?._id; + if (!userId) { + return ResponseUtil.unauthorized(ctx, "登录已失效"); } // 订单必须关联就诊人:patient 是下单当刻的快照,healthProfileId 才是关联 - if (!record.healthProfileId) { + if (!body.healthProfileId) { return ResponseUtil.badRequest(ctx, "缺少就诊人档案ID"); } - const newRecord = await DBModel.EscortRecord.createRecord(HandlerEscortRecord.pickFields(record)); + // 就诊人档案必须属于当前登录用户,避免把订单挂到他人档案上 + const profile = await DBModel.HealthProfile.findProfileById(body.healthProfileId); + if (!profile || String(profile.userId) !== String(userId)) { + return ResponseUtil.badRequest(ctx, "就诊人档案不存在"); + } + + // 服务项目与价格以服务端 services.js 为准,客户端提交的价格一律忽略 + const service = services.find((s) => String(s.id) === String(body.escort?.serviceId)); + if (!service) { + return ResponseUtil.badRequest(ctx, "服务项目不存在"); + } + + const payload = HandlerEscortRecord.pickFields(body); + // 归属、状态、费用全部由服务端决定 + payload.userId = userId; + payload.status = "pending"; + payload.payment = { + totalFee: Number(service.price) || 0, + paidFee: 0, + status: "unpaid", + }; + + const newRecord = await DBModel.EscortRecord.createRecord(payload); return ResponseUtil.success(ctx, { record: newRecord }, "创建成功"); } catch (err) { return ResponseUtil.internalError(ctx, err.message); @@ -126,7 +194,20 @@ class HandlerEscortRecord { return ResponseUtil.badRequest(ctx, "缺少记录ID"); } - const updatedRecord = await DBModel.EscortRecord.updateRecord(id, HandlerEscortRecord.pickFields(update)); + const existing = await DBModel.EscortRecord.findById(id); + if (!existing) { + return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404); + } + if (!HandlerEscortRecord.canAccess(ctx.state.user, existing)) { + return ResponseUtil.forbidden(ctx, "无权修改该订单"); + } + + // 非管理员只能改就诊信息类字段,费用/状态/归属/陪诊员分配不可自助修改 + const allowFields = HandlerEscortRecord.isAdmin(ctx.state.user) + ? HandlerEscortRecord.RECORD_FIELDS + : HandlerEscortRecord.OWNER_UPDATE_FIELDS; + + const updatedRecord = await DBModel.EscortRecord.updateRecord(id, HandlerEscortRecord.pickFields(update, allowFields)); if (!updatedRecord) { return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404); } @@ -149,6 +230,20 @@ class HandlerEscortRecord { return ResponseUtil.badRequest(ctx, "缺少状态值"); } + const existing = await DBModel.EscortRecord.findById(id); + if (!existing) { + return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404); + } + if (!HandlerEscortRecord.canAccess(ctx.state.user, existing)) { + return ResponseUtil.forbidden(ctx, "无权修改该订单"); + } + + // 用户端只允许「取消」与「确认完成」,其余状态由平台流转 + if (!HandlerEscortRecord.isAdmin(ctx.state.user) + && !HandlerEscortRecord.OWNER_ALLOWED_STATUS.includes(status)) { + return ResponseUtil.forbidden(ctx, "不支持的状态变更"); + } + const updatedRecord = await DBModel.EscortRecord.updateRecord(id, { "status": status, }); @@ -171,6 +266,23 @@ class HandlerEscortRecord { return ResponseUtil.badRequest(ctx, "缺少记录ID"); } + const existing = await DBModel.EscortRecord.findById(id); + if (!existing) { + return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404); + } + if (!HandlerEscortRecord.canAccess(ctx.state.user, existing)) { + return ResponseUtil.forbidden(ctx, "无权删除该订单"); + } + + // 与前端口径一致:仅「未支付且未进入服务阶段」的订单可删。 + // 已支付/进行中/已完成的订单承载服务与费用记录,不能只靠前端隐藏按钮来保护 + const paymentStatus = existing.payment?.status || "unpaid"; + const deletable = paymentStatus === "unpaid" + && ["pending", "confirmed", "cancelled"].includes(existing.status); + if (!HandlerEscortRecord.isAdmin(ctx.state.user) && !deletable) { + return ResponseUtil.forbidden(ctx, "该订单不可删除"); + } + const deletedRecord = await DBModel.EscortRecord.deleteRecord(id); if (!deletedRecord) { @@ -184,4 +296,4 @@ class HandlerEscortRecord { } } -export { HandlerEscortRecord }; \ No newline at end of file +export { HandlerEscortRecord }; diff --git a/handler/health_profile.js b/handler/health_profile.js index 0d67499..254d447 100644 --- a/handler/health_profile.js +++ b/handler/health_profile.js @@ -8,6 +8,17 @@ class HandlerHealthProfile { // 白名单:只允许写入 schema 定义的顶层字段(兼容 'profile.name' 等点号路径) static PROFILE_FIELDS = ["userId", "profile", "location", "health"]; + // 单页最大条数,避免 pageSize 传超大值一次拖库 + static MAX_PAGE_SIZE = 200; + + // 解析分页参数并夹紧上限 + static parsePaging(query = {}) { + const page = Math.max(1, parseInt(query.page) || 1); + const rawSize = parseInt(query.pageSize) || 20; + const pageSize = Math.min(Math.max(1, rawSize), HandlerHealthProfile.MAX_PAGE_SIZE); + return { page, pageSize }; + } + static pickFields(body) { const picked = {}; for (const key of Object.keys(body || {})) { @@ -23,11 +34,12 @@ class HandlerHealthProfile { async getProfiles(ctx) { try { - const { page = 1, pageSize = 20, userId, name, mobile, sortBy } = ctx.request.query; + const { userId, name, mobile, sortBy } = ctx.request.query; + const { page, pageSize } = HandlerHealthProfile.parsePaging(ctx.request.query); const result = await DBModel.HealthProfile.findProfiles({ - page: parseInt(page), - pageSize: parseInt(pageSize), + page, + pageSize, userId, name, mobile, @@ -43,12 +55,13 @@ class HandlerHealthProfile { // 查询"我的"健康档案列表:强制以登录用户身份查询,忽略请求中的 userId async getMyProfiles(ctx) { try { - const { page = 1, pageSize = 20, name, mobile, sortBy } = ctx.request.query; + const { name, mobile, sortBy } = ctx.request.query; + const { page, pageSize } = HandlerHealthProfile.parsePaging(ctx.request.query); const userId = ctx.state.user?._id; const result = await DBModel.HealthProfile.findProfiles({ - page: parseInt(page), - pageSize: parseInt(pageSize), + page, + pageSize, userId, name, mobile, diff --git a/routes/index.js b/routes/index.js index 97d6c8d..cba6581 100644 --- a/routes/index.js +++ b/routes/index.js @@ -26,6 +26,19 @@ async function requireAuth(ctx, next) { await next(); } +/** + * 管理员校验中间件:仅管理端小程序绑定的账号可访问管理端接口 + * 与 WS 管理端 agent 的校验口径保持一致(app 中是否存在 wxapp-escort-admin) + */ +async function requireAdmin(ctx, next) { + const user = ctx.state.user; + if (!user || !user.app || !("wxapp-escort-admin" in user.app)) { + ResponseUtil.forbidden(ctx, "无管理员权限"); + return; + } + await next(); +} + function printRoutes(stack) { for (const layer of stack) { if (layer.path) { @@ -46,7 +59,8 @@ function registerRoutes(app) { // 敏感数据路由需要登录后访问 router.get("/escort-record/my", requireAuth, handlerEscortRecord.getMyRecords.bind(handlerEscortRecord)); router.get("/escort-record/attendant", requireAuth, handlerEscortRecord.getAttendantRecords.bind(handlerEscortRecord)); - router.get("/escort-record", requireAuth, handlerEscortRecord.getRecords.bind(handlerEscortRecord)); + // 全量查询属管理端能力,必须管理员 + router.get("/escort-record", requireAuth, requireAdmin, handlerEscortRecord.getRecords.bind(handlerEscortRecord)); router.get("/escort-record/:id", requireAuth, handlerEscortRecord.getRecordById.bind(handlerEscortRecord)); router.post("/escort-record", requireAuth, handlerEscortRecord.createRecord.bind(handlerEscortRecord)); router.put("/escort-record/:id", requireAuth, handlerEscortRecord.updateRecord.bind(handlerEscortRecord)); @@ -60,11 +74,12 @@ function registerRoutes(app) { router.put("/health-profile/mine/:id", requireAuth, handlerHealthProfile.updateMyProfile.bind(handlerHealthProfile)); router.delete("/health-profile/mine/:id", requireAuth, handlerHealthProfile.deleteMyProfile.bind(handlerHealthProfile)); - router.get("/health-profile", requireAuth, handlerHealthProfile.getProfiles.bind(handlerHealthProfile)); - router.get("/health-profile/:id", requireAuth, handlerHealthProfile.getProfileById.bind(handlerHealthProfile)); - router.post("/health-profile", requireAuth, handlerHealthProfile.createProfile.bind(handlerHealthProfile)); - router.put("/health-profile/:id", requireAuth, handlerHealthProfile.updateProfile.bind(handlerHealthProfile)); - router.delete("/health-profile/:id", requireAuth, handlerHealthProfile.deleteProfile.bind(handlerHealthProfile)); + // 以下为管理端档案接口(C 端一律走 /mine),必须管理员 + router.get("/health-profile", requireAuth, requireAdmin, handlerHealthProfile.getProfiles.bind(handlerHealthProfile)); + router.get("/health-profile/:id", requireAuth, requireAdmin, handlerHealthProfile.getProfileById.bind(handlerHealthProfile)); + router.post("/health-profile", requireAuth, requireAdmin, handlerHealthProfile.createProfile.bind(handlerHealthProfile)); + router.put("/health-profile/:id", requireAuth, requireAdmin, handlerHealthProfile.updateProfile.bind(handlerHealthProfile)); + router.delete("/health-profile/:id", requireAuth, requireAdmin, handlerHealthProfile.deleteProfile.bind(handlerHealthProfile)); router.get("/service", handlerResource.getServices.bind(handlerResource)); router.get("/agreement", handlerResource.getAgreement.bind(handlerResource)); diff --git a/utils/user_service.js b/utils/user_service.js index 05d4c29..6a929c6 100644 --- a/utils/user_service.js +++ b/utils/user_service.js @@ -39,61 +39,36 @@ async function lookup(body) { } /** - * 获取用户信息(带 5 分钟进程内缓存,token→user) + * 仅按 token 获取用户信息(带 5 分钟进程内缓存,token→user) * * 注意:缓存期内登出/账户锁定/用户信息变更最多延迟 5 分钟生效 * (user 服务自身接口即时生效) * + * 只保留 token 路径:user 服务已不再接受按 userId 明文查询, + * 故不存在 userId 兜底,避免凭 userId 冒充他人身份。 + * * @param {string} token 登录 token - * @param {string} [userId] 备用用户ID(token 缺失或失效时的兜底路径,保持原 WS 行为) * @returns {Promise} 用户信息或 null */ -export async function getUserInfo(token, userId) { - if (!token && !userId) return null; +export async function getUserByToken(token) { + if (!token) return null; - // 1. token 路径(带缓存) - if (token) { - const key = `tk:${token}`; - const cached = cacheGet(key); - if (cached !== undefined) return cached; + const key = `tk:${token}`; + const cached = cacheGet(key); + if (cached !== undefined) return cached; - try { - const user = await lookup({ token }); - if (user) { - cacheSet(key, user, CACHE_TTL); - return user; - } - // 无效 token 短缓存,保护 user 服务 - cacheSet(key, null, NEGATIVE_TTL); - } catch (err) { - // 网络异常不缓存,走 userId 兜底 - console.error("getUserByToken error:", err.message); - } - } - - // 2. userId 兜底路径 - if (userId) { - const key = `uid:${userId}`; - const cached = cacheGet(key); - if (cached !== undefined) return cached; - - try { - const user = await lookup({ userId }); - if (user) { - cacheSet(key, user, CACHE_TTL); - return user; - } - } catch (err) { - console.error("getUserById error:", err.message); + try { + const user = await lookup({ token }); + if (user) { + cacheSet(key, user, CACHE_TTL); + return user; } + // 无效 token 短缓存,保护 user 服务 + cacheSet(key, null, NEGATIVE_TTL); + } catch (err) { + // 网络异常不缓存,避免把故障期内的问题固化 5 分钟 + console.error("getUserByToken error:", err.message); } return null; } - -/** - * 仅按 token 获取用户信息(REST 鉴权用,无 userId 兜底) - */ -export async function getUserByToken(token) { - return getUserInfo(token, null); -} diff --git a/websocket.js b/websocket.js index 731397d..c733ae2 100644 --- a/websocket.js +++ b/websocket.js @@ -3,7 +3,7 @@ import http from 'http'; import { DBModel } from "./models/index.js"; import { chatTask } from "./agent/bots/bot-escort/task.js"; import { adminAgent } from "./agent/bots/bot-escort-admin/agent.js" -import { getUserInfo } from "./utils/user_service.js"; +import { getUserByToken } from "./utils/user_service.js"; export default class WebSocketServerManager { constructor(port = 8080) { @@ -71,9 +71,10 @@ export default class WebSocketServerManager { async _handleMessage(ws, data) { try { const raw = data.toString(); - console.log('Received:', raw); const msg = JSON.parse(raw); + // 只记录消息类型:正文含健康描述、token/userId 属敏感信息,不进日志 + console.log('Received message type:', msg.type); if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); @@ -91,7 +92,12 @@ export default class WebSocketServerManager { }; try { - const userInfo = await getUserInfo(msg.token, msg.userId); + // 身份只认 token:不再接受凭 userId 明文接入,防止冒充他人聊天 + if (!msg.token) { + ws.send(JSON.stringify({ type: 'error', content: '缺少登录凭证,请重新登录' })); + return; + } + const userInfo = await getUserByToken(msg.token); if (!userInfo) { ws.send(JSON.stringify({ type: 'error', content: '登录已失效' })); return; @@ -122,7 +128,7 @@ export default class WebSocketServerManager { return; } - console.log('[Unknown type]', msg); + console.log('[Unknown message type]', msg.type); } catch (e) { console.error('Message parse error:', e); ws.send(JSON.stringify({ type: 'error', content: 'Invalid JSON' }));