guard.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. 'use strict';
  2. /**
  3. * 写操作防护(Write Guard)
  4. * ============================================================================
  5. * 背景:淘宝闪购商家后台的写接口(接单 / 改价 / 上下架 / 提交活动…)一旦被
  6. * 重放会产生**真实业务副作用**,不可撤销。
  7. *
  8. * 当前策略(用户指令):
  9. * 「写接口暂时不验证」→ 写接口默认**全面拦截**,只读接口才放行。
  10. *
  11. * 三道判定,优先级从高到低:
  12. * 1. 目录命中:请求能对上 data/apis.json 里的接口 → 直接采用它的 kind
  13. * 2. 方法名动词:从 query 的 method= / body.method / body.params.method 取真名,
  14. * 按 get/query/list… vs create/update/delete… 判定
  15. * 3. HTTP 方法兜底:GET/HEAD/OPTIONS → read;其余 → 视为 write(保守)
  16. *
  17. * 放行方式(必须显式):
  18. * · CLI : 加 --allow-write
  19. * · 环境 : NAPOS_ALLOW_WRITE=1
  20. */
  21. const fs = require('fs');
  22. const { templatePath, tryParseJson, lastSegment, methodFromPath, detectKind } = require('./classify');
  23. // 动词表与目录归纳器共用同一份,避免两处判定打架
  24. const GENERIC_INVOKER = /\/invoke\/?$|invoke\/?$|iOneRoute|h5api|callFromNapos|nevermore|xtop\//i;
  25. const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
  26. /**
  27. * 实测确认的「只读服务族」。
  28. *
  29. * 为什么需要它:归纳器靠方法名动词判读写,但有些服务的方法名里**根本没有动词**
  30. * (`PollingService.abnormalOrders` / `unprocessedOrders` / `nonCoreOrders`
  31. * 全被切成 ['abnormal','orders'] 这类名词,判不出来)→ 保守拦成 unknown。
  32. *
  33. * 证据(2026-10-11 抓包对照):这些是**订单看板的轮询口**,由前端每 N 秒自动调用,
  34. * body 只有 `{shopId}`,无任何可变业务参数;方法语义均为"取当前状态"。
  35. * 与之对照的真实写操作(接单/拒单/取消)都带 accept/reject/cancel 这类动词,
  36. * 不会被这条规则误放行。
  37. */
  38. const READ_ONLY_SERVICES = new Set(['PollingService']);
  39. /**
  40. * 实测确认的「只读路径前缀」(lsycm 数据中心 / 生意参谋)。
  41. *
  42. * 为什么需要它:这些接口是 POST 且 URL 里没有方法名(方法语义在 body 里,如
  43. * `/api/marketing/chain/card/marketingXxhfOverviewChain`),归纳器判不出动词 →
  44. * 保守拦成 unknown。但它们全是**数据中心的统计卡片 / 排行榜 / 归因**接口,无写能力。
  45. *
  46. * 证据(2026-10-11 抓包 + 实测):
  47. * · `/api/overview/chain/custom/*` → 经营概览(historyOverview / queryBusinessSummary / queryChainRealtimeInfo)
  48. * · `/api/marketing/chain/*` → 营销统计(先享后付 XXHF / 活动效果 / 门店洞察排行)
  49. * · `/api/customer/chain/general/*` → 顾客私域分析(PrvtOverview / PrvtTouchAnalyze / PrvtFlowTrans / PrvtUserStick)
  50. * · `/api/diagnosis/chain/*` → 经营诊断
  51. * · `/api/focus/*` → 指标定义
  52. * · `/api/naposOverview/*` → 概览辅助(城市列表 / 最新时间)
  53. *
  54. * 与之对照:lsycm 上的**写操作不存在**(生意参谋是纯读分析域);
  55. * 唯一需要永久拒绝的 `proteinStandardQuery/*` 是埋点/风控网关,已在 RISK_DENY 里一票否决,
  56. * 优先级高于本白名单。
  57. */
  58. const READ_ONLY_PATH_HOST = /(^|\.)lsycm\.alibaba\.com$/i;
  59. const READ_ONLY_PATH_PREFIXES = [
  60. '/api/overview/',
  61. '/api/marketing/chain/',
  62. '/api/customer/chain/',
  63. '/api/diagnosis/chain/',
  64. '/api/focus/',
  65. '/api/naposOverview/',
  66. '/api/napos/',
  67. '/api/ark/',
  68. '/naposGrayConfig/',
  69. ];
  70. /** error.code 用于上层区分"被防护挡住"和"真出错" */
  71. const CODE_WRITE_BLOCKED = 'NAPOS_WRITE_BLOCKED';
  72. const CODE_RISK_BLOCKED = 'NAPOS_RISK_BLOCKED';
  73. // ============================================================================
  74. // ★ 风控防线(Risk Guard)
  75. // ----------------------------------------------------------------------------
  76. // 实测结论(2026-10-11,逐接口对照):
  77. // 往**验签类接口**发不带 `secretkey` 的请求,服务端会判为伪造来源 →
  78. // 返回 `_____tmd_____/punish` 挑战页 → **客户端弹出验证码**。
  79. //
  80. // 证据:
  81. // · historyOverview —— 带 secretkey → 正常
  82. // · queryChainRealtimeInfo —— 不带 secretkey → 命中 punish(客户端挂出验证 iframe)
  83. // · proteinStandardQuery/TG3gM96 —— 不带 secretkey → 命中 punish
  84. //
  85. // 还有一类是**埋点/风控网关**接口,本身就不是给脚本调的,手构请求必触发。
  86. //
  87. // 防线统一放在这里(所有出站请求的唯一出口),
  88. // 这样走 MCP 工具 / CLI / skill 脚本三条路都拦得住。
  89. // ============================================================================
  90. /** 一票否决:这些请求永不外发 */
  91. const RISK_DENY = [
  92. { re: /proteinStandardQuery/i, why: '埋点 / 风控网关(TG3gM96 家族),手构请求必触发验证码' },
  93. { re: /_____tmd_____|\/punish\b|slidecaptcha|captcha/i, why: '这是风控验证页本身,不是业务接口' },
  94. { re: /\/\/?[^/]*\/um\.json|ynuf\.aliapp\.org/i, why: '阿里统一用户追踪(风控探针)' },
  95. ];
  96. /**
  97. * 必须带签名才允许外发的接口(发往 lsycm 数据中心的 POST)。
  98. * 这些接口一旦不带 `secretkey`,服务端判为伪造 → 风控。
  99. */
  100. const SIGN_REQUIRED_HOST = /(^|\.)lsycm\.alibaba\.com$/i;
  101. /**
  102. * 例外白名单:这些 lsycm 接口**实测不带签名也是安全的**(零风控、零鉴权失败)。
  103. * 只有实测过、确认安全的才准进这张表。
  104. */
  105. const SIGN_SAFE_PATHS = [
  106. /^\/api\/overview\/chain\/custom\/queryBusinessSummary$/,
  107. /^\/api\/overview\/chain\/custom\/historyOverview$/, // 走 kit 时本来就会签名
  108. /^\/api\/naposOverview\/queryChainCityList$/,
  109. /^\/api\/naposOverview\/getLatestTime$/,
  110. /^\/api\/customer\/chain\/general\/chainPrvtOverview$/,
  111. /^\/api\/customer\/chain\/general\/chainPrvtTouchAnalyze$/,
  112. /^\/api\/napos\/isGray$/,
  113. /^\/api\/napos\/isSycmPermission$/,
  114. /^\/api\/napos\/isSuperChainAdmin$/,
  115. /^\/naposGrayConfig\/getGray$/,
  116. /^\/api\/overview\/chain\/custom\/inGray$/,
  117. ];
  118. class RiskBlockedError extends Error {
  119. constructor(info) {
  120. super(
  121. `🛑 已拦截高危请求(防触发风控验证码)\n` +
  122. ` 接口 : ${info.method} ${info.url}\n` +
  123. ` 原因 : ${info.reason}\n` +
  124. (info.advice ? ` 怎么办 : ${info.advice}\n` : '') +
  125. ` ℹ️ 不带签名调用验签接口 = 伪造来源,会弹验证码,故一律拒发。`
  126. );
  127. this.name = 'RiskBlockedError';
  128. this.code = CODE_RISK_BLOCKED;
  129. this.info = info;
  130. }
  131. }
  132. /**
  133. * 风控预检。通过返回 {ok:true};命中红线抛 RiskBlockedError。
  134. * @param {object} req {method,url,body}
  135. * @param {object} [opts] {allowUnsigned}
  136. */
  137. function checkRisk(req, opts = {}) {
  138. const method = String(req.method || 'GET').toUpperCase();
  139. const url = String(req.url || '');
  140. // 1) 一票否决
  141. for (const d of RISK_DENY) {
  142. if (d.re.test(url)) {
  143. throw new RiskBlockedError({
  144. method, url, reason: d.why,
  145. advice: '该接口不在技能支持范围内;若确需数据,请改用白名单内的经营数据接口。',
  146. });
  147. }
  148. }
  149. // 2) 验签接口必须带 secretkey
  150. let u;
  151. try { u = new URL(url); } catch { u = null; }
  152. if (u && SIGN_REQUIRED_HOST.test(u.hostname) && !READ_METHODS.has(method)) {
  153. const safe = SIGN_SAFE_PATHS.some((re) => re.test(u.pathname));
  154. const body = typeof req.body === 'string' ? tryParseJson(req.body) : req.body;
  155. const hasSign = !!(body && typeof body === 'object' && body.secretkey);
  156. if (!safe && !hasSign && !opts.allowUnsigned) {
  157. throw new RiskBlockedError({
  158. method, url, reason: '该接口需要 secretkey 签名,但请求里没有 —— 会被服务端判为伪造并触发风控',
  159. advice: '用 kit.statsHistory()/kit.sign() 补齐签名;确认过安全可加 allowUnsigned。',
  160. });
  161. }
  162. }
  163. return { ok: true };
  164. }
  165. /** 环境变量是否显式放行无签名请求(默认否) */
  166. function allowUnsignedFromEnv() {
  167. const v = String(process.env.NAPOS_ALLOW_UNSIGNED || '').trim();
  168. return v === '1' || v.toLowerCase() === 'true' || v.toLowerCase() === 'yes';
  169. }
  170. class WriteBlockedError extends Error {
  171. constructor(info) {
  172. const head = info.kind === 'write' ? '已拦截写操作(策略:写接口暂不调用)' : '已拦截:无法确认是否为只读接口';
  173. super(
  174. `${head}\n` +
  175. ` 接口 : ${info.method} ${info.url}\n` +
  176. (info.apiMethod ? ` 方法名 : ${info.apiMethod}\n` : '') +
  177. ` 判定依据: ${info.reason}\n` +
  178. ` 如需放行: CLI 加 --allow-write,或设 NAPOS_ALLOW_WRITE=1`
  179. );
  180. this.name = 'WriteBlockedError';
  181. this.code = CODE_WRITE_BLOCKED;
  182. this.info = info;
  183. }
  184. }
  185. // ------------------------------------------------------------------ 真名提取
  186. /** 从 URL query / body 里取真实 RPC 方法名与服务名 */
  187. function extractApiMethod(url, body) {
  188. let service = '';
  189. let method = '';
  190. const b = typeof body === 'string' ? tryParseJson(body) : body;
  191. if (b && typeof b === 'object' && !Array.isArray(b)) {
  192. if (typeof b.service === 'string') service = b.service;
  193. if (typeof b.method === 'string') method = b.method;
  194. const inner = b.params && typeof b.params === 'object' && !Array.isArray(b.params) ? b.params : null;
  195. if (inner && typeof inner.method === 'string') {
  196. method = inner.method;
  197. if (typeof inner.service === 'string' && !service) service = inner.service;
  198. }
  199. }
  200. if (!method || !service) {
  201. let q;
  202. try { q = new URL(url).searchParams; } catch { q = new URLSearchParams(); }
  203. if (!method) {
  204. const qm = q.get('method');
  205. if (qm) {
  206. if (qm.includes('.')) {
  207. const i = qm.lastIndexOf('.');
  208. if (!service) service = qm.slice(0, i);
  209. method = qm.slice(i + 1);
  210. } else method = qm;
  211. }
  212. }
  213. if (!service) {
  214. const qs = q.get('service');
  215. if (qs) service = qs;
  216. }
  217. }
  218. return { service, method };
  219. }
  220. // ------------------------------------------------------------------ 目录索引
  221. /**
  222. * 用 apis.json 建索引。
  223. * 返回 { byKey: Map(规范key → kind), byRpc: Map('service.method' → kind) }
  224. */
  225. function buildCatalogIndex(cat) {
  226. const byKey = new Map();
  227. const byRpc = new Map();
  228. if (!cat || !Array.isArray(cat.endpoints)) return { byKey, byRpc };
  229. for (const e of cat.endpoints) {
  230. if (e.key) byKey.set(e.key, e.kind);
  231. if (e.rpcMethod) {
  232. const q = e.service ? `${e.service}.${e.rpcMethod}` : e.rpcMethod;
  233. // 同名冲突时,只要有一个是 write 就按 write 处理(保守)
  234. const prev = byRpc.get(q);
  235. byRpc.set(q, prev === 'write' ? 'write' : e.kind);
  236. }
  237. }
  238. return { byKey, byRpc };
  239. }
  240. /** 按目录生成规范 key(与 classify.js 的 key 构造保持一致) */
  241. function catalogKey(method, url, apiMethod, apiService) {
  242. let u;
  243. try { u = new URL(url); } catch { return null; }
  244. const pt = templatePath(u.pathname);
  245. const qualified = apiMethod ? (apiService ? `${apiService}.${apiMethod}` : apiMethod) : '';
  246. const isGeneric = GENERIC_INVOKER.test(u.pathname);
  247. if (qualified && isGeneric) return `${method} ${u.hostname} ${pt} :: ${qualified}`;
  248. return `${method} ${u.hostname} ${pt}`;
  249. }
  250. // ------------------------------------------------------------------ 判定
  251. /**
  252. * 判定一个请求是读、写,还是无法判定。
  253. * @returns {{kind:'read'|'write'|'unknown', reason:string, source:string, apiMethod:string, apiService:string}}
  254. */
  255. function classifyRequest(req, index) {
  256. const method = String(req.method || 'GET').toUpperCase();
  257. const url = String(req.url || '');
  258. const { service, method: apiMethod } = extractApiMethod(url, req.body);
  259. const base = { apiMethod, apiService: service };
  260. // 0) 实测只读服务族(最高优先级 —— 目录对它们判不出,会落到 unknown)
  261. if (service && READ_ONLY_SERVICES.has(service)) {
  262. return {
  263. ...base, kind: 'read', source: 'service-allowlist',
  264. reason: `服务 ${service} 属实测只读轮询族(订单看板轮询口,无副作用)`,
  265. };
  266. }
  267. // 0.5) lsycm 数据中心只读路径(生意参谋统计卡片/排行/归因,POST 无方法名判不出)
  268. {
  269. let u;
  270. try { u = new URL(url); } catch { u = null; }
  271. if (u && READ_ONLY_PATH_HOST.test(u.hostname)
  272. && READ_ONLY_PATH_PREFIXES.some((p) => u.pathname.startsWith(p))) {
  273. return {
  274. ...base, kind: 'read', source: 'path-allowlist',
  275. reason: `lsycm 数据中心只读路径(${u.pathname.split('/').slice(0, 4).join('/')}…,生意参谋统计接口,无写能力)`,
  276. };
  277. }
  278. }
  279. // 1) 目录命中优先(最权威)
  280. if (index) {
  281. const key = catalogKey(method, url, apiMethod, service);
  282. if (key && index.byKey.has(key)) {
  283. const kind = index.byKey.get(key);
  284. return { ...base, kind, source: 'catalog', reason: `命中接口目录(${key})→ ${kindLabel(kind)}` };
  285. }
  286. if (apiMethod) {
  287. const q = service ? `${service}.${apiMethod}` : apiMethod;
  288. if (index.byRpc.has(q)) {
  289. const kind = index.byRpc.get(q);
  290. return { ...base, kind, source: 'catalog-rpc', reason: `命中方法名索引(${q})→ ${kindLabel(kind)}` };
  291. }
  292. }
  293. }
  294. // 2) 动作词(apiMethod 优先,其次路径里自带的方法名,最后整条路径)
  295. // 判定逻辑与接口目录归纳器共用 detectKind,两处不会打架
  296. const texts = [];
  297. if (apiMethod) texts.push({ t: lastSegment(apiMethod), src: 'name' });
  298. let pathname = '';
  299. try { pathname = new URL(url).pathname; } catch { pathname = ''; }
  300. const pm = methodFromPath(pathname);
  301. if (pm) texts.push({ t: pm, src: 'pathMethod' });
  302. texts.push({ t: pathname, src: 'path' });
  303. for (const { t, src } of texts) {
  304. if (!t) continue;
  305. const d = detectKind(t);
  306. if (!d) continue;
  307. return {
  308. ...base, kind: d.kind, source: `verb-${src}`,
  309. reason: d.kind === 'write'
  310. ? `动作词 ${d.word} 属写操作(来自${src === 'name' ? '方法名' : src === 'pathMethod' ? '路径方法名' : '路径'})`
  311. : `动作词 ${d.word} 属读取(来自${src === 'name' ? '方法名' : src === 'pathMethod' ? '路径方法名' : '路径'})`,
  312. };
  313. }
  314. // 3) HTTP 方法兜底
  315. if (READ_METHODS.has(method)) {
  316. return { ...base, kind: 'read', source: 'http', reason: `${method} 视为只读` };
  317. }
  318. // 拿不到方法名的 POST:无法确认,交防护层保守拦截
  319. return { ...base, kind: 'unknown', source: 'http', reason: `${method} 且未能识别方法名 → 无法确认是否为写操作` };
  320. }
  321. function kindLabel(k) {
  322. return k === 'read' ? '只读' : k === 'write' ? '写操作' : '无法判定';
  323. }
  324. /** 环境变量是否显式放行 */
  325. function allowWriteFromEnv() {
  326. const v = String(process.env.NAPOS_ALLOW_WRITE || '').trim();
  327. return v === '1' || v.toLowerCase() === 'true' || v.toLowerCase() === 'yes';
  328. }
  329. /**
  330. * 出口校验:先过风控红线,再判读/写;只放行读接口。
  331. * @param {object} req {method,url,body}
  332. * @param {object} opts {index, allowWrite, allowUnsigned}
  333. */
  334. function assertAllowed(req, opts = {}) {
  335. // ★ 风控防线优先 —— 比读写判定更致命(会弹验证码)
  336. checkRisk(req, { allowUnsigned: opts.allowUnsigned || allowUnsignedFromEnv() });
  337. const info = classifyRequest(req, opts.index);
  338. const allow = opts.allowWrite === true || allowWriteFromEnv();
  339. if (info.kind === 'read' || allow) {
  340. return { ...info, allowed: true, blocked: false, forced: info.kind !== 'read' && allow };
  341. }
  342. throw new WriteBlockedError({ ...info, method: String(req.method || 'GET').toUpperCase(), url: String(req.url || '') });
  343. }
  344. module.exports = {
  345. CODE_WRITE_BLOCKED,
  346. CODE_RISK_BLOCKED,
  347. WriteBlockedError,
  348. RiskBlockedError,
  349. RISK_DENY,
  350. SIGN_SAFE_PATHS,
  351. READ_ONLY_SERVICES,
  352. READ_ONLY_PATH_PREFIXES,
  353. checkRisk,
  354. allowUnsignedFromEnv,
  355. extractApiMethod,
  356. buildCatalogIndex,
  357. catalogKey,
  358. classifyRequest,
  359. allowWriteFromEnv,
  360. assertAllowed,
  361. };