feat(sms): add real instance message workflow

This commit is contained in:
chick
2026-07-19 02:12:57 +08:00
parent 38a26c2ce1
commit 0112e3320a
17 changed files with 781 additions and 450 deletions
@@ -59,7 +59,6 @@ const origin = (raw: string): URL => {
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash
)
throw new TransportError('UNSAFE_ORIGIN');
@@ -98,6 +98,53 @@ describe('SafeUpstreamGateway', () => {
});
});
it('allows only a bounded SMS list query and serializes an explicit SMS payload', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({ transport: { get, post } });
await gateway.request({
url: 'http://192.168.1.20:8080/api/sms/list?limit=20&offset=0&direction=outgoing',
method: 'GET',
headers: { accept: 'application/json' },
});
await gateway.request({
url: 'http://192.168.1.20:8080/api/sms/send',
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
sms: { phoneNumber: '+15550199', content: 'hello' },
});
expect(get).toHaveBeenCalledOnce();
expect(post).toHaveBeenCalledWith(
'http://192.168.1.20:8080/api/sms/send',
{ accept: 'application/json', 'content-type': 'application/json' },
'{"phone_number":"+15550199","content":"hello"}',
);
});
it('rejects unallowlisted SMS queries and malformed send payloads before transport', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({ transport: { get, post } });
for (const url of [
'http://192.168.1.20/api/sms/list?limit=101&offset=0',
'http://192.168.1.20/api/sms/list?limit=10&offset=0&pdu=true',
'http://192.168.1.20/api/sms/list?offset=0&limit=10',
])
await expect(gateway.request({ url, method: 'GET', headers: {} })).rejects.toThrow(
'UPSTREAM_REQUEST_INVALID',
);
await expect(
gateway.request({
url: 'http://192.168.1.20/api/sms/send',
method: 'POST',
headers: {},
sms: { phoneNumber: 'bad\nnumber', content: 'hello' },
}),
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
expect(get).not.toHaveBeenCalled();
expect(post).not.toHaveBeenCalled();
});
it('does not allow a supplied redacted body marker to become a network request body', async () => {
const gateway = new SafeUpstreamGateway({
transport: {
@@ -46,12 +46,21 @@ export class SafeUpstreamGateway {
}
async request(request: UpstreamRequest): Promise<UpstreamResponse> {
const url = new URL(request.url);
const headerKeys = Object.keys(request.headers).sort().join(',');
if (request.method === 'GET') {
const smsList = url.pathname === '/api/sms/list';
const smsQuery = smsList
? /^(?:limit=([1-9]\d?)|limit=100)&offset=(0|[1-9]\d{0,4})(?:&direction=(incoming|outgoing))?$/u.exec(
url.search.slice(1),
)
: null;
if (
request.secret !== undefined ||
request.body !== undefined ||
(url.pathname !== '/api/stats' && url.pathname !== '/api/sim') ||
url.search ||
request.sms !== undefined ||
(headerKeys !== 'accept' && headerKeys !== 'accept,cookie') ||
(url.pathname !== '/api/stats' && url.pathname !== '/api/sim' && !smsQuery) ||
(!smsList && url.search) ||
url.hash ||
url.username ||
url.password ||
@@ -62,6 +71,31 @@ export class SafeUpstreamGateway {
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.get(request.url, request.headers);
}
if (url.pathname === '/api/sms/send') {
const sms = request.sms;
if (
request.method !== 'POST' ||
request.secret !== undefined ||
request.body !== undefined ||
!sms ||
(headerKeys !== 'accept,content-type' && headerKeys !== 'accept,content-type,cookie') ||
!/^\+?[0-9][0-9 ()-]{2,31}$/u.test(sms.phoneNumber) ||
sms.content.length < 1 ||
sms.content.length > 1600 ||
Buffer.byteLength(sms.content, 'utf8') > 6400 ||
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(sms.content) ||
url.search ||
url.hash ||
url.username ||
url.password
)
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.post(
request.url,
request.headers,
JSON.stringify({ phone_number: sms.phoneNumber, content: sms.content }),
);
}
if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (request.url.endsWith('/api/auth/login')) {