vellum 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bun.lock +4 -4
- package/package.json +4 -3
- package/src/__tests__/asset-materialize-tool.test.ts +2 -2
- package/src/__tests__/checker.test.ts +104 -0
- package/src/__tests__/config-schema.test.ts +0 -6
- package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +538 -0
- package/src/__tests__/ingress-url-consistency.test.ts +214 -0
- package/src/__tests__/ipc-snapshot.test.ts +17 -5
- package/src/__tests__/oauth-callback-registry.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +304 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
- package/src/__tests__/public-ingress-urls.test.ts +222 -0
- package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
- package/src/__tests__/runtime-events-sse.test.ts +162 -0
- package/src/__tests__/tool-executor.test.ts +88 -0
- package/src/__tests__/turn-commit.test.ts +64 -0
- package/src/__tests__/twilio-provider.test.ts +1 -1
- package/src/__tests__/twilio-routes.test.ts +4 -4
- package/src/__tests__/twitter-auth-handler.test.ts +87 -2
- package/src/calls/call-domain.ts +8 -6
- package/src/calls/twilio-config.ts +18 -3
- package/src/calls/twilio-routes.ts +10 -2
- package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
- package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
- package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
- package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
- package/src/config/defaults.ts +4 -1
- package/src/config/schema.ts +30 -6
- package/src/config/system-prompt.ts +1 -1
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
- package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
- package/src/daemon/computer-use-session.ts +2 -1
- package/src/daemon/handlers/config.ts +49 -17
- package/src/daemon/handlers/sessions.ts +2 -2
- package/src/daemon/handlers/shared.ts +1 -0
- package/src/daemon/handlers/subagents.ts +85 -2
- package/src/daemon/handlers/twitter-auth.ts +31 -2
- package/src/daemon/handlers/work-items.ts +1 -1
- package/src/daemon/ipc-contract-inventory.json +8 -4
- package/src/daemon/ipc-contract.ts +34 -15
- package/src/daemon/lifecycle.ts +9 -4
- package/src/daemon/server.ts +7 -0
- package/src/daemon/session-tool-setup.ts +8 -1
- package/src/inbound/public-ingress-urls.ts +112 -0
- package/src/memory/attachments-store.ts +0 -1
- package/src/memory/channel-delivery-store.ts +0 -1
- package/src/memory/conversation-key-store.ts +0 -1
- package/src/memory/db.ts +472 -148
- package/src/memory/llm-usage-store.ts +0 -1
- package/src/memory/runs-store.ts +51 -6
- package/src/memory/schema.ts +2 -6
- package/src/runtime/gateway-client.ts +7 -1
- package/src/runtime/http-server.ts +174 -7
- package/src/runtime/routes/channel-routes.ts +7 -2
- package/src/runtime/routes/events-routes.ts +79 -0
- package/src/runtime/routes/run-routes.ts +43 -0
- package/src/runtime/run-orchestrator.ts +64 -7
- package/src/security/oauth-callback-registry.ts +66 -0
- package/src/security/oauth2.ts +208 -58
- package/src/subagent/manager.ts +3 -1
- package/src/swarm/backend-claude-code.ts +1 -1
- package/src/tools/assets/search.ts +1 -36
- package/src/tools/claude-code/claude-code.ts +3 -3
- package/src/tools/tasks/work-item-list.ts +16 -2
- package/src/tools/tasks/work-item-run.ts +78 -0
- package/src/util/platform.ts +1 -1
- package/src/work-items/work-item-runner.ts +171 -0
- package/src/workspace/provider-commit-message-generator.ts +39 -23
- package/src/workspace/turn-commit.ts +6 -2
- package/src/__tests__/handlers-twilio-config.test.ts +0 -221
- package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
- package/src/calls/twilio-webhook-urls.ts +0 -50
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for gateway-only ingress mode enforcement in the runtime HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* - Direct Twilio webhook routes return 410 in gateway_only mode
|
|
6
|
+
* - Internal forwarding routes (gateway→runtime) still work in gateway_only mode
|
|
7
|
+
* - Relay WebSocket upgrade blocked for non-private-network origins (isPrivateNetworkOrigin) in gateway_only mode
|
|
8
|
+
* - Relay WebSocket upgrade allowed from private network peers/origins in gateway_only mode
|
|
9
|
+
* - All routes work normally in compat mode
|
|
10
|
+
* - Startup warning when RUNTIME_HTTP_HOST is not loopback in gateway_only mode
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
|
13
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'gw-only-enforcement-test-')));
|
|
18
|
+
|
|
19
|
+
mock.module('../util/platform.js', () => ({
|
|
20
|
+
getRootDir: () => testDir,
|
|
21
|
+
getDataDir: () => testDir,
|
|
22
|
+
getWorkspaceConfigPath: () => join(testDir, 'config.json'),
|
|
23
|
+
isMacOS: () => process.platform === 'darwin',
|
|
24
|
+
isLinux: () => process.platform === 'linux',
|
|
25
|
+
isWindows: () => process.platform === 'win32',
|
|
26
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
27
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
28
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
29
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
30
|
+
ensureDataDir: () => {},
|
|
31
|
+
migrateToDataLayout: () => {},
|
|
32
|
+
migrateToWorkspaceLayout: () => {},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// Configurable ingress mode — tests toggle this between 'gateway_only' and 'compat'
|
|
36
|
+
let mockIngressMode: 'gateway_only' | 'compat' = 'compat';
|
|
37
|
+
|
|
38
|
+
const logMessages: { level: string; msg: string; args?: unknown }[] = [];
|
|
39
|
+
|
|
40
|
+
mock.module('../util/logger.js', () => ({
|
|
41
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
42
|
+
get: (_target, prop: string) => {
|
|
43
|
+
if (prop === 'child') return () => new Proxy({} as Record<string, unknown>, {
|
|
44
|
+
get: () => () => {},
|
|
45
|
+
});
|
|
46
|
+
return (...args: unknown[]) => {
|
|
47
|
+
if (typeof args[0] === 'string') {
|
|
48
|
+
logMessages.push({ level: prop, msg: args[0] });
|
|
49
|
+
} else if (typeof args[1] === 'string') {
|
|
50
|
+
logMessages.push({ level: prop, msg: args[1], args: args[0] });
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
mock.module('../config/loader.js', () => ({
|
|
58
|
+
loadConfig: () => ({
|
|
59
|
+
model: 'test',
|
|
60
|
+
provider: 'test',
|
|
61
|
+
apiKeys: {},
|
|
62
|
+
memory: { enabled: false },
|
|
63
|
+
rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
|
|
64
|
+
secretDetection: { enabled: false },
|
|
65
|
+
calls: {
|
|
66
|
+
enabled: true,
|
|
67
|
+
provider: 'twilio',
|
|
68
|
+
webhookBaseUrl: 'https://test.example.com',
|
|
69
|
+
maxDurationSeconds: 3600,
|
|
70
|
+
userConsultTimeoutSeconds: 120,
|
|
71
|
+
disclosure: { enabled: false, text: '' },
|
|
72
|
+
safety: { denyCategories: [] },
|
|
73
|
+
},
|
|
74
|
+
ingress: {
|
|
75
|
+
publicBaseUrl: 'https://test.example.com',
|
|
76
|
+
mode: mockIngressMode,
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
getConfig: () => ({
|
|
80
|
+
model: 'test',
|
|
81
|
+
provider: 'test',
|
|
82
|
+
apiKeys: {},
|
|
83
|
+
memory: { enabled: false },
|
|
84
|
+
rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
|
|
85
|
+
secretDetection: { enabled: false },
|
|
86
|
+
ingress: {
|
|
87
|
+
publicBaseUrl: 'https://test.example.com',
|
|
88
|
+
mode: mockIngressMode,
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
invalidateConfigCache: () => {},
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
// Mock Twilio provider
|
|
95
|
+
mock.module('../calls/twilio-provider.js', () => ({
|
|
96
|
+
TwilioConversationRelayProvider: class {
|
|
97
|
+
static getAuthToken() { return 'mock-auth-token'; }
|
|
98
|
+
static verifyWebhookSignature() { return true; }
|
|
99
|
+
async initiateCall() { return { callSid: 'CA_mock_sid' }; }
|
|
100
|
+
async endCall() { return; }
|
|
101
|
+
},
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
// Mock Twilio config
|
|
105
|
+
mock.module('../calls/twilio-config.js', () => ({
|
|
106
|
+
getTwilioConfig: () => ({
|
|
107
|
+
accountSid: 'AC_test',
|
|
108
|
+
authToken: 'test_token',
|
|
109
|
+
phoneNumber: '+15550001111',
|
|
110
|
+
webhookBaseUrl: 'https://test.example.com',
|
|
111
|
+
wssBaseUrl: 'wss://test.example.com',
|
|
112
|
+
}),
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
mock.module('../security/secure-keys.js', () => ({
|
|
116
|
+
getSecureKey: () => null,
|
|
117
|
+
setSecureKey: () => true,
|
|
118
|
+
deleteSecureKey: () => {},
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
mock.module('../inbound/public-ingress-urls.js', () => ({
|
|
122
|
+
getPublicBaseUrl: () => 'https://test.example.com',
|
|
123
|
+
getTwilioRelayUrl: () => 'wss://test.example.com/webhooks/twilio/relay',
|
|
124
|
+
getTwilioVoiceWebhookUrl: (_cfg: unknown, id: string) => `https://test.example.com/webhooks/twilio/voice?callSessionId=${id}`,
|
|
125
|
+
getTwilioStatusCallbackUrl: () => 'https://test.example.com/webhooks/twilio/status',
|
|
126
|
+
getTwilioConnectActionUrl: () => 'https://test.example.com/webhooks/twilio/connect-action',
|
|
127
|
+
getOAuthCallbackUrl: () => 'https://test.example.com/webhooks/oauth/callback',
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
// Mock the oauth callback registry
|
|
131
|
+
mock.module('../security/oauth-callback-registry.js', () => ({
|
|
132
|
+
consumeCallback: () => true,
|
|
133
|
+
consumeCallbackError: () => true,
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
import { RuntimeHttpServer, isPrivateAddress } from '../runtime/http-server.js';
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Helpers
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
const TEST_TOKEN = 'test-bearer-token-gw';
|
|
143
|
+
const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` };
|
|
144
|
+
|
|
145
|
+
function makeFormBody(params: Record<string, string>): string {
|
|
146
|
+
return new URLSearchParams(params).toString();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Tests
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
describe('gateway-only ingress enforcement', () => {
|
|
154
|
+
let server: RuntimeHttpServer;
|
|
155
|
+
let port: number;
|
|
156
|
+
|
|
157
|
+
beforeEach(async () => {
|
|
158
|
+
logMessages.length = 0;
|
|
159
|
+
server = new RuntimeHttpServer({
|
|
160
|
+
port: 0,
|
|
161
|
+
hostname: '127.0.0.1',
|
|
162
|
+
bearerToken: TEST_TOKEN,
|
|
163
|
+
});
|
|
164
|
+
await server.start();
|
|
165
|
+
port = server.actualPort;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
afterEach(async () => {
|
|
169
|
+
await server.stop();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── Direct Twilio webhook routes blocked in gateway_only mode ──────
|
|
173
|
+
|
|
174
|
+
describe('gateway_only mode — direct webhook routes', () => {
|
|
175
|
+
beforeEach(() => { mockIngressMode = 'gateway_only'; });
|
|
176
|
+
afterEach(() => { mockIngressMode = 'compat'; });
|
|
177
|
+
|
|
178
|
+
test('POST /webhooks/twilio/voice returns 410', async () => {
|
|
179
|
+
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/voice`, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
182
|
+
body: makeFormBody({ CallSid: 'CA123', AccountSid: 'AC_test' }),
|
|
183
|
+
});
|
|
184
|
+
expect(res.status).toBe(410);
|
|
185
|
+
const body = await res.json() as { error: string; code: string };
|
|
186
|
+
expect(body.code).toBe('GATEWAY_ONLY');
|
|
187
|
+
expect(body.error).toContain('gateway-only mode');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('POST /webhooks/twilio/status returns 410', async () => {
|
|
191
|
+
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/status`, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
194
|
+
body: makeFormBody({ CallSid: 'CA123', CallStatus: 'completed' }),
|
|
195
|
+
});
|
|
196
|
+
expect(res.status).toBe(410);
|
|
197
|
+
const body = await res.json() as { error: string; code: string };
|
|
198
|
+
expect(body.code).toBe('GATEWAY_ONLY');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('POST /webhooks/twilio/connect-action returns 410', async () => {
|
|
202
|
+
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/connect-action`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
205
|
+
body: makeFormBody({ CallSid: 'CA123' }),
|
|
206
|
+
});
|
|
207
|
+
expect(res.status).toBe(410);
|
|
208
|
+
const body = await res.json() as { error: string; code: string };
|
|
209
|
+
expect(body.code).toBe('GATEWAY_ONLY');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('POST /v1/calls/twilio/voice-webhook returns 410', async () => {
|
|
213
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/calls/twilio/voice-webhook`, {
|
|
214
|
+
method: 'POST',
|
|
215
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
216
|
+
body: makeFormBody({ CallSid: 'CA123' }),
|
|
217
|
+
});
|
|
218
|
+
expect(res.status).toBe(410);
|
|
219
|
+
const body = await res.json() as { error: string; code: string };
|
|
220
|
+
expect(body.code).toBe('GATEWAY_ONLY');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('POST /v1/calls/twilio/status returns 410', async () => {
|
|
224
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/calls/twilio/status`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
227
|
+
body: makeFormBody({ CallSid: 'CA123', CallStatus: 'completed' }),
|
|
228
|
+
});
|
|
229
|
+
expect(res.status).toBe(410);
|
|
230
|
+
const body = await res.json() as { error: string; code: string };
|
|
231
|
+
expect(body.code).toBe('GATEWAY_ONLY');
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ── Internal forwarding routes still work in gateway_only mode ─────
|
|
236
|
+
|
|
237
|
+
describe('gateway_only mode — internal forwarding routes', () => {
|
|
238
|
+
beforeEach(() => { mockIngressMode = 'gateway_only'; });
|
|
239
|
+
afterEach(() => { mockIngressMode = 'compat'; });
|
|
240
|
+
|
|
241
|
+
test('POST /v1/internal/twilio/voice-webhook is NOT blocked', async () => {
|
|
242
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/internal/twilio/voice-webhook`, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
245
|
+
body: JSON.stringify({
|
|
246
|
+
params: { CallSid: 'CA123', AccountSid: 'AC_test' },
|
|
247
|
+
originalUrl: `http://127.0.0.1:${port}/v1/internal/twilio/voice-webhook?callSessionId=sess-123`,
|
|
248
|
+
}),
|
|
249
|
+
});
|
|
250
|
+
// Should NOT be 410 — it may 404 or 400 because the call session
|
|
251
|
+
// doesn't exist, but the gateway-only guard should NOT block it.
|
|
252
|
+
expect(res.status).not.toBe(410);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('POST /v1/internal/twilio/status is NOT blocked', async () => {
|
|
256
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/internal/twilio/status`, {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
259
|
+
body: JSON.stringify({
|
|
260
|
+
params: { CallSid: 'CA123', CallStatus: 'completed' },
|
|
261
|
+
}),
|
|
262
|
+
});
|
|
263
|
+
expect(res.status).not.toBe(410);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('POST /v1/internal/twilio/connect-action is NOT blocked', async () => {
|
|
267
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/internal/twilio/connect-action`, {
|
|
268
|
+
method: 'POST',
|
|
269
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
270
|
+
body: JSON.stringify({
|
|
271
|
+
params: { CallSid: 'CA123' },
|
|
272
|
+
}),
|
|
273
|
+
});
|
|
274
|
+
expect(res.status).not.toBe(410);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('POST /v1/internal/oauth/callback is NOT blocked', async () => {
|
|
278
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/internal/oauth/callback`, {
|
|
279
|
+
method: 'POST',
|
|
280
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
281
|
+
body: JSON.stringify({
|
|
282
|
+
state: 'test-state',
|
|
283
|
+
code: 'test-code',
|
|
284
|
+
}),
|
|
285
|
+
});
|
|
286
|
+
// Should succeed or return a non-410 status
|
|
287
|
+
expect(res.status).not.toBe(410);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ── Relay WebSocket upgrade in gateway_only mode ───────────────────
|
|
292
|
+
|
|
293
|
+
describe('gateway_only mode — relay WebSocket upgrade', () => {
|
|
294
|
+
beforeEach(() => { mockIngressMode = 'gateway_only'; });
|
|
295
|
+
afterEach(() => { mockIngressMode = 'compat'; });
|
|
296
|
+
|
|
297
|
+
test('blocks non-private-network origin', async () => {
|
|
298
|
+
// The peer address (127.0.0.1) passes the private network check,
|
|
299
|
+
// but the external Origin header triggers the secondary defense-in-depth block.
|
|
300
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`, {
|
|
301
|
+
headers: {
|
|
302
|
+
'Upgrade': 'websocket',
|
|
303
|
+
'Connection': 'Upgrade',
|
|
304
|
+
'Origin': 'https://external.example.com',
|
|
305
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
306
|
+
'Sec-WebSocket-Version': '13',
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
expect(res.status).toBe(403);
|
|
310
|
+
const body = await res.json() as { error: string; code: string };
|
|
311
|
+
expect(body.code).toBe('GATEWAY_ONLY');
|
|
312
|
+
expect(body.error).toContain('gateway-only mode');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('allows request with no origin header (private network peer)', async () => {
|
|
316
|
+
// Without an origin header, isPrivateNetworkOrigin returns true.
|
|
317
|
+
// The peer address (127.0.0.1) passes the private network peer check.
|
|
318
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`, {
|
|
319
|
+
headers: {
|
|
320
|
+
'Upgrade': 'websocket',
|
|
321
|
+
'Connection': 'Upgrade',
|
|
322
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
323
|
+
'Sec-WebSocket-Version': '13',
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
// Should NOT be 403 — WebSocket upgrade may or may not succeed
|
|
327
|
+
// depending on test environment, but the gateway guard should pass.
|
|
328
|
+
expect(res.status).not.toBe(403);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('allows localhost origin from loopback peer', async () => {
|
|
332
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`, {
|
|
333
|
+
headers: {
|
|
334
|
+
'Upgrade': 'websocket',
|
|
335
|
+
'Connection': 'Upgrade',
|
|
336
|
+
'Origin': 'http://127.0.0.1:3000',
|
|
337
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
338
|
+
'Sec-WebSocket-Version': '13',
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
// Should NOT be 403
|
|
342
|
+
expect(res.status).not.toBe(403);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ── Compat mode — everything works as before ───────────────────────
|
|
347
|
+
|
|
348
|
+
describe('compat mode — no enforcement', () => {
|
|
349
|
+
beforeEach(() => { mockIngressMode = 'compat'; });
|
|
350
|
+
|
|
351
|
+
test('POST /webhooks/twilio/voice is NOT blocked', async () => {
|
|
352
|
+
// In compat mode, disable webhook validation to focus on the ingress check
|
|
353
|
+
const savedDisable = process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
|
|
354
|
+
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
|
|
355
|
+
try {
|
|
356
|
+
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/voice?callSessionId=test-compat`, {
|
|
357
|
+
method: 'POST',
|
|
358
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
359
|
+
body: makeFormBody({ CallSid: 'CA_compat', AccountSid: 'AC_test' }),
|
|
360
|
+
});
|
|
361
|
+
// Should NOT be 410 (gateway-only)
|
|
362
|
+
expect(res.status).not.toBe(410);
|
|
363
|
+
} finally {
|
|
364
|
+
if (savedDisable !== undefined) {
|
|
365
|
+
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = savedDisable;
|
|
366
|
+
} else {
|
|
367
|
+
delete process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('POST /webhooks/twilio/status is NOT blocked', async () => {
|
|
373
|
+
const savedDisable = process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
|
|
374
|
+
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
|
|
375
|
+
try {
|
|
376
|
+
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/status`, {
|
|
377
|
+
method: 'POST',
|
|
378
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
379
|
+
body: makeFormBody({ CallSid: 'CA_compat', CallStatus: 'completed' }),
|
|
380
|
+
});
|
|
381
|
+
expect(res.status).not.toBe(410);
|
|
382
|
+
} finally {
|
|
383
|
+
if (savedDisable !== undefined) {
|
|
384
|
+
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = savedDisable;
|
|
385
|
+
} else {
|
|
386
|
+
delete process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('relay WebSocket upgrade is NOT blocked for external origin', async () => {
|
|
392
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-compat`, {
|
|
393
|
+
headers: {
|
|
394
|
+
'Upgrade': 'websocket',
|
|
395
|
+
'Connection': 'Upgrade',
|
|
396
|
+
'Origin': 'https://external.example.com',
|
|
397
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
398
|
+
'Sec-WebSocket-Version': '13',
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
// In compat mode, the gateway-only guard should not activate
|
|
402
|
+
expect(res.status).not.toBe(403);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ── isPrivateAddress unit tests ─────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
describe('isPrivateAddress', () => {
|
|
409
|
+
// Loopback
|
|
410
|
+
test.each([
|
|
411
|
+
'127.0.0.1',
|
|
412
|
+
'127.0.0.2',
|
|
413
|
+
'127.255.255.255',
|
|
414
|
+
'::1',
|
|
415
|
+
'::ffff:127.0.0.1',
|
|
416
|
+
])('accepts loopback address %s', (addr) => {
|
|
417
|
+
expect(isPrivateAddress(addr)).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// RFC 1918 private ranges
|
|
421
|
+
test.each([
|
|
422
|
+
'10.0.0.1',
|
|
423
|
+
'10.255.255.255',
|
|
424
|
+
'172.16.0.1',
|
|
425
|
+
'172.31.255.255',
|
|
426
|
+
'192.168.0.1',
|
|
427
|
+
'192.168.1.100',
|
|
428
|
+
])('accepts RFC 1918 private address %s', (addr) => {
|
|
429
|
+
expect(isPrivateAddress(addr)).toBe(true);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Link-local
|
|
433
|
+
test.each([
|
|
434
|
+
'169.254.0.1',
|
|
435
|
+
'169.254.255.255',
|
|
436
|
+
])('accepts link-local address %s', (addr) => {
|
|
437
|
+
expect(isPrivateAddress(addr)).toBe(true);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// IPv6 unique local (fc00::/7)
|
|
441
|
+
test.each([
|
|
442
|
+
'fc00::1',
|
|
443
|
+
'fd12:3456:789a::1',
|
|
444
|
+
'fdff::1',
|
|
445
|
+
])('accepts IPv6 unique local address %s', (addr) => {
|
|
446
|
+
expect(isPrivateAddress(addr)).toBe(true);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// IPv6 link-local (fe80::/10)
|
|
450
|
+
test.each([
|
|
451
|
+
'fe80::1',
|
|
452
|
+
'fe80::abcd:1234',
|
|
453
|
+
])('accepts IPv6 link-local address %s', (addr) => {
|
|
454
|
+
expect(isPrivateAddress(addr)).toBe(true);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// IPv4-mapped IPv6 private addresses
|
|
458
|
+
test.each([
|
|
459
|
+
'::ffff:10.0.0.1',
|
|
460
|
+
'::ffff:172.16.0.1',
|
|
461
|
+
'::ffff:192.168.1.1',
|
|
462
|
+
'::ffff:169.254.0.1',
|
|
463
|
+
])('accepts IPv4-mapped IPv6 private address %s', (addr) => {
|
|
464
|
+
expect(isPrivateAddress(addr)).toBe(true);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Public addresses — should be rejected
|
|
468
|
+
test.each([
|
|
469
|
+
'8.8.8.8',
|
|
470
|
+
'1.1.1.1',
|
|
471
|
+
'203.0.113.1',
|
|
472
|
+
'172.32.0.1',
|
|
473
|
+
'172.15.255.255',
|
|
474
|
+
'11.0.0.1',
|
|
475
|
+
'192.169.0.1',
|
|
476
|
+
'::ffff:8.8.8.8',
|
|
477
|
+
'2001:db8::1',
|
|
478
|
+
])('rejects public address %s', (addr) => {
|
|
479
|
+
expect(isPrivateAddress(addr)).toBe(false);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// ── Startup warning for non-loopback host ──────────────────────────
|
|
484
|
+
|
|
485
|
+
describe('startup guard — non-loopback host warning', () => {
|
|
486
|
+
test('logs warning when hostname is not loopback in gateway_only mode', async () => {
|
|
487
|
+
mockIngressMode = 'gateway_only';
|
|
488
|
+
logMessages.length = 0;
|
|
489
|
+
|
|
490
|
+
const warnServer = new RuntimeHttpServer({
|
|
491
|
+
port: 0,
|
|
492
|
+
hostname: '0.0.0.0',
|
|
493
|
+
bearerToken: TEST_TOKEN,
|
|
494
|
+
});
|
|
495
|
+
await warnServer.start();
|
|
496
|
+
|
|
497
|
+
const infoMsg = logMessages.find(
|
|
498
|
+
m => m.level === 'info' && m.msg.includes('gateway-only ingress mode'),
|
|
499
|
+
);
|
|
500
|
+
expect(infoMsg).toBeDefined();
|
|
501
|
+
|
|
502
|
+
const warnMsg = logMessages.find(
|
|
503
|
+
m => m.level === 'warn' && m.msg.includes('not bound to loopback'),
|
|
504
|
+
);
|
|
505
|
+
expect(warnMsg).toBeDefined();
|
|
506
|
+
|
|
507
|
+
await warnServer.stop();
|
|
508
|
+
mockIngressMode = 'compat';
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test('does NOT log warning when hostname is loopback in gateway_only mode', async () => {
|
|
512
|
+
mockIngressMode = 'gateway_only';
|
|
513
|
+
logMessages.length = 0;
|
|
514
|
+
|
|
515
|
+
// The main test server already uses 127.0.0.1, so restart with
|
|
516
|
+
// a fresh server and capture logs
|
|
517
|
+
const loopbackServer = new RuntimeHttpServer({
|
|
518
|
+
port: 0,
|
|
519
|
+
hostname: '127.0.0.1',
|
|
520
|
+
bearerToken: TEST_TOKEN,
|
|
521
|
+
});
|
|
522
|
+
await loopbackServer.start();
|
|
523
|
+
|
|
524
|
+
const infoMsg = logMessages.find(
|
|
525
|
+
m => m.level === 'info' && m.msg.includes('gateway-only ingress mode'),
|
|
526
|
+
);
|
|
527
|
+
expect(infoMsg).toBeDefined();
|
|
528
|
+
|
|
529
|
+
const warnMsg = logMessages.find(
|
|
530
|
+
m => m.level === 'warn' && m.msg.includes('not bound to loopback'),
|
|
531
|
+
);
|
|
532
|
+
expect(warnMsg).toBeUndefined();
|
|
533
|
+
|
|
534
|
+
await loopbackServer.stop();
|
|
535
|
+
mockIngressMode = 'compat';
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
});
|