diff --git a/docs/configuration.md b/docs/configuration.md index 1c80a03..463d0bd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -187,6 +187,14 @@ another workflow's lease. TTL is clamped to two hours and protects against cancelled workflows whose cleanup does not run. A preset remains active while any of its leases exists. +A lease is the operator's explicit statement that the matching SMS is expected +within that window, so a leased `guangdong-sso-auth` matches on the SMS text +only (`验证码` plus one SSO marker). The optional `GUANGDONG_SMS_SENDER` guard +applies to the statically configured preset, not to leases: gateway sender IDs +change per carrier and a stale value would otherwise let the OTP reach Telegram. +Outside any lease the workflow rule does not apply at all, so other messages are +forwarded exactly as before. + Lease writes accept only a bearer token; query-string authentication is not accepted. Reading `/messages` keeps its existing authentication behavior. diff --git a/src/pipeline.js b/src/pipeline.js index 79061bd..6503325 100644 --- a/src/pipeline.js +++ b/src/pipeline.js @@ -222,14 +222,18 @@ function telecomClaimPresetRules(env) { ]; } -function guangdongSsoPresetRules(env) { +function guangdongSsoPresetRules(env, { leased = false } = {}) { const rule = { name: 'guangdong-sso-auth', action: 'silence-store', textIncludesAll: ['验证码'], textIncludesAny: ['统一身份认证', '广东政务服务', '粤省事', '政务服务网', '深圳社保局'], }; - if (env.GUANGDONG_SMS_SENDER) rule.senderIncludes = env.GUANGDONG_SMS_SENDER; + // A workflow lease is the operator's explicit "an SSO OTP is expected right + // now" consent, so the leased window must not depend on the gateway's sender + // string, which changes per carrier/gateway and silently let codes through. + // Text evidence above stays mandatory either way. + if (env.GUANGDONG_SMS_SENDER && !leased) rule.senderIncludes = env.GUANGDONG_SMS_SENDER; if (env.GUANGDONG_SMS_KEYWORD) rule.textIncludesAny = [env.GUANGDONG_SMS_KEYWORD]; return [rule]; } @@ -245,9 +249,9 @@ export const WORKFLOW_LEASED_PRESETS = new Set( Object.entries(PRESETS).filter(([, preset]) => preset.leaseable).map(([name]) => name), ); -function interceptPresetRules(preset, env) { +function interceptPresetRules(preset, env, { leased = false } = {}) { const entry = PRESETS[preset]; - return entry ? entry.build(env) : []; + return entry ? entry.build(env, { leased }) : []; } function splitCsv(value) { diff --git a/tests/pipeline.test.js b/tests/pipeline.test.js index 6b03665..fe94459 100644 --- a/tests/pipeline.test.js +++ b/tests/pipeline.test.js @@ -183,3 +183,31 @@ test('parses numeric PushPlus timestamps in seconds and milliseconds', () => { assert.equal(parsePushPlusUpdateTime(1_780_295_445), 1_780_295_445_000); assert.equal(parsePushPlusUpdateTime(1_780_295_445_000), 1_780_295_445_000); }); + +test('guangdong preset ignores the sender filter inside a workflow lease', async () => { + const { interceptPresetRules } = await import('../src/pipeline.js'); + const env = { GUANGDONG_SMS_SENDER: '10086' }; + const message = { + text: '【政务短信平台】本次短信验证码为243073,有效期5分钟,请勿泄露。省统一身份认证平台\n发件号码: 10686353020', + }; + + // Static configuration keeps the sender guard. + const staticRules = interceptPresetRules('guangdong-sso-auth', env); + assert.equal(findInterceptRule(message, staticRules), null); + + // A held lease only needs the text evidence, so a gateway that reports a + // different sender cannot let the OTP through. + const leasedRules = interceptPresetRules('guangdong-sso-auth', env, { leased: true }); + const matched = findInterceptRule(message, leasedRules); + assert.equal(matched?.name, 'guangdong-sso-auth'); + assert.equal(interceptShouldSilence(matched), true); + assert.equal(interceptShouldStore(matched), true); +}); + +test('leased guangdong preset still requires SSO text evidence', async () => { + const { interceptPresetRules } = await import('../src/pipeline.js'); + const leasedRules = interceptPresetRules('guangdong-sso-auth', {}, { leased: true }); + const unrelated = { text: '【银行】您的验证码为 889900,请勿泄露。\n发件号码: 95588' }; + assert.equal(findInterceptRule(unrelated, leasedRules), null); + assert.equal(findInterceptRule({ text: '今天天气不错' }, leasedRules), null); +}); diff --git a/tests/worker-intercept.test.js b/tests/worker-intercept.test.js index 70217f1..9fd6745 100644 --- a/tests/worker-intercept.test.js +++ b/tests/worker-intercept.test.js @@ -217,3 +217,87 @@ test('intercept coordinator keeps a preset active until every workflow lease end assert.deepEqual(await activePresets(), []); assert.equal(stored.has('lease:telecom-claim-silent:expired'), false); }); + +test('a held lease silences an SSO OTP even when the configured sender differs', async () => { + const { default: worker, InterceptLeaseCoordinator } = await loadWorker(); + const originalFetch = globalThis.fetch; + const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); + const stored = new Map(); + const leaseStorage = new Map(); + let telegramCalls = 0; + Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true }); + globalThis.fetch = async url => { + assert.equal(new URL(String(url)).hostname, 'api.telegram.org'); + telegramCalls += 1; + return Response.json({ ok: true, result: { message_id: 1 } }); + }; + const deliveries = {}; + const coordinator = new InterceptLeaseCoordinator({ + storage: { + list: async ({ prefix }) => new Map( + [...leaseStorage].filter(([key]) => key.startsWith(prefix)), + ), + get: async key => leaseStorage.get(key), + put: async (key, value) => leaseStorage.set(key, value), + delete: async keyOrKeys => { + for (const key of Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys]) leaseStorage.delete(key); + }, + }, + }); + const env = { + CALLBACK_TOKEN: 'callback-token', + INBOX_TOKEN: 'inbox-token', + STATE_SECRET: 'state-secret', + TELEGRAM_BOT_TOKEN: 'telegram-token', + TELEGRAM_CHAT_ID: 'chat-id', + // A stale sender guard must not defeat the lease. + GUANGDONG_SMS_SENDER: '10086', + FORWARDED_KV: { + get: async key => stored.get(key) || null, + put: async (key, value) => stored.set(key, value), + }, + INTERCEPT_LEASES: { + getByName: name => name === 'sms-delivery' ? deliveries.getByName(name) : ({ + fetch: request => coordinator.fetch( + typeof request === 'string' ? new Request(request) : request, + ), + }), + }, + }; + Object.assign(deliveries, memoryDurableObjects(InterceptLeaseCoordinator, env)); + + try { + await worker.fetch(new Request( + 'https://worker.example.test/intercepts/leases/guangdong-sso-auth/run-456', + { + method: 'PUT', + headers: { authorization: 'Bearer inbox-token', 'content-type': 'application/json' }, + body: JSON.stringify({ ttlSeconds: 600 }), + }, + ), env, {}); + + const response = await worker.fetch(new Request( + 'https://worker.example.test/pushplus/webhook/callback-token', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + shortCode: 'leased-mismatched-sender', + title: '短信转发', + content: '【政务短信平台】本次短信验证码为243073,有效期5分钟,请勿泄露。省统一身份认证平台\n发件号码: 10686353020', + }), + }, + ), env, {}); + assert.equal(response.status, 200); + } finally { + globalThis.fetch = originalFetch; + if (originalCrypto) { + Object.defineProperty(globalThis, 'crypto', originalCrypto); + } else { + delete globalThis.crypto; + } + } + + assert.equal(telegramCalls, 0); + assert.equal([...stored.values()].some(value => value === 'intercept:guangdong-sso-auth'), true); +}); diff --git a/worker.js b/worker.js index 981247f..784b822 100644 --- a/worker.js +++ b/worker.js @@ -234,11 +234,11 @@ async function activeInterceptLeasePresets(env) { async function findEffectiveInterceptRule(message, env) { const staticRule = findInterceptRule(message, loadInterceptRules(env)); - if (staticRule) return staticRule; + if (staticRule) return { rule: staticRule, leased: false, presets: [] }; const leasedPresets = await activeInterceptLeasePresets(env); - if (!leasedPresets.length) return null; - const leasedRules = leasedPresets.flatMap(preset => interceptPresetRules(preset, env)); - return findInterceptRule(message, leasedRules); + if (!leasedPresets.length) return { rule: null, leased: false, presets: [] }; + const leasedRules = leasedPresets.flatMap(preset => interceptPresetRules(preset, env, { leased: true })); + return { rule: findInterceptRule(message, leasedRules), leased: true, presets: leasedPresets }; } async function dedupeKey(sourceId, env) { @@ -842,16 +842,34 @@ async function forwardPushPlusMessage(env, message) { await putDedupeKeys(keys, `duplicate:${String(existingValue).slice(0, 128)}`, env); return 'duplicates'; } - const interceptRule = await findEffectiveInterceptRule({ ...message, text }, env); + const intercept = await findEffectiveInterceptRule({ ...message, text }, env); + const interceptRule = intercept.rule; if (interceptRule) { if (interceptShouldStore(interceptRule)) { await storeInboxMessage(env, { ...message, text }); } if (interceptShouldSilence(interceptRule)) { await putDedupeKeys(keys, `intercept:${interceptRule.name || 'silence'}`, env); + console.log(JSON.stringify({ + event: 'intercept_decision', + decision: 'silenced', + rule: interceptRule.name || 'silence', + leased: intercept.leased, + presets: intercept.presets, + })); return 'intercepted'; } } + if (intercept.leased) { + // A held lease that matches nothing is the failure mode that lets an OTP + // reach Telegram, so record it instead of failing silently. + console.warn(JSON.stringify({ + event: 'intercept_decision', + decision: 'no_rule_match_while_leased', + leased: true, + presets: intercept.presets, + })); + } if (env.MESSAGE_TITLE_KEYWORD && !String(message.title || '').includes(env.MESSAGE_TITLE_KEYWORD)) { await putDedupeKeys(keys, 'ignored', env, IGNORED_DEDUPE_TTL_SECONDS);