Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 8 additions & 4 deletions src/pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand All @@ -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) {
Expand Down
28 changes: 28 additions & 0 deletions tests/pipeline.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
84 changes: 84 additions & 0 deletions tests/worker-intercept.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
28 changes: 23 additions & 5 deletions worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
Loading