mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
Restrict delivery HTTP to loopback and fix grant races.
Require trusted loopback requests for proactive send, accept hyphenated WhatsApp group JIDs, and serialize access-grant mutations to avoid lost concurrent updates. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
01924e5f7e
commit
cbbf373b6a
8 changed files with 411 additions and 240 deletions
|
|
@ -11126,7 +11126,7 @@ function installWeixinStyles() {
|
||||||
var ACCESS_GRANT_VERSION = 1;
|
var ACCESS_GRANT_VERSION = 1;
|
||||||
var ACCESS_PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
var ACCESS_PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
||||||
var ACCESS_GRANT_PHONE_MAX_LENGTH = 32;
|
var ACCESS_GRANT_PHONE_MAX_LENGTH = 32;
|
||||||
var ACCESS_GRANT_GROUP_JID_PATTERN = /^\d{5,32}@g\.us$/;
|
var ACCESS_GRANT_GROUP_JID_PATTERN = /^\d{5,32}(?:-\d{1,32})?@g\.us$/;
|
||||||
var AGENT_PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
|
var AGENT_PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
var CONTROL_CHARACTERS2 = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
|
var CONTROL_CHARACTERS2 = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
|
||||||
function normalizeOptionalAgentPresetId(value) {
|
function normalizeOptionalAgentPresetId(value) {
|
||||||
|
|
|
||||||
440
lib/index.js
440
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -4,6 +4,33 @@ export const DELIVERY_HTTP_PATH = '/api/dsh-im/delivery/messages';
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 1024 * 1024;
|
const MAX_BODY_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same loopback / same-origin gate as DSH Host APIs: delivery must not be
|
||||||
|
* callable from a LAN-bound Host or cross-site browser navigation.
|
||||||
|
* @param {import('node:http').IncomingMessage} request
|
||||||
|
*/
|
||||||
|
export function isTrustedDeliveryRequest(request) {
|
||||||
|
const host = request.headers.host ?? '';
|
||||||
|
if (!host) return false;
|
||||||
|
const hostname = host.split(':')[0].replace(/^\[|\]$/g, '');
|
||||||
|
if ((request.headers['sec-fetch-site'] ?? '') === 'cross-site') return false;
|
||||||
|
const origin = request.headers.origin;
|
||||||
|
if (origin !== undefined && origin !== 'null') {
|
||||||
|
try {
|
||||||
|
if (new URL(origin).host !== host) return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const remoteHost = String(request.socket?.remoteAddress || '')
|
||||||
|
.replace(/^::ffff:/i, '');
|
||||||
|
if (remoteHost && remoteHost !== '127.0.0.1' && remoteHost !== '::1') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hostname === '127.0.0.1' || hostname === 'localhost'
|
||||||
|
|| hostname === '::1' || hostname === '0.0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
const DELIVERY_ERROR_STATUS = Object.freeze({
|
const DELIVERY_ERROR_STATUS = Object.freeze({
|
||||||
'bad-request': 400,
|
'bad-request': 400,
|
||||||
'unknown-bot': 404,
|
'unknown-bot': 404,
|
||||||
|
|
@ -68,6 +95,12 @@ async function readJsonBody(request) {
|
||||||
export function createDeliveryHttpHandler(service) {
|
export function createDeliveryHttpHandler(service) {
|
||||||
const dispatch = createDeliveryRpcHandler(service);
|
const dispatch = createDeliveryRpcHandler(service);
|
||||||
return async (request, response) => {
|
return async (request, response) => {
|
||||||
|
if (!isTrustedDeliveryRequest(request)) {
|
||||||
|
json(response, 403, {
|
||||||
|
error: { code: 'forbidden', message: 'forbidden', details: {} },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (request.method !== 'POST') {
|
if (request.method !== 'POST') {
|
||||||
json(response, 405, {
|
json(response, 405, {
|
||||||
error: { code: 'method-not-allowed', message: 'method-not-allowed', details: {} },
|
error: { code: 'method-not-allowed', message: 'method-not-allowed', details: {} },
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
export const ACCESS_GRANT_VERSION = 1;
|
export const ACCESS_GRANT_VERSION = 1;
|
||||||
export const ACCESS_PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
export const ACCESS_PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
export const ACCESS_GRANT_PHONE_MAX_LENGTH = 32;
|
export const ACCESS_GRANT_PHONE_MAX_LENGTH = 32;
|
||||||
export const ACCESS_GRANT_GROUP_JID_PATTERN = /^\d{5,32}@g\.us$/;
|
export const ACCESS_GRANT_GROUP_JID_PATTERN = /^\d{5,32}(?:-\d{1,32})?@g\.us$/;
|
||||||
const AGENT_PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
|
const AGENT_PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
|
|
||||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
|
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
|
||||||
|
|
|
||||||
|
|
@ -757,6 +757,41 @@ export class BotWorkspaceStore {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a mutator to the latest persisted grant inside the bot write queue.
|
||||||
|
* Callers must not read-modify-write outside this method — concurrent inbound
|
||||||
|
* (contact upsert, pending enqueue, admin approve) would otherwise lose updates.
|
||||||
|
* @param {string} botId
|
||||||
|
* @param {(current: object|null) => object|Promise<object>} mutator
|
||||||
|
* @param {{ incarnation?: unknown }} [options]
|
||||||
|
*/
|
||||||
|
async mutateAccessGrant(botId, mutator, { incarnation } = {}) {
|
||||||
|
const id = botIdOf(botId);
|
||||||
|
if (typeof mutator !== 'function') {
|
||||||
|
throw new TypeError('mutateAccessGrant requires a mutator function');
|
||||||
|
}
|
||||||
|
return this.#enqueue(id, async () => {
|
||||||
|
if (!this.has(id)
|
||||||
|
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
|
||||||
|
const error = new Error('找不到要修改的机器人。');
|
||||||
|
error.code = 'workspace-bot-not-found';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const previous = this.#accessGrants[id] ?? null;
|
||||||
|
const nextValue = await mutator(previous);
|
||||||
|
const grant = validateAccessGrant(nextValue);
|
||||||
|
this.#accessGrants[id] = grant;
|
||||||
|
try {
|
||||||
|
await this.#persist();
|
||||||
|
} catch (error) {
|
||||||
|
if (previous) this.#accessGrants[id] = previous;
|
||||||
|
else delete this.#accessGrants[id];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return grant;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async bindWorkspaceSession(botId, value, {
|
async bindWorkspaceSession(botId, value, {
|
||||||
conversationKey,
|
conversationKey,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,18 @@ export async function rememberWhatsappContact(workspaces, botId, grant, message,
|
||||||
if (!phone && !lid) return grant;
|
if (!phone && !lid) return grant;
|
||||||
const pushName = message.contextSource?.()?.senderName;
|
const pushName = message.contextSource?.()?.senderName;
|
||||||
try {
|
try {
|
||||||
|
if (typeof workspaces.mutateAccessGrant === 'function') {
|
||||||
|
return await workspaces.mutateAccessGrant(botId, (current) => {
|
||||||
|
const base = current ?? grant;
|
||||||
|
return upsertAccessContact(base, {
|
||||||
|
phone,
|
||||||
|
lid,
|
||||||
|
pushName,
|
||||||
|
scene: message.kind === 'group' ? 'group' : 'direct',
|
||||||
|
groupJid: message.kind === 'group' ? message.conversationId : undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
const next = upsertAccessContact(grant, {
|
const next = upsertAccessContact(grant, {
|
||||||
phone,
|
phone,
|
||||||
lid,
|
lid,
|
||||||
|
|
@ -144,12 +156,26 @@ export async function tryHandleWhatsappApprovalReply({
|
||||||
if (!pending || pending.status !== 'pending') return false;
|
if (!pending || pending.status !== 'pending') return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { grant: next, pending: resolved } = resolveAccessPending(grant, {
|
let resolved = pending;
|
||||||
pendingId: pending.id,
|
if (typeof workspaces.mutateAccessGrant === 'function') {
|
||||||
action: intent,
|
await workspaces.mutateAccessGrant(botId, (current) => {
|
||||||
resolvedByPhone: phone,
|
const result = resolveAccessPending(current ?? grant, {
|
||||||
});
|
pendingId: pending.id,
|
||||||
await workspaces.setAccessGrant(botId, next);
|
action: intent,
|
||||||
|
resolvedByPhone: phone,
|
||||||
|
});
|
||||||
|
resolved = result.pending;
|
||||||
|
return result.grant;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const result = resolveAccessPending(grant, {
|
||||||
|
pendingId: pending.id,
|
||||||
|
action: intent,
|
||||||
|
resolvedByPhone: phone,
|
||||||
|
});
|
||||||
|
resolved = result.pending;
|
||||||
|
await workspaces.setAccessGrant(botId, result.grant);
|
||||||
|
}
|
||||||
await sendText(
|
await sendText(
|
||||||
{ jid: message.replyTarget?.jid ?? `${phone}@s.whatsapp.net` },
|
{ jid: message.replyTarget?.jid ?? `${phone}@s.whatsapp.net` },
|
||||||
intent === 'approve' ? ACCESS_GRANT_COPY.adminApproved : ACCESS_GRANT_COPY.adminDenied,
|
intent === 'approve' ? ACCESS_GRANT_COPY.adminApproved : ACCESS_GRANT_COPY.adminDenied,
|
||||||
|
|
@ -264,16 +290,37 @@ export async function gateWhatsappInbound({
|
||||||
return { allowed: false, reason: 'group-unaddressed', grant: current };
|
return { allowed: false, reason: 'group-unaddressed', grant: current };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { grant: withPending, pending, created } = enqueueAccessPending(current, {
|
let pending;
|
||||||
kind: scene,
|
let created = false;
|
||||||
groupJid: scene === 'group' ? message.conversationId : undefined,
|
if (typeof workspaces.mutateAccessGrant === 'function') {
|
||||||
phone: phone ?? '',
|
let enqueued = null;
|
||||||
lid: lid ?? undefined,
|
current = await workspaces.mutateAccessGrant(botId, (latest) => {
|
||||||
pushName: message.contextSource?.()?.senderName,
|
enqueued = enqueueAccessPending(latest ?? current, {
|
||||||
requestText: message.content,
|
kind: scene,
|
||||||
});
|
groupJid: scene === 'group' ? message.conversationId : undefined,
|
||||||
current = withPending;
|
phone: phone ?? '',
|
||||||
await workspaces.setAccessGrant(botId, current);
|
lid: lid ?? undefined,
|
||||||
|
pushName: message.contextSource?.()?.senderName,
|
||||||
|
requestText: message.content,
|
||||||
|
});
|
||||||
|
return enqueued.grant;
|
||||||
|
});
|
||||||
|
pending = enqueued.pending;
|
||||||
|
created = enqueued.created;
|
||||||
|
} else {
|
||||||
|
const enqueued = enqueueAccessPending(current, {
|
||||||
|
kind: scene,
|
||||||
|
groupJid: scene === 'group' ? message.conversationId : undefined,
|
||||||
|
phone: phone ?? '',
|
||||||
|
lid: lid ?? undefined,
|
||||||
|
pushName: message.contextSource?.()?.senderName,
|
||||||
|
requestText: message.content,
|
||||||
|
});
|
||||||
|
current = enqueued.grant;
|
||||||
|
pending = enqueued.pending;
|
||||||
|
created = enqueued.created;
|
||||||
|
await workspaces.setAccessGrant(botId, current);
|
||||||
|
}
|
||||||
|
|
||||||
const ack = !phone
|
const ack = !phone
|
||||||
? ACCESS_GRANT_COPY.pendingUnresolved
|
? ACCESS_GRANT_COPY.pendingUnresolved
|
||||||
|
|
@ -310,8 +357,14 @@ export async function gateWhatsappInbound({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (refs.length > 0) {
|
if (refs.length > 0) {
|
||||||
current = attachPendingNotifyRefs(current, pending.id, refs);
|
if (typeof workspaces.mutateAccessGrant === 'function') {
|
||||||
await workspaces.setAccessGrant(botId, current);
|
current = await workspaces.mutateAccessGrant(botId, (latest) => (
|
||||||
|
attachPendingNotifyRefs(latest ?? current, pending.id, refs)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
current = attachPendingNotifyRefs(current, pending.id, refs);
|
||||||
|
await workspaces.setAccessGrant(botId, current);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,17 @@ function grant(overrides = {}) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('ACCESS_GRANT_GROUP_JID_PATTERN accepts legacy hyphen group JIDs', () => {
|
||||||
|
const legacy = '123456789-120363000000000000@g.us';
|
||||||
|
assert.doesNotThrow(() => validateAccessGrant({
|
||||||
|
...emptyAccessGrant(),
|
||||||
|
globalAdmins: ['8618111111111'],
|
||||||
|
groups: {
|
||||||
|
[legacy]: { admins: [], members: [] },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
test('normalizeAccessPhone accepts E.164, JIDs, and rejects LID servers', () => {
|
test('normalizeAccessPhone accepts E.164, JIDs, and rejects LID servers', () => {
|
||||||
assert.equal(normalizeAccessPhone('+86 181-4238-7786'), '8618142387786');
|
assert.equal(normalizeAccessPhone('+86 181-4238-7786'), '8618142387786');
|
||||||
assert.equal(normalizeAccessPhone('8618142387786@s.whatsapp.net'), '8618142387786');
|
assert.equal(normalizeAccessPhone('8618142387786@s.whatsapp.net'), '8618142387786');
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,45 @@ test('delivery HTTP maps only stable delivery errors to HTTP status codes', asyn
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('delivery HTTP rejects non-loopback Host', async () => {
|
||||||
|
const { service, calls } = serviceFixture();
|
||||||
|
const handler = createDeliveryHttpHandler(service);
|
||||||
|
const chunks = [];
|
||||||
|
const response = {
|
||||||
|
destroyed: false,
|
||||||
|
writableEnded: false,
|
||||||
|
writeHead(status) {
|
||||||
|
this.status = status;
|
||||||
|
},
|
||||||
|
end(body) {
|
||||||
|
this.writableEnded = true;
|
||||||
|
chunks.push(body);
|
||||||
|
},
|
||||||
|
once() {},
|
||||||
|
off() {},
|
||||||
|
};
|
||||||
|
await handler({
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
host: '192.168.1.10:3080',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
socket: { remoteAddress: '192.168.1.20' },
|
||||||
|
once() {},
|
||||||
|
off() {},
|
||||||
|
async *[Symbol.asyncIterator]() {
|
||||||
|
yield Buffer.from(JSON.stringify({
|
||||||
|
botId: 'bot_one', targetId: 'daily-report', text: 'nope',
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}, response);
|
||||||
|
assert.equal(response.status, 403);
|
||||||
|
assert.deepEqual(JSON.parse(chunks.join('')), {
|
||||||
|
error: { code: 'forbidden', message: 'forbidden', details: {} },
|
||||||
|
});
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('delivery HTTP installs one exact WebServer route with Cordis lifecycle ownership', () => {
|
test('delivery HTTP installs one exact WebServer route with Cordis lifecycle ownership', () => {
|
||||||
const { service } = serviceFixture();
|
const { service } = serviceFixture();
|
||||||
const registrations = [];
|
const registrations = [];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue