| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398 |
- '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,
- };
|