dsh-im-ops/plugin-src/host/delivery-http.mjs
oliver cbbf373b6a 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>
2026-09-06 14:45:37 +08:00

165 lines
5 KiB
JavaScript

import { createDeliveryRpcHandler, DELIVERY_ENDPOINTS } from './delivery-rpc.mjs';
export const DELIVERY_HTTP_PATH = '/api/dsh-im/delivery/messages';
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({
'bad-request': 400,
'unknown-bot': 404,
'unknown-target': 404,
'target-conflict': 409,
'invalid-target': 422,
'bot-not-connected': 503,
'target-rejected': 422,
'delivery-failed': 502,
cancelled: 408,
});
class HttpRequestError extends Error {
constructor(status, code) {
super(code);
this.status = status;
this.code = code;
}
}
function json(response, status, value, headers = {}) {
if (response.destroyed || response.writableEnded) return;
const body = JSON.stringify(value);
response.writeHead(status, {
'cache-control': 'no-store',
'content-type': 'application/json; charset=utf-8',
'content-length': String(Buffer.byteLength(body)),
...headers,
});
response.end(body);
}
function isJsonContentType(value) {
return typeof value === 'string'
&& value.split(';', 1)[0].trim().toLowerCase() === 'application/json';
}
async function readJsonBody(request) {
const declaredLength = Number(request.headers['content-length']);
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
throw new HttpRequestError(413, 'payload-too-large');
}
const chunks = [];
let length = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
length += buffer.length;
if (length > MAX_BODY_BYTES) {
throw new HttpRequestError(413, 'payload-too-large');
}
chunks.push(buffer);
}
try {
return JSON.parse(Buffer.concat(chunks, length).toString('utf8'));
} catch {
throw new HttpRequestError(400, 'bad-request');
}
}
export function createDeliveryHttpHandler(service) {
const dispatch = createDeliveryRpcHandler(service);
return async (request, response) => {
if (!isTrustedDeliveryRequest(request)) {
json(response, 403, {
error: { code: 'forbidden', message: 'forbidden', details: {} },
});
return;
}
if (request.method !== 'POST') {
json(response, 405, {
error: { code: 'method-not-allowed', message: 'method-not-allowed', details: {} },
}, { allow: 'POST' });
return;
}
if (!isJsonContentType(request.headers['content-type'])) {
json(response, 415, {
error: { code: 'unsupported-media-type', message: 'unsupported-media-type', details: {} },
});
return;
}
const abort = new AbortController();
const cancel = () => abort.abort();
const cancelClosedResponse = () => {
if (!response.writableEnded) cancel();
};
request.once('aborted', cancel);
response.once('close', cancelClosedResponse);
try {
const payload = await readJsonBody(request);
const result = await dispatch(DELIVERY_ENDPOINTS.send, payload, abort.signal);
if (result.ok) {
json(response, 200, result.value);
return;
}
json(response, DELIVERY_ERROR_STATUS[result.error.code] ?? 500, {
error: result.error,
});
} catch (error) {
if (error instanceof HttpRequestError) {
json(response, error.status, {
error: { code: error.code, message: error.code, details: {} },
});
return;
}
json(response, 500, {
error: { code: 'delivery-failed', message: 'delivery-failed', details: {} },
});
} finally {
request.off('aborted', cancel);
response.off('close', cancelClosedResponse);
}
};
}
export function installDeliveryHttp(ctx, service) {
if (!ctx?.webServer || typeof ctx.webServer.register !== 'function'
|| typeof ctx.effect !== 'function') {
throw new TypeError('DSH Host WebServer is required');
}
const route = {
kind: 'exact',
path: DELIVERY_HTTP_PATH,
handler: createDeliveryHttpHandler(service),
};
return ctx.effect(
() => ctx.webServer.register(route),
`dsh-im: ${DELIVERY_HTTP_PATH}`,
);
}