feat(sms): add real instance message workflow
This commit is contained in:
@@ -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')) {
|
||||
|
||||
Reference in New Issue
Block a user