@tellescope/sdk 1.256.0 → 1.256.2
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/lib/cjs/sdk.d.ts +2 -2
- package/lib/cjs/tests/api_tests/appointment_link_expiration.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/appointment_link_expiration.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/appointment_link_expiration.test.js +163 -0
- package/lib/cjs/tests/api_tests/appointment_link_expiration.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.js +224 -0
- package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/embeddables_auth_token.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/embeddables_auth_token.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/embeddables_auth_token.test.js +354 -0
- package/lib/cjs/tests/api_tests/embeddables_auth_token.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/start_ai_conversation.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/start_ai_conversation.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/start_ai_conversation.test.js +351 -0
- package/lib/cjs/tests/api_tests/start_ai_conversation.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +217 -189
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/cjs/tests/unit_tests/phone_validation.test.d.ts +3 -0
- package/lib/cjs/tests/unit_tests/phone_validation.test.d.ts.map +1 -0
- package/lib/cjs/tests/unit_tests/phone_validation.test.js +96 -0
- package/lib/cjs/tests/unit_tests/phone_validation.test.js.map +1 -0
- package/lib/esm/sdk.d.ts +2 -2
- package/lib/esm/tests/api_tests/appointment_link_expiration.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/appointment_link_expiration.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/appointment_link_expiration.test.js +156 -0
- package/lib/esm/tests/api_tests/appointment_link_expiration.test.js.map +1 -0
- package/lib/esm/tests/api_tests/beluga_trigger_refill.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/beluga_trigger_refill.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/beluga_trigger_refill.test.js +220 -0
- package/lib/esm/tests/api_tests/beluga_trigger_refill.test.js.map +1 -0
- package/lib/esm/tests/api_tests/embeddables_auth_token.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/embeddables_auth_token.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/embeddables_auth_token.test.js +347 -0
- package/lib/esm/tests/api_tests/embeddables_auth_token.test.js.map +1 -0
- package/lib/esm/tests/api_tests/start_ai_conversation.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/start_ai_conversation.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/start_ai_conversation.test.js +347 -0
- package/lib/esm/tests/api_tests/start_ai_conversation.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +217 -189
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/esm/tests/unit_tests/phone_validation.test.d.ts +3 -0
- package/lib/esm/tests/unit_tests/phone_validation.test.d.ts.map +1 -0
- package/lib/esm/tests/unit_tests/phone_validation.test.js +93 -0
- package/lib/esm/tests/unit_tests/phone_validation.test.js.map +1 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/tests/api_tests/appointment_link_expiration.test.ts +92 -0
- package/src/tests/api_tests/beluga_trigger_refill.test.ts +152 -0
- package/src/tests/api_tests/embeddables_auth_token.test.ts +417 -0
- package/src/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.ts +292 -0
- package/src/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.ts +179 -0
- package/src/tests/api_tests/start_ai_conversation.test.ts +256 -0
- package/src/tests/tests.ts +14 -0
- package/src/tests/unit_tests/phone_validation.test.ts +108 -0
- package/test_generated.pdf +0 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import http from "http"
|
|
4
|
+
import os from "os"
|
|
5
|
+
import { Session } from "../../../sdk"
|
|
6
|
+
import {
|
|
7
|
+
assert,
|
|
8
|
+
async_test,
|
|
9
|
+
log_header,
|
|
10
|
+
} from "@tellescope/testing"
|
|
11
|
+
import { setup_tests } from "../../setup"
|
|
12
|
+
|
|
13
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
14
|
+
|
|
15
|
+
const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '::1']
|
|
16
|
+
|
|
17
|
+
const TEST_SECRET = "f0015 outbound ssrf test secret"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The sentinel — the destination that must NEVER be reached — is bound to this machine's own RFC1918
|
|
21
|
+
* address rather than to loopback, and that choice is load-bearing.
|
|
22
|
+
*
|
|
23
|
+
* Loopback is deliberately permitted outside prod/staging (see api/modules/outbound_url.ts), because
|
|
24
|
+
* several test suites bind their receivers with `listen(0, '127.0.0.1')` and an ephemeral port cannot be
|
|
25
|
+
* allowlisted ahead of time. A loopback sentinel would therefore be ALLOWED in exactly the environment
|
|
26
|
+
* where this test runs, and "the sentinel was not contacted" would pass by allowance while proving
|
|
27
|
+
* nothing.
|
|
28
|
+
*
|
|
29
|
+
* RFC1918 is refused in every environment, so binding there keeps the assertion meaningful AND keeps a
|
|
30
|
+
* real listener present — which is what lets us prove non-delivery by observation rather than by
|
|
31
|
+
* reading a log message.
|
|
32
|
+
*/
|
|
33
|
+
const first_private_ipv4 = () => {
|
|
34
|
+
for (const addresses of Object.values(os.networkInterfaces())) {
|
|
35
|
+
for (const address of addresses ?? []) {
|
|
36
|
+
if (address.family !== 'IPv4' || address.internal) continue
|
|
37
|
+
if (/^10\./.test(address.address)) return address.address
|
|
38
|
+
if (/^192\.168\./.test(address.address)) return address.address
|
|
39
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(address.address)) return address.address
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 127.0.0.1 only means "the machine running this test" when the API is also local. Against staging or
|
|
47
|
+
* prod it is the API server itself — the sentinel would never be contacted, every assertion here would
|
|
48
|
+
* pass for the wrong reason, and we would be creating a real webhook in a live tenant. Fail loudly.
|
|
49
|
+
*/
|
|
50
|
+
const assert_local_api = () => {
|
|
51
|
+
const hostname = new URL(host).hostname
|
|
52
|
+
if (!LOCAL_HOSTNAMES.includes(hostname)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`F-0015 SSRF tests must run against a local API (got ${host}). `
|
|
55
|
+
+ `127.0.0.1 would resolve to the API server rather than this machine, and this test configures a webhook.`
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const listen = (server: http.Server, address: string) => new Promise<void>((resolve, reject) => {
|
|
61
|
+
server.once('error', reject)
|
|
62
|
+
server.listen(0, address, () => resolve()) // ephemeral port — nothing here needs a fixed one
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const port_of = (server: http.Server) => (server.address() as any).port
|
|
66
|
+
|
|
67
|
+
/** Webhook delivery is fire-and-forget, so absence has to be established by waiting. */
|
|
68
|
+
const settle = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
|
69
|
+
const wait_until = async (predicate: () => boolean, ms: number) => {
|
|
70
|
+
const deadline = Date.now() + ms
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
if (predicate()) return true
|
|
73
|
+
await settle(100)
|
|
74
|
+
}
|
|
75
|
+
return predicate()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Regression tests for F-0015
|
|
80
|
+
* (security-audit/findings/F-0015-webhook-outbound-ssrf.md).
|
|
81
|
+
*
|
|
82
|
+
* Outbound webhooks request customer-configured URLs that are validated only for SYNTAX
|
|
83
|
+
* (urlValidator -> validator.isURL, which happily accepts http://10.0.0.1/). Four call sites are
|
|
84
|
+
* affected: api/modules/webhooks.ts:145, :214, :270, and the five-method block at api/v1/api.ts:9723.
|
|
85
|
+
*
|
|
86
|
+
* Two properties make this worse than a blind SSRF:
|
|
87
|
+
* - A non-2xx response body is stored in log.response, and webhook_logs is surfaced to customers for
|
|
88
|
+
* debugging — so an internal endpoint's error body is readable by the tenant that configured it.
|
|
89
|
+
* - The automation send-webhook action builds its URL at runtime through five nested template
|
|
90
|
+
* replacers over action.info.url, so patient-controlled field values can reach the destination.
|
|
91
|
+
*
|
|
92
|
+
* And critically: axios@0.30.2 follows redirects by default (maxRedirects: 5). A pre-flight check on
|
|
93
|
+
* the configured URL is therefore NOT sufficient — a receiver can simply 302 to an internal address
|
|
94
|
+
* after validation has already passed. T4 exists to prove exactly that, which is why the fix has to
|
|
95
|
+
* validate at connect time (a guarded agent) rather than as a string check.
|
|
96
|
+
*
|
|
97
|
+
* Each test below asserts ABSENCE ("the sentinel was never contacted"), which passes trivially if the
|
|
98
|
+
* request never happened at all — a missing allowlist entry, a webhook that never fired, a broken
|
|
99
|
+
* subscription. So each is paired with a positive control that proves the attempt really was made.
|
|
100
|
+
*/
|
|
101
|
+
export const webhook_outbound_ssrf_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
102
|
+
log_header("F-0015: outbound webhook SSRF")
|
|
103
|
+
assert_local_api()
|
|
104
|
+
|
|
105
|
+
const SENTINEL = `f0015-sentinel-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
106
|
+
|
|
107
|
+
const privateIP = first_private_ipv4()
|
|
108
|
+
if (!privateIP) {
|
|
109
|
+
// Degrade loudly rather than silently passing. Everything below needs a bindable address that the
|
|
110
|
+
// guard refuses in every environment, and loopback does not qualify (see the note above).
|
|
111
|
+
assert(
|
|
112
|
+
false,
|
|
113
|
+
'F-0015 SKIPPED: no non-loopback RFC1918 interface on this host, so the SSRF sentinel cannot be '
|
|
114
|
+
+ 'bound anywhere the guard refuses. Run on a host with a private network interface.',
|
|
115
|
+
'F-0015 outbound SSRF',
|
|
116
|
+
)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let sentinelHits = 0 // must NEVER be reached once fixed
|
|
121
|
+
let okHits = 0 // permitted target: must stay reachable
|
|
122
|
+
let redirectHits = 0 // permitted hop 1 of the redirect chain
|
|
123
|
+
|
|
124
|
+
let sentinelURL = ''
|
|
125
|
+
|
|
126
|
+
// Stands in for an internal-only service, on an RFC1918 address that is refused in dev, staging and
|
|
127
|
+
// prod alike.
|
|
128
|
+
const sentinel = http.createServer((_, res) => {
|
|
129
|
+
sentinelHits++
|
|
130
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
131
|
+
res.end(JSON.stringify({ sentinel: SENTINEL, credentials: 'internal-only' }))
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// The legitimate receiver, on loopback — permitted in local dev exactly like every other webhook test.
|
|
135
|
+
const allowed = http.createServer((req, res) => {
|
|
136
|
+
if ((req.url || '').startsWith('/redirect-to-internal')) {
|
|
137
|
+
redirectHits++
|
|
138
|
+
// Hop 1 is permitted and genuinely succeeds; hop 2 points somewhere that is not.
|
|
139
|
+
res.writeHead(302, { location: sentinelURL })
|
|
140
|
+
res.end()
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
okHits++
|
|
144
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
145
|
+
res.end(JSON.stringify({ ok: true }))
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
let enduserId: string | undefined
|
|
149
|
+
let templateEnduserId: string | undefined
|
|
150
|
+
let configuredWebhook = false
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await listen(sentinel, privateIP)
|
|
154
|
+
const sentinelPort = port_of(sentinel)
|
|
155
|
+
sentinelURL = `http://${privateIP}:${sentinelPort}/ssrf-sentinel`
|
|
156
|
+
|
|
157
|
+
await listen(allowed, '127.0.0.1')
|
|
158
|
+
const okURL = `http://127.0.0.1:${port_of(allowed)}/webhook-ok`
|
|
159
|
+
const redirectURL = `http://127.0.0.1:${port_of(allowed)}/redirect-to-internal`
|
|
160
|
+
|
|
161
|
+
const enduser = await sdk.api.endusers.createOne({
|
|
162
|
+
email: `f0015-${Date.now()}@example.com`, fname: 'F0015', lname: 'Ssrf',
|
|
163
|
+
})
|
|
164
|
+
enduserId = enduser.id
|
|
165
|
+
|
|
166
|
+
const send_automation_webhook_to = async (url: string, forEnduserId = enduser.id) => {
|
|
167
|
+
sentinelHits = 0; okHits = 0; redirectHits = 0
|
|
168
|
+
const outcome = await sdk.api.webhooks.send_automation_webhook({
|
|
169
|
+
enduserId: forEnduserId,
|
|
170
|
+
action: { info: { url } },
|
|
171
|
+
} as any).then(() => 'resolved').catch(() => 'rejected')
|
|
172
|
+
// The action awaits its own axios call, but give redirect chains a moment to land.
|
|
173
|
+
await settle(750)
|
|
174
|
+
return { outcome, sentinelHits, okHits, redirectHits }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------------------------
|
|
178
|
+
// T6 first: the positive control for the whole file. If an allowlisted target does not receive the
|
|
179
|
+
// webhook, every "sentinel not contacted" assertion below is meaningless.
|
|
180
|
+
// ---------------------------------------------------------------------------------------------
|
|
181
|
+
await async_test(
|
|
182
|
+
'F-0015: automation webhook to a permitted target still delivers (regression baseline)',
|
|
183
|
+
() => send_automation_webhook_to(okURL),
|
|
184
|
+
{ onResult: (r: any) => r.okHits === 1 && r.sentinelHits === 0 },
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
// T3 — direct non-allowlisted private destination.
|
|
188
|
+
await async_test(
|
|
189
|
+
'F-0015: automation webhook does not reach a private destination',
|
|
190
|
+
() => send_automation_webhook_to(sentinelURL),
|
|
191
|
+
{ onResult: (r: any) => r.sentinelHits === 0 },
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
// T4 — the redirect bypass. THE test that justifies a connect-time guard over a pre-flight check.
|
|
195
|
+
await async_test(
|
|
196
|
+
'F-0015: automation webhook does not follow a redirect from a permitted host into a private one',
|
|
197
|
+
() => send_automation_webhook_to(redirectURL),
|
|
198
|
+
{
|
|
199
|
+
// redirectHits === 1 is the positive control: hop 1 was allowlisted and really was reached, so
|
|
200
|
+
// sentinelHits === 0 can only mean hop 2 was refused — not that the request never happened.
|
|
201
|
+
onResult: (r: any) => r.redirectHits === 1 && r.sentinelHits === 0,
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
// T5 — the destination arrives via template interpolation, so validation must run AFTER the
|
|
206
|
+
// replacers rather than against the stored config. {{enduser.<key>}} resolves from enduser.fields.
|
|
207
|
+
const templateEnduser = await sdk.api.endusers.createOne({
|
|
208
|
+
email: `f0015-tpl-${Date.now()}@example.com`,
|
|
209
|
+
fname: 'F0015', lname: 'Template',
|
|
210
|
+
fields: { ssrfTarget: `${privateIP}:${sentinelPort}` },
|
|
211
|
+
})
|
|
212
|
+
templateEnduserId = templateEnduser.id
|
|
213
|
+
|
|
214
|
+
await async_test(
|
|
215
|
+
'F-0015: automation webhook validates the URL after template interpolation, not before',
|
|
216
|
+
() => send_automation_webhook_to('http://{{enduser.ssrfTarget}}/ssrf-sentinel', templateEnduser.id),
|
|
217
|
+
{ onResult: (r: any) => r.sentinelHits === 0 },
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------------------------
|
|
221
|
+
// The configured-webhook delivery path (api/modules/webhooks.ts:145), which is a different code
|
|
222
|
+
// path from the automation action above. Paired: allowlisted target proves the subscription and
|
|
223
|
+
// trigger plumbing works, then the same plumbing pointed at the sentinel must not reach it.
|
|
224
|
+
// ---------------------------------------------------------------------------------------------
|
|
225
|
+
// CUDSubscription is an object of booleans, not a list of verbs (see webhooks_tests.ts fullSubscription).
|
|
226
|
+
await sdk.api.webhooks.configure({
|
|
227
|
+
url: okURL, secret: TEST_SECRET, subscriptions: { endusers: { create: true } },
|
|
228
|
+
})
|
|
229
|
+
configuredWebhook = true
|
|
230
|
+
|
|
231
|
+
await async_test(
|
|
232
|
+
'F-0015: configured webhook to a permitted target still delivers (positive control)',
|
|
233
|
+
async () => {
|
|
234
|
+
sentinelHits = 0; okHits = 0
|
|
235
|
+
const e = await sdk.api.endusers.createOne({ email: `f0015-ok-${Date.now()}@example.com` })
|
|
236
|
+
const delivered = await wait_until(() => okHits > 0, 8_000)
|
|
237
|
+
await sdk.api.endusers.deleteOne(e.id).catch(() => {})
|
|
238
|
+
return { delivered, okHits, sentinelHits }
|
|
239
|
+
},
|
|
240
|
+
{ onResult: (r: any) => r.delivered === true && r.sentinelHits === 0 },
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
// Repoint the same configuration at the sentinel, keeping the subscription intact.
|
|
244
|
+
await sdk.api.webhooks.update({ url: sentinelURL })
|
|
245
|
+
|
|
246
|
+
await async_test(
|
|
247
|
+
'F-0015: configured webhook does not deliver to a private destination',
|
|
248
|
+
async () => {
|
|
249
|
+
sentinelHits = 0; okHits = 0
|
|
250
|
+
const e = await sdk.api.endusers.createOne({ email: `f0015-blocked-${Date.now()}@example.com` })
|
|
251
|
+
// Wait out the window a real delivery would have landed in, then assert it did not.
|
|
252
|
+
await settle(6_000)
|
|
253
|
+
await sdk.api.endusers.deleteOne(e.id).catch(() => {})
|
|
254
|
+
return { sentinelHits }
|
|
255
|
+
},
|
|
256
|
+
{ onResult: (r: any) => r.sentinelHits === 0 },
|
|
257
|
+
)
|
|
258
|
+
} finally {
|
|
259
|
+
// Leaving a webhook configured would fire on every subsequent create in the shared suite run.
|
|
260
|
+
if (configuredWebhook) {
|
|
261
|
+
try {
|
|
262
|
+
const { webhooks } = await sdk.api.webhooks.getSome({}) as any
|
|
263
|
+
for (const w of (webhooks ?? [])) { await sdk.api.webhooks.deleteOne(w.id).catch(() => {}) }
|
|
264
|
+
} catch {}
|
|
265
|
+
}
|
|
266
|
+
if (enduserId) { try { await sdk.api.endusers.deleteOne(enduserId) } catch {} }
|
|
267
|
+
if (templateEnduserId) { try { await sdk.api.endusers.deleteOne(templateEnduserId) } catch {} }
|
|
268
|
+
sentinel.close()
|
|
269
|
+
allowed.close()
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (require.main === module) {
|
|
274
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
275
|
+
const sdk = new Session({ host })
|
|
276
|
+
const sdkNonAdmin = new Session({ host })
|
|
277
|
+
|
|
278
|
+
const runTests = async () => {
|
|
279
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
280
|
+
await webhook_outbound_ssrf_tests({ sdk, sdkNonAdmin })
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
runTests()
|
|
284
|
+
.then(() => {
|
|
285
|
+
console.log("✅ F-0015 outbound webhook SSRF test suite completed successfully")
|
|
286
|
+
process.exit(0)
|
|
287
|
+
})
|
|
288
|
+
.catch((error) => {
|
|
289
|
+
console.error("❌ F-0015 outbound webhook SSRF test suite failed:", error)
|
|
290
|
+
process.exit(1)
|
|
291
|
+
})
|
|
292
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import http from "http"
|
|
4
|
+
import axios from "axios"
|
|
5
|
+
import { Session } from "../../../sdk"
|
|
6
|
+
import {
|
|
7
|
+
async_test,
|
|
8
|
+
log_header,
|
|
9
|
+
} from "@tellescope/testing"
|
|
10
|
+
import { setup_tests } from "../../setup"
|
|
11
|
+
|
|
12
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
13
|
+
|
|
14
|
+
const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '::1']
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 127.0.0.1 only means "the machine running this test" when the API is also local. Against staging or
|
|
18
|
+
* prod it is the API server itself — the sentinel below would never be contacted, every assertion
|
|
19
|
+
* here would pass for the wrong reason, and we would be probing a live environment. Fail loudly.
|
|
20
|
+
*/
|
|
21
|
+
const assert_local_api = () => {
|
|
22
|
+
const hostname = new URL(host).hostname
|
|
23
|
+
if (!LOCAL_HOSTNAMES.includes(hostname)) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`F-0025 SSRF tests must run against a local API (got ${host}). `
|
|
26
|
+
+ `127.0.0.1 would resolve to the API server rather than this machine, so the tests would be meaningless.`
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Regression tests for F-0025
|
|
33
|
+
* (security-audit/findings/F-0025-proxy-image-unauth-ssrf.md).
|
|
34
|
+
*
|
|
35
|
+
* GET /v1/proxy-image accepted an arbitrary ?url= and streamed the upstream response body straight
|
|
36
|
+
* back with Access-Control-Allow-Origin: *, with no authentication, no destination validation and no
|
|
37
|
+
* content-type restriction. That reaches http://169.254.169.254/ (EC2 instance credentials under
|
|
38
|
+
* IMDSv1), 127.0.0.1, and anything else inside the VPC.
|
|
39
|
+
*
|
|
40
|
+
* The endpoint's only legitimate job is to re-serve an image that ALREADY renders in the staff
|
|
41
|
+
* browser, with permissive CORS headers, so html2canvas can rasterize a form-response PDF without
|
|
42
|
+
* tainting the canvas (canvas.toDataURL() throws on a tainted canvas, and the form-response view sets
|
|
43
|
+
* crossOrigin="anonymous"). Anything reachable only from inside our network was therefore never a
|
|
44
|
+
* legitimate input — which is why refusing non-public destinations cannot regress the feature.
|
|
45
|
+
*
|
|
46
|
+
* Repro: stand up a local HTTP server that serves a JSON secret, point proxy-image at it with no
|
|
47
|
+
* credentials, and assert the secret does not come back.
|
|
48
|
+
* - red (unfixed): the sentinel body is streamed back to an unauthenticated caller
|
|
49
|
+
* - green (fixed): the request is refused and the sentinel server is never contacted
|
|
50
|
+
*
|
|
51
|
+
* Assertions are deliberately written as "4xx and nothing leaked" rather than pinning an exact status,
|
|
52
|
+
* because the fix both authenticates the endpoint and moves it to POST (the url moves into the body so
|
|
53
|
+
* presigned S3 URLs for patient attachments stop landing in access logs). Either 401 or 404/405 on the
|
|
54
|
+
* old GET shape is an acceptable outcome; streaming the body is not.
|
|
55
|
+
*
|
|
56
|
+
* The authenticated happy-path test lives with the Phase 2 endpoint change — it cannot exist here
|
|
57
|
+
* because the POST route does not yet exist, and a test that fails for "not implemented" would muddy
|
|
58
|
+
* the vulnerability proof.
|
|
59
|
+
*/
|
|
60
|
+
export const proxy_image_ssrf_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
61
|
+
log_header("F-0025: proxy-image unauthenticated SSRF")
|
|
62
|
+
assert_local_api()
|
|
63
|
+
|
|
64
|
+
const SENTINEL = `f0025-sentinel-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
65
|
+
let sentinelHits = 0
|
|
66
|
+
|
|
67
|
+
// Stands in for an internal-only service (IMDS, an internal admin API, a VPC-local port). Serves
|
|
68
|
+
// JSON rather than an image, so it also exercises the missing content-type restriction.
|
|
69
|
+
const sentinel = http.createServer((_, res) => {
|
|
70
|
+
sentinelHits++
|
|
71
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
72
|
+
res.end(JSON.stringify({ sentinel: SENTINEL, credentials: 'internal-only' }))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await new Promise<void>(resolve => sentinel.listen(0, '127.0.0.1', () => resolve()))
|
|
77
|
+
const sentinelPort = (sentinel.address() as any).port
|
|
78
|
+
const sentinelURL = `http://127.0.0.1:${sentinelPort}/latest/meta-data/iam/security-credentials/`
|
|
79
|
+
|
|
80
|
+
// Raw axios rather than the SDK: the SDK always attaches an Authorization header, and the point
|
|
81
|
+
// here is that no credential is presented at all.
|
|
82
|
+
const call_unauthenticated = async () => {
|
|
83
|
+
sentinelHits = 0
|
|
84
|
+
const res = await axios.get(`${host}/v1/proxy-image`, {
|
|
85
|
+
params: { url: sentinelURL },
|
|
86
|
+
validateStatus: () => true, // capture 4xx/5xx instead of throwing
|
|
87
|
+
responseType: 'text',
|
|
88
|
+
transformResponse: [(d: any) => d], // keep the raw body as a string
|
|
89
|
+
timeout: 10_000,
|
|
90
|
+
} as any)
|
|
91
|
+
|
|
92
|
+
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data ?? '')
|
|
93
|
+
return { status: res.status, leaked: body.includes(SENTINEL), sentinelHits }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// T1 — the core SSRF: an unauthenticated caller must not be able to read an internal resource.
|
|
97
|
+
await async_test(
|
|
98
|
+
'F-0025: proxy-image does not stream an internal non-image resource to an unauthenticated caller',
|
|
99
|
+
call_unauthenticated,
|
|
100
|
+
{
|
|
101
|
+
// Positive control: a numeric status proves we actually reached our API rather than failing to
|
|
102
|
+
// connect (a connection error would make "nothing leaked" true for the wrong reason).
|
|
103
|
+
onResult: (r: any) => (
|
|
104
|
+
typeof r.status === 'number'
|
|
105
|
+
&& r.leaked === false
|
|
106
|
+
&& r.sentinelHits === 0
|
|
107
|
+
),
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// T2 — the unauthenticated surface itself: the request must be refused outright, and the
|
|
112
|
+
// destination must never be contacted (no SSRF-as-a-side-effect even when the body is withheld).
|
|
113
|
+
await async_test(
|
|
114
|
+
'F-0025: proxy-image refuses an unauthenticated request without contacting the destination',
|
|
115
|
+
call_unauthenticated,
|
|
116
|
+
{ onResult: (r: any) => r.status >= 400 && r.status < 500 && r.sentinelHits === 0 },
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// Authenticating is not enough on its own — a staff session must not be able to reach an internal
|
|
120
|
+
// address either. These use the real session, and are deterministic (no external network).
|
|
121
|
+
const call_authenticated = async (url: string) => {
|
|
122
|
+
sentinelHits = 0
|
|
123
|
+
const res = await axios.post(`${host}/v1/proxy-image`, { url }, {
|
|
124
|
+
headers: { Authorization: `Bearer ${sdk.authToken}` },
|
|
125
|
+
validateStatus: () => true,
|
|
126
|
+
timeout: 10_000,
|
|
127
|
+
} as any)
|
|
128
|
+
return { status: res.status, sentinelHits }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await async_test(
|
|
132
|
+
'F-0025: proxy-image refuses a private destination even for an authenticated staff session',
|
|
133
|
+
() => call_authenticated(sentinelURL),
|
|
134
|
+
{ onResult: (r: any) => r.status === 400 && r.sentinelHits === 0 },
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
await async_test(
|
|
138
|
+
'F-0025: proxy-image refuses cloud metadata for an authenticated staff session',
|
|
139
|
+
() => call_authenticated('https://169.254.169.254/latest/meta-data/'),
|
|
140
|
+
{ onResult: (r: any) => r.status === 400 },
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
// Scheme and port are pinned: only https on 443, which is all the PDF export ever needs.
|
|
144
|
+
await async_test(
|
|
145
|
+
'F-0025: proxy-image refuses a plain http destination',
|
|
146
|
+
() => call_authenticated('http://example.com/logo.png'),
|
|
147
|
+
{ onResult: (r: any) => r.status === 400 },
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
await async_test(
|
|
151
|
+
'F-0025: proxy-image refuses a non-443 port',
|
|
152
|
+
() => call_authenticated('https://example.com:8443/logo.png'),
|
|
153
|
+
{ onResult: (r: any) => r.status === 400 },
|
|
154
|
+
)
|
|
155
|
+
} finally {
|
|
156
|
+
sentinel.close()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (require.main === module) {
|
|
161
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
162
|
+
const sdk = new Session({ host })
|
|
163
|
+
const sdkNonAdmin = new Session({ host })
|
|
164
|
+
|
|
165
|
+
const runTests = async () => {
|
|
166
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
167
|
+
await proxy_image_ssrf_tests({ sdk, sdkNonAdmin })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
runTests()
|
|
171
|
+
.then(() => {
|
|
172
|
+
console.log("✅ F-0025 proxy-image SSRF test suite completed successfully")
|
|
173
|
+
process.exit(0)
|
|
174
|
+
})
|
|
175
|
+
.catch((error) => {
|
|
176
|
+
console.error("❌ F-0025 proxy-image SSRF test suite failed:", error)
|
|
177
|
+
process.exit(1)
|
|
178
|
+
})
|
|
179
|
+
}
|