'use strict'; /** * 写操作防护(Write Guard) * ============================================================================ * 背景:淘宝闪购商家后台的写接口(接单 / 改价 / 上下架 / 提交活动…)一旦被 * 重放会产生**真实业务副作用**,不可撤销。 * * 当前策略(用户指令): * 「写接口暂时不验证」→ 写接口默认**全面拦截**,只读接口才放行。 * * 三道判定,优先级从高到低: * 1. 目录命中:请求能对上 data/apis.json 里的接口 → 直接采用它的 kind * 2. 方法名动词:从 query 的 method= / body.method / body.params.method 取真名, * 按 get/query/list… vs create/update/delete… 判定 * 3. HTTP 方法兜底:GET/HEAD/OPTIONS → read;其余 → 视为 write(保守) * * 放行方式(必须显式): * · CLI : 加 --allow-write * · 环境 : NAPOS_ALLOW_WRITE=1 */ const fs = require('fs'); const { templatePath, tryParseJson, lastSegment, methodFromPath, detectKind } = require('./classify'); // 动词表与目录归纳器共用同一份,避免两处判定打架 const GENERIC_INVOKER = /\/invoke\/?$|invoke\/?$|iOneRoute|h5api|callFromNapos|nevermore|xtop\//i; const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); /** * 实测确认的「只读服务族」。 * * 为什么需要它:归纳器靠方法名动词判读写,但有些服务的方法名里**根本没有动词** * (`PollingService.abnormalOrders` / `unprocessedOrders` / `nonCoreOrders` * 全被切成 ['abnormal','orders'] 这类名词,判不出来)→ 保守拦成 unknown。 * * 证据(2026-10-11 抓包对照):这些是**订单看板的轮询口**,由前端每 N 秒自动调用, * body 只有 `{shopId}`,无任何可变业务参数;方法语义均为"取当前状态"。 * 与之对照的真实写操作(接单/拒单/取消)都带 accept/reject/cancel 这类动词, * 不会被这条规则误放行。 */ const READ_ONLY_SERVICES = new Set(['PollingService']); /** * 实测确认的「只读路径前缀」(lsycm 数据中心 / 生意参谋)。 * * 为什么需要它:这些接口是 POST 且 URL 里没有方法名(方法语义在 body 里,如 * `/api/marketing/chain/card/marketingXxhfOverviewChain`),归纳器判不出动词 → * 保守拦成 unknown。但它们全是**数据中心的统计卡片 / 排行榜 / 归因**接口,无写能力。 * * 证据(2026-10-11 抓包 + 实测): * · `/api/overview/chain/custom/*` → 经营概览(historyOverview / queryBusinessSummary / queryChainRealtimeInfo) * · `/api/marketing/chain/*` → 营销统计(先享后付 XXHF / 活动效果 / 门店洞察排行) * · `/api/customer/chain/general/*` → 顾客私域分析(PrvtOverview / PrvtTouchAnalyze / PrvtFlowTrans / PrvtUserStick) * · `/api/diagnosis/chain/*` → 经营诊断 * · `/api/focus/*` → 指标定义 * · `/api/naposOverview/*` → 概览辅助(城市列表 / 最新时间) * * 与之对照:lsycm 上的**写操作不存在**(生意参谋是纯读分析域); * 唯一需要永久拒绝的 `proteinStandardQuery/*` 是埋点/风控网关,已在 RISK_DENY 里一票否决, * 优先级高于本白名单。 */ const READ_ONLY_PATH_HOST = /(^|\.)lsycm\.alibaba\.com$/i; const READ_ONLY_PATH_PREFIXES = [ '/api/overview/', '/api/marketing/chain/', '/api/customer/chain/', '/api/diagnosis/chain/', '/api/focus/', '/api/naposOverview/', '/api/napos/', '/api/ark/', '/naposGrayConfig/', ]; /** error.code 用于上层区分"被防护挡住"和"真出错" */ const CODE_WRITE_BLOCKED = 'NAPOS_WRITE_BLOCKED'; const CODE_RISK_BLOCKED = 'NAPOS_RISK_BLOCKED'; // ============================================================================ // ★ 风控防线(Risk Guard) // ---------------------------------------------------------------------------- // 实测结论(2026-10-11,逐接口对照): // 往**验签类接口**发不带 `secretkey` 的请求,服务端会判为伪造来源 → // 返回 `_____tmd_____/punish` 挑战页 → **客户端弹出验证码**。 // // 证据: // · historyOverview —— 带 secretkey → 正常 // · queryChainRealtimeInfo —— 不带 secretkey → 命中 punish(客户端挂出验证 iframe) // · proteinStandardQuery/TG3gM96 —— 不带 secretkey → 命中 punish // // 还有一类是**埋点/风控网关**接口,本身就不是给脚本调的,手构请求必触发。 // // 防线统一放在这里(所有出站请求的唯一出口), // 这样走 MCP 工具 / CLI / skill 脚本三条路都拦得住。 // ============================================================================ /** 一票否决:这些请求永不外发 */ const RISK_DENY = [ { re: /proteinStandardQuery/i, why: '埋点 / 风控网关(TG3gM96 家族),手构请求必触发验证码' }, { re: /_____tmd_____|\/punish\b|slidecaptcha|captcha/i, why: '这是风控验证页本身,不是业务接口' }, { re: /\/\/?[^/]*\/um\.json|ynuf\.aliapp\.org/i, why: '阿里统一用户追踪(风控探针)' }, ]; /** * 必须带签名才允许外发的接口(发往 lsycm 数据中心的 POST)。 * 这些接口一旦不带 `secretkey`,服务端判为伪造 → 风控。 */ const SIGN_REQUIRED_HOST = /(^|\.)lsycm\.alibaba\.com$/i; /** * 例外白名单:这些 lsycm 接口**实测不带签名也是安全的**(零风控、零鉴权失败)。 * 只有实测过、确认安全的才准进这张表。 */ const SIGN_SAFE_PATHS = [ /^\/api\/overview\/chain\/custom\/queryBusinessSummary$/, /^\/api\/overview\/chain\/custom\/historyOverview$/, // 走 kit 时本来就会签名 /^\/api\/naposOverview\/queryChainCityList$/, /^\/api\/naposOverview\/getLatestTime$/, /^\/api\/customer\/chain\/general\/chainPrvtOverview$/, /^\/api\/customer\/chain\/general\/chainPrvtTouchAnalyze$/, /^\/api\/napos\/isGray$/, /^\/api\/napos\/isSycmPermission$/, /^\/api\/napos\/isSuperChainAdmin$/, /^\/naposGrayConfig\/getGray$/, /^\/api\/overview\/chain\/custom\/inGray$/, ]; class RiskBlockedError extends Error { constructor(info) { super( `🛑 已拦截高危请求(防触发风控验证码)\n` + ` 接口 : ${info.method} ${info.url}\n` + ` 原因 : ${info.reason}\n` + (info.advice ? ` 怎么办 : ${info.advice}\n` : '') + ` ℹ️ 不带签名调用验签接口 = 伪造来源,会弹验证码,故一律拒发。` ); this.name = 'RiskBlockedError'; this.code = CODE_RISK_BLOCKED; this.info = info; } } /** * 风控预检。通过返回 {ok:true};命中红线抛 RiskBlockedError。 * @param {object} req {method,url,body} * @param {object} [opts] {allowUnsigned} */ function checkRisk(req, opts = {}) { const method = String(req.method || 'GET').toUpperCase(); const url = String(req.url || ''); // 1) 一票否决 for (const d of RISK_DENY) { if (d.re.test(url)) { throw new RiskBlockedError({ method, url, reason: d.why, advice: '该接口不在技能支持范围内;若确需数据,请改用白名单内的经营数据接口。', }); } } // 2) 验签接口必须带 secretkey let u; try { u = new URL(url); } catch { u = null; } if (u && SIGN_REQUIRED_HOST.test(u.hostname) && !READ_METHODS.has(method)) { const safe = SIGN_SAFE_PATHS.some((re) => re.test(u.pathname)); const body = typeof req.body === 'string' ? tryParseJson(req.body) : req.body; const hasSign = !!(body && typeof body === 'object' && body.secretkey); if (!safe && !hasSign && !opts.allowUnsigned) { throw new RiskBlockedError({ method, url, reason: '该接口需要 secretkey 签名,但请求里没有 —— 会被服务端判为伪造并触发风控', advice: '用 kit.statsHistory()/kit.sign() 补齐签名;确认过安全可加 allowUnsigned。', }); } } return { ok: true }; } /** 环境变量是否显式放行无签名请求(默认否) */ function allowUnsignedFromEnv() { const v = String(process.env.NAPOS_ALLOW_UNSIGNED || '').trim(); return v === '1' || v.toLowerCase() === 'true' || v.toLowerCase() === 'yes'; } class WriteBlockedError extends Error { constructor(info) { const head = info.kind === 'write' ? '已拦截写操作(策略:写接口暂不调用)' : '已拦截:无法确认是否为只读接口'; super( `${head}\n` + ` 接口 : ${info.method} ${info.url}\n` + (info.apiMethod ? ` 方法名 : ${info.apiMethod}\n` : '') + ` 判定依据: ${info.reason}\n` + ` 如需放行: CLI 加 --allow-write,或设 NAPOS_ALLOW_WRITE=1` ); this.name = 'WriteBlockedError'; this.code = CODE_WRITE_BLOCKED; this.info = info; } } // ------------------------------------------------------------------ 真名提取 /** 从 URL query / body 里取真实 RPC 方法名与服务名 */ function extractApiMethod(url, body) { let service = ''; let method = ''; const b = typeof body === 'string' ? tryParseJson(body) : body; if (b && typeof b === 'object' && !Array.isArray(b)) { if (typeof b.service === 'string') service = b.service; if (typeof b.method === 'string') method = b.method; const inner = b.params && typeof b.params === 'object' && !Array.isArray(b.params) ? b.params : null; if (inner && typeof inner.method === 'string') { method = inner.method; if (typeof inner.service === 'string' && !service) service = inner.service; } } if (!method || !service) { let q; try { q = new URL(url).searchParams; } catch { q = new URLSearchParams(); } if (!method) { const qm = q.get('method'); if (qm) { if (qm.includes('.')) { const i = qm.lastIndexOf('.'); if (!service) service = qm.slice(0, i); method = qm.slice(i + 1); } else method = qm; } } if (!service) { const qs = q.get('service'); if (qs) service = qs; } } return { service, method }; } // ------------------------------------------------------------------ 目录索引 /** * 用 apis.json 建索引。 * 返回 { byKey: Map(规范key → kind), byRpc: Map('service.method' → kind) } */ function buildCatalogIndex(cat) { const byKey = new Map(); const byRpc = new Map(); if (!cat || !Array.isArray(cat.endpoints)) return { byKey, byRpc }; for (const e of cat.endpoints) { if (e.key) byKey.set(e.key, e.kind); if (e.rpcMethod) { const q = e.service ? `${e.service}.${e.rpcMethod}` : e.rpcMethod; // 同名冲突时,只要有一个是 write 就按 write 处理(保守) const prev = byRpc.get(q); byRpc.set(q, prev === 'write' ? 'write' : e.kind); } } return { byKey, byRpc }; } /** 按目录生成规范 key(与 classify.js 的 key 构造保持一致) */ function catalogKey(method, url, apiMethod, apiService) { let u; try { u = new URL(url); } catch { return null; } const pt = templatePath(u.pathname); const qualified = apiMethod ? (apiService ? `${apiService}.${apiMethod}` : apiMethod) : ''; const isGeneric = GENERIC_INVOKER.test(u.pathname); if (qualified && isGeneric) return `${method} ${u.hostname} ${pt} :: ${qualified}`; return `${method} ${u.hostname} ${pt}`; } // ------------------------------------------------------------------ 判定 /** * 判定一个请求是读、写,还是无法判定。 * @returns {{kind:'read'|'write'|'unknown', reason:string, source:string, apiMethod:string, apiService:string}} */ function classifyRequest(req, index) { const method = String(req.method || 'GET').toUpperCase(); const url = String(req.url || ''); const { service, method: apiMethod } = extractApiMethod(url, req.body); const base = { apiMethod, apiService: service }; // 0) 实测只读服务族(最高优先级 —— 目录对它们判不出,会落到 unknown) if (service && READ_ONLY_SERVICES.has(service)) { return { ...base, kind: 'read', source: 'service-allowlist', reason: `服务 ${service} 属实测只读轮询族(订单看板轮询口,无副作用)`, }; } // 0.5) lsycm 数据中心只读路径(生意参谋统计卡片/排行/归因,POST 无方法名判不出) { let u; try { u = new URL(url); } catch { u = null; } if (u && READ_ONLY_PATH_HOST.test(u.hostname) && READ_ONLY_PATH_PREFIXES.some((p) => u.pathname.startsWith(p))) { return { ...base, kind: 'read', source: 'path-allowlist', reason: `lsycm 数据中心只读路径(${u.pathname.split('/').slice(0, 4).join('/')}…,生意参谋统计接口,无写能力)`, }; } } // 1) 目录命中优先(最权威) if (index) { const key = catalogKey(method, url, apiMethod, service); if (key && index.byKey.has(key)) { const kind = index.byKey.get(key); return { ...base, kind, source: 'catalog', reason: `命中接口目录(${key})→ ${kindLabel(kind)}` }; } if (apiMethod) { const q = service ? `${service}.${apiMethod}` : apiMethod; if (index.byRpc.has(q)) { const kind = index.byRpc.get(q); return { ...base, kind, source: 'catalog-rpc', reason: `命中方法名索引(${q})→ ${kindLabel(kind)}` }; } } } // 2) 动作词(apiMethod 优先,其次路径里自带的方法名,最后整条路径) // 判定逻辑与接口目录归纳器共用 detectKind,两处不会打架 const texts = []; if (apiMethod) texts.push({ t: lastSegment(apiMethod), src: 'name' }); let pathname = ''; try { pathname = new URL(url).pathname; } catch { pathname = ''; } const pm = methodFromPath(pathname); if (pm) texts.push({ t: pm, src: 'pathMethod' }); texts.push({ t: pathname, src: 'path' }); for (const { t, src } of texts) { if (!t) continue; const d = detectKind(t); if (!d) continue; return { ...base, kind: d.kind, source: `verb-${src}`, reason: d.kind === 'write' ? `动作词 ${d.word} 属写操作(来自${src === 'name' ? '方法名' : src === 'pathMethod' ? '路径方法名' : '路径'})` : `动作词 ${d.word} 属读取(来自${src === 'name' ? '方法名' : src === 'pathMethod' ? '路径方法名' : '路径'})`, }; } // 3) HTTP 方法兜底 if (READ_METHODS.has(method)) { return { ...base, kind: 'read', source: 'http', reason: `${method} 视为只读` }; } // 拿不到方法名的 POST:无法确认,交防护层保守拦截 return { ...base, kind: 'unknown', source: 'http', reason: `${method} 且未能识别方法名 → 无法确认是否为写操作` }; } function kindLabel(k) { return k === 'read' ? '只读' : k === 'write' ? '写操作' : '无法判定'; } /** 环境变量是否显式放行 */ function allowWriteFromEnv() { const v = String(process.env.NAPOS_ALLOW_WRITE || '').trim(); return v === '1' || v.toLowerCase() === 'true' || v.toLowerCase() === 'yes'; } /** * 出口校验:先过风控红线,再判读/写;只放行读接口。 * @param {object} req {method,url,body} * @param {object} opts {index, allowWrite, allowUnsigned} */ function assertAllowed(req, opts = {}) { // ★ 风控防线优先 —— 比读写判定更致命(会弹验证码) checkRisk(req, { allowUnsigned: opts.allowUnsigned || allowUnsignedFromEnv() }); const info = classifyRequest(req, opts.index); const allow = opts.allowWrite === true || allowWriteFromEnv(); if (info.kind === 'read' || allow) { return { ...info, allowed: true, blocked: false, forced: info.kind !== 'read' && allow }; } throw new WriteBlockedError({ ...info, method: String(req.method || 'GET').toUpperCase(), url: String(req.url || '') }); } module.exports = { CODE_WRITE_BLOCKED, CODE_RISK_BLOCKED, WriteBlockedError, RiskBlockedError, RISK_DENY, SIGN_SAFE_PATHS, READ_ONLY_SERVICES, READ_ONLY_PATH_PREFIXES, checkRisk, allowUnsignedFromEnv, extractApiMethod, buildCatalogIndex, catalogKey, classifyRequest, allowWriteFromEnv, assertAllowed, };