@tellescope/sdk 1.255.19 → 1.256.1
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/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/custom_dashboards.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/custom_dashboards.test.js +99 -8
- package/lib/cjs/tests/api_tests/custom_dashboards.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/default_summary_journey.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js +278 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/openloop_webhooks.test.js +212 -33
- package/lib/cjs/tests/api_tests/openloop_webhooks.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
- package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +242 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +443 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +231 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +524 -294
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -1
- 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 +477 -260
- package/lib/cjs/tests/tests.js.map +1 -1
- 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/custom_dashboards.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/custom_dashboards.test.js +99 -8
- package/lib/esm/tests/api_tests/custom_dashboards.test.js.map +1 -1
- package/lib/esm/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/default_summary_journey.test.js.map +1 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.js +248 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
- package/lib/esm/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/openloop_webhooks.test.js +213 -34
- package/lib/esm/tests/api_tests/openloop_webhooks.test.js.map +1 -1
- package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
- package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +238 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +436 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +224 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +524 -294
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -1
- 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 +477 -260
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/tests/api_tests/beluga_trigger_refill.test.ts +152 -0
- package/src/tests/api_tests/custom_dashboards.test.ts +92 -0
- package/src/tests/api_tests/file_download_unicode_names.test.ts +223 -0
- package/src/tests/api_tests/openloop_webhooks.test.ts +138 -0
- package/src/tests/api_tests/phone_calls_cold_transfer.test.ts +54 -0
- package/src/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.ts +215 -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/security/F-0166-allowed-paths-wildcard-bypass.test.ts +215 -0
- package/src/tests/api_tests/start_ai_conversation.test.ts +256 -0
- package/src/tests/tests.ts +220 -1
- package/test_generated.pdf +0 -0
|
@@ -6,11 +6,13 @@ import {
|
|
|
6
6
|
handleAnyError,
|
|
7
7
|
log_header,
|
|
8
8
|
} from "@tellescope/testing"
|
|
9
|
+
import { FULL_ACCESS, PROVIDER_PERMISSIONS } from "@tellescope/constants"
|
|
9
10
|
import { setup_tests } from "../setup"
|
|
10
11
|
|
|
11
12
|
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
12
13
|
|
|
13
14
|
const NONEXISTENT_MONGO_ID = '60398b1131a295e64f084fff'
|
|
15
|
+
const RAND = () => Math.random().toString(36).slice(2, 10)
|
|
14
16
|
|
|
15
17
|
// Validation-path tests for the cold transfer endpoint. All assertions below hit
|
|
16
18
|
// validations that run before any Twilio access, so no Twilio config is needed.
|
|
@@ -19,6 +21,7 @@ export const phone_calls_cold_transfer_tests = async ({ sdk, sdkNonAdmin } : { s
|
|
|
19
21
|
|
|
20
22
|
const enduser = await sdk.api.endusers.createOne({ fname: 'Cold', lname: 'Transfer' })
|
|
21
23
|
const targetUsers: { id: string }[] = []
|
|
24
|
+
const createdRoleIds: string[] = []
|
|
22
25
|
|
|
23
26
|
try {
|
|
24
27
|
const inboundCall = await sdk.api.phone_calls.createOne({
|
|
@@ -81,11 +84,62 @@ export const phone_calls_cold_transfer_tests = async ({ sdk, sdkNonAdmin } : { s
|
|
|
81
84
|
() => sdkNonAdmin.api.phone_calls.cold_transfer({ callSid: inboundCall.externalId, targetUserId: disabledTargetUser.id }),
|
|
82
85
|
handleAnyError
|
|
83
86
|
)
|
|
87
|
+
|
|
88
|
+
// ── target-user lookup must be gated by users READ access, not the endpoint's update access ──
|
|
89
|
+
// PROVIDER_PERMISSIONS already models the reported bug: users read='All', update='Default'.
|
|
90
|
+
// phone_calls gets FULL_ACCESS so the call itself is visible under update-level filtering.
|
|
91
|
+
const mkRoleUser = async (permissions: any) => {
|
|
92
|
+
const role = `cold-transfer-${RAND()}`
|
|
93
|
+
const rbap = await sdk.api.role_based_access_permissions.createOne({ role, permissions })
|
|
94
|
+
createdRoleIds.push(rbap.id)
|
|
95
|
+
|
|
96
|
+
const user = await sdk.api.users.createOne({
|
|
97
|
+
email: `cold.transfer.${RAND()}@tellescope.example`,
|
|
98
|
+
fname: 'Cold', lname: 'TransferRole',
|
|
99
|
+
notificationEmailsDisabled: true, verifiedEmail: true,
|
|
100
|
+
roles: [role],
|
|
101
|
+
} as any)
|
|
102
|
+
targetUsers.push(user)
|
|
103
|
+
|
|
104
|
+
const { authToken, user: sessionUser } = await sdk.api.users.generate_auth_token({ id: user.id })
|
|
105
|
+
const session = new Session({ host })
|
|
106
|
+
session.setAuthToken(authToken)
|
|
107
|
+
session.setUserInfo(sessionUser as any)
|
|
108
|
+
return session
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const sdkUpdateDefault = await mkRoleUser({ ...PROVIDER_PERMISSIONS, phone_calls: FULL_ACCESS })
|
|
112
|
+
const sdkReadDefault = await mkRoleUser({
|
|
113
|
+
...PROVIDER_PERMISSIONS,
|
|
114
|
+
phone_calls: FULL_ACCESS,
|
|
115
|
+
users: { create: null, read: 'Default', update: 'Default', delete: null },
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// regression: previously failed with "Target user not found" because the lookup was
|
|
119
|
+
// filtered by users update='Default' (self-only); getting past it to the
|
|
120
|
+
// incoming-calls-disabled validation proves the lookup now uses read access
|
|
121
|
+
await async_test(
|
|
122
|
+
"cold_transfer: users update=Default no longer blocks the target lookup",
|
|
123
|
+
() => sdkUpdateDefault.api.phone_calls.cold_transfer({ callSid: inboundCall.externalId, targetUserId: disabledTargetUser.id }),
|
|
124
|
+
{ shouldError: true, onError: (e: any) => e.message === "Target user has incoming calls disabled" }
|
|
125
|
+
)
|
|
126
|
+
// guard: the lookup is read-filtered (not org-wide) — users read='Default' still can't see the target
|
|
127
|
+
await async_test(
|
|
128
|
+
"cold_transfer: users read=Default still hides the target user",
|
|
129
|
+
() => sdkReadDefault.api.phone_calls.cold_transfer({ callSid: inboundCall.externalId, targetUserId: disabledTargetUser.id }),
|
|
130
|
+
{ shouldError: true, onError: (e: any) => e.message === "Target user not found" }
|
|
131
|
+
)
|
|
132
|
+
await async_test(
|
|
133
|
+
"cold_transfer: nonexistent target still rejected for restricted role",
|
|
134
|
+
() => sdkUpdateDefault.api.phone_calls.cold_transfer({ callSid: inboundCall.externalId, targetUserId: NONEXISTENT_MONGO_ID }),
|
|
135
|
+
{ shouldError: true, onError: (e: any) => e.message === "Target user not found" }
|
|
136
|
+
)
|
|
84
137
|
} finally {
|
|
85
138
|
// deleting the enduser cascades deletion of its phone_calls records
|
|
86
139
|
await Promise.all([
|
|
87
140
|
sdk.api.endusers.deleteOne(enduser.id),
|
|
88
141
|
...targetUsers.map(u => sdk.api.users.deleteOne(u.id)),
|
|
142
|
+
...createdRoleIds.map(id => sdk.api.role_based_access_permissions.deleteOne(id)),
|
|
89
143
|
]).catch(console.error)
|
|
90
144
|
}
|
|
91
145
|
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { DEFAULT_IFRAME_SANDBOX, replace_form_field_template_values, sanitize_user_html, sanitize_user_html_with_iframes } from "@tellescope/utilities"
|
|
2
|
+
|
|
3
|
+
// Companion to F-0013-sanitize-user-html.test.ts (pattern 06 — XSS via dangerouslySetInnerHTML).
|
|
4
|
+
//
|
|
5
|
+
// sanitize_user_html_with_iframes is the variant used by the live form's field description, so
|
|
6
|
+
// admins can embed video / scheduling widgets. It permits <iframe> and NOTHING else beyond what
|
|
7
|
+
// sanitize_user_html allows. This asserts:
|
|
8
|
+
// 1. permitting iframe did not reopen any of the F-0013 vectors (the full payload list is re-run)
|
|
9
|
+
// 2. only absolute https iframes survive — relative, protocol-relative, http, javascript:, data:
|
|
10
|
+
// and srcdoc frames are dropped, along with their fallback content
|
|
11
|
+
// 3. the restrictive sandbox is forced, but an author-supplied sandbox is honored
|
|
12
|
+
// 4. sanitize_user_html itself still strips iframes (the boundary between the two functions)
|
|
13
|
+
//
|
|
14
|
+
// Pure-function test — no Session needed. Runs as part of the main suite and standalone:
|
|
15
|
+
// ./build_cjs.sh && cd packages/public/sdk && node -r dotenv/config lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js
|
|
16
|
+
|
|
17
|
+
const fail = (msg: string) => { throw new Error(msg) }
|
|
18
|
+
|
|
19
|
+
// Same as has_no_executable_vector in the F-0013 test, minus the '<iframe' clause (iframes are
|
|
20
|
+
// expected in this function's output). Everything else must still be neutralized.
|
|
21
|
+
const has_no_executable_vector_allowing_iframe = (out: string) => {
|
|
22
|
+
const o = out.toLowerCase()
|
|
23
|
+
const withoutValues = o.replace(/"[^"]*"/g, '""').replace(/'[^']*'/g, "''")
|
|
24
|
+
return !/\son[a-z]+\s*=/.test(withoutValues) // no live on*= event-handler attribute
|
|
25
|
+
&& !o.includes('javascript:')
|
|
26
|
+
&& !o.includes('vbscript:')
|
|
27
|
+
&& !o.includes('<script')
|
|
28
|
+
&& !o.includes('<svg')
|
|
29
|
+
&& !o.includes('<math')
|
|
30
|
+
&& !o.includes('<object')
|
|
31
|
+
&& !o.includes('<embed')
|
|
32
|
+
&& !o.includes('<form')
|
|
33
|
+
&& !o.includes('<noscript')
|
|
34
|
+
&& !o.includes('<template')
|
|
35
|
+
&& !o.includes('srcdoc') // an inline document is arbitrary markup
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Tokens that must NEVER appear in the forced default. allow-top-navigation is the important one:
|
|
39
|
+
// it is what stops a hostile embed from redirecting the patient's form page to a phishing site.
|
|
40
|
+
const FORBIDDEN_SANDBOX_TOKENS = [
|
|
41
|
+
'allow-top-navigation',
|
|
42
|
+
'allow-downloads',
|
|
43
|
+
'allow-modals',
|
|
44
|
+
'allow-pointer-lock',
|
|
45
|
+
'allow-orientation-lock',
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
export const sanitize_user_html_with_iframes_tests = async () => {
|
|
49
|
+
console.log("Running sanitize_user_html_with_iframes XSS regression tests")
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------------------------
|
|
52
|
+
// 1. Re-run the full F-0013 payload set — permitting iframe must not be a general regression
|
|
53
|
+
// ---------------------------------------------------------------------------------------------
|
|
54
|
+
const xssPayloads: [string, string][] = [
|
|
55
|
+
['img onerror', `<img src=x onerror="alert(document.domain)">`],
|
|
56
|
+
['svg onload', `<svg onload="alert(1)"></svg>`],
|
|
57
|
+
['svg animate onbegin', `<svg><animate onbegin="alert(1)" attributeName="x" dur="1s"></svg>`],
|
|
58
|
+
['details ontoggle', `<details open ontoggle="alert(1)"></details>`],
|
|
59
|
+
['input onfocus autofocus', `<input autofocus onfocus="alert(1)">`],
|
|
60
|
+
['body onpageshow', `<body onpageshow="alert(1)">`],
|
|
61
|
+
['a javascript scheme', `<a href="javascript:alert(1)">x</a>`],
|
|
62
|
+
['a javascript entity-encoded', `<a href="jav	ascript:alert(1)">x</a>`],
|
|
63
|
+
['script tag', `<script>alert(1)</script>`],
|
|
64
|
+
['onerror newline before =', `<img src=x onerror\n="alert(1)">`],
|
|
65
|
+
['onerror mixed case', `<IMG SRC=x OnErRoR="alert(1)">`],
|
|
66
|
+
['marquee onstart', `<marquee onstart="alert(1)">x</marquee>`],
|
|
67
|
+
['mathml mglyph style mxss', `<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>`],
|
|
68
|
+
['svg foreignObject', `<svg><foreignObject><img src=x onerror=alert(1)></foreignObject></svg>`],
|
|
69
|
+
['noscript context confusion', `<noscript><p title="</noscript><img src=x onerror=alert(1)>">`],
|
|
70
|
+
['template content', `<template><img src=x onerror=alert(1)></template>`],
|
|
71
|
+
['comment confusion', `<!--><img src=x onerror=alert(1)>-->`],
|
|
72
|
+
['cdata confusion', `<![CDATA[<img src=x onerror=alert(1)>]]>`],
|
|
73
|
+
['markup inside attr value', `<img src="x" alt="<script>alert(1)</script>">`],
|
|
74
|
+
['vbscript scheme', `<a href="vbscript:msgbox(1)">x</a>`],
|
|
75
|
+
['data text/html href', `<a href="data:text/html,<script>alert(1)</script>">x</a>`],
|
|
76
|
+
['javascript decimal entity', `<a href="Javascript:alert(1)">x</a>`],
|
|
77
|
+
['javascript newline entity', `<a href="jav
ascript:alert(1)">x</a>`],
|
|
78
|
+
]
|
|
79
|
+
for (const [name, payload] of xssPayloads) {
|
|
80
|
+
const out = sanitize_user_html_with_iframes(payload)
|
|
81
|
+
if (!has_no_executable_vector_allowing_iframe(out)) fail(`XSS not neutralized [${name}] -> ${out}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------------------------
|
|
85
|
+
// 2. Only absolute https iframes survive
|
|
86
|
+
// ---------------------------------------------------------------------------------------------
|
|
87
|
+
const badSrcs: [string, string][] = [
|
|
88
|
+
['javascript scheme', `javascript:alert(1)`],
|
|
89
|
+
['data text/html', `data:text/html,<script>alert(1)</script>`],
|
|
90
|
+
['http (not https)', `http://evil.example`],
|
|
91
|
+
// sanitize-html does NOT reject protocol-relative iframe srcs on its own when
|
|
92
|
+
// allowProtocolRelative is set — this is the self-origin framing vector and the reason the
|
|
93
|
+
// sanitizer does its own absolute-https check.
|
|
94
|
+
['protocol-relative', `//app.tellescope.com/`],
|
|
95
|
+
['root-relative', `/portal/settings`],
|
|
96
|
+
['bare relative', `settings`],
|
|
97
|
+
['whitespace-obfuscated', ` https:/\\/\\evil`],
|
|
98
|
+
['tab-entity javascript', `jav	ascript:alert(1)`],
|
|
99
|
+
['scheme-only', `https://`],
|
|
100
|
+
['empty', ``],
|
|
101
|
+
]
|
|
102
|
+
for (const [name, src] of badSrcs) {
|
|
103
|
+
const out = sanitize_user_html_with_iframes(`<iframe src="${src}"></iframe>`)
|
|
104
|
+
if (out.toLowerCase().includes('<iframe')) fail(`bad iframe src survived [${name}] -> ${out}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A dropped iframe must take its fallback content with it (exclusiveFilter, not a tagName swap)
|
|
108
|
+
const fallback = sanitize_user_html_with_iframes(`<iframe src="/x">FALLBACK-TEXT</iframe>`)
|
|
109
|
+
if (fallback.includes('FALLBACK-TEXT')) fail(`dropped iframe leaked its fallback text: ${fallback}`)
|
|
110
|
+
if (fallback.toLowerCase().includes('<iframe')) fail(`dropped iframe survived: ${fallback}`)
|
|
111
|
+
|
|
112
|
+
// srcdoc is not allowlisted, even alongside a valid src
|
|
113
|
+
const srcdoc = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" srcdoc="<img src=x onerror=alert(1)>"></iframe>`)
|
|
114
|
+
if (srcdoc.toLowerCase().includes('srcdoc')) fail(`srcdoc not stripped: ${srcdoc}`)
|
|
115
|
+
if (!srcdoc.toLowerCase().includes('<iframe')) fail(`valid iframe dropped alongside srcdoc: ${srcdoc}`)
|
|
116
|
+
|
|
117
|
+
// handlers / clobbering attributes stripped, frame itself kept
|
|
118
|
+
const handlers = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" onload="alert(1)" id="x" name="getElementById"></iframe>`)
|
|
119
|
+
if (!handlers.toLowerCase().includes('<iframe')) fail(`valid iframe dropped: ${handlers}`)
|
|
120
|
+
if (/\son[a-z]+\s*=/.test(handlers.toLowerCase())) fail(`iframe event handler survived: ${handlers}`)
|
|
121
|
+
if (/\b(id|name)\s*=/.test(handlers)) fail(`iframe id/name not stripped (DOM clobbering): ${handlers}`)
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------------------------
|
|
124
|
+
// 3. Legitimate embeds survive, hardened
|
|
125
|
+
// ---------------------------------------------------------------------------------------------
|
|
126
|
+
// The exact shape draftjs-to-html emits for the WYSIWYG "embedded" button
|
|
127
|
+
const yt = sanitize_user_html_with_iframes(`<iframe width="560" height="315" src="https://www.youtube.com/embed/abc" frameBorder="0"></iframe>`)
|
|
128
|
+
if (!yt.includes('src="https://www.youtube.com/embed/abc"')) fail(`youtube embed src stripped: ${yt}`)
|
|
129
|
+
if (!(yt.includes('width="560"') && yt.includes('height="315"'))) fail(`iframe dimensions stripped: ${yt}`)
|
|
130
|
+
if (!yt.toLowerCase().includes('frameborder')) fail(`frameborder stripped: ${yt}`)
|
|
131
|
+
|
|
132
|
+
// forced containment
|
|
133
|
+
if (!yt.includes(`sandbox="${DEFAULT_IFRAME_SANDBOX}"`)) fail(`sandbox not forced: ${yt}`)
|
|
134
|
+
if (!yt.toLowerCase().includes('referrerpolicy=')) fail(`referrerpolicy not set: ${yt}`)
|
|
135
|
+
for (const token of FORBIDDEN_SANDBOX_TOKENS) {
|
|
136
|
+
if (DEFAULT_IFRAME_SANDBOX.includes(token)) fail(`forced sandbox must not grant ${token}: ${DEFAULT_IFRAME_SANDBOX}`)
|
|
137
|
+
}
|
|
138
|
+
// allow-same-origin IS granted — required for real embeds (a Loom embed renders blank without it)
|
|
139
|
+
// and harmless because the src is guaranteed cross-origin by the check asserted above.
|
|
140
|
+
if (!DEFAULT_IFRAME_SANDBOX.includes('allow-same-origin')) fail(`forced sandbox must grant allow-same-origin so real embeds render: ${DEFAULT_IFRAME_SANDBOX}`)
|
|
141
|
+
|
|
142
|
+
// author-supplied sandbox honored verbatim, so a surface can tighten (or loosen) per embed.
|
|
143
|
+
// Uses a value distinct from the default, so this can't pass by coincidence.
|
|
144
|
+
const authorSandbox = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" sandbox="allow-scripts"></iframe>`)
|
|
145
|
+
if (!authorSandbox.includes('sandbox="allow-scripts"')) fail(`author sandbox not honored: ${authorSandbox}`)
|
|
146
|
+
|
|
147
|
+
// sandbox="" is the MOST restrictive value and must not be replaced by the default
|
|
148
|
+
const emptySandbox = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" sandbox=""></iframe>`)
|
|
149
|
+
if (emptySandbox.includes('allow-')) fail(`empty sandbox was replaced with the permissive default: ${emptySandbox}`)
|
|
150
|
+
if (!/\ssandbox(\s|>)/.test(emptySandbox)) fail(`empty sandbox attribute lost entirely: ${emptySandbox}`)
|
|
151
|
+
|
|
152
|
+
// author referrerpolicy honored
|
|
153
|
+
const noref = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" referrerpolicy="no-referrer"></iframe>`)
|
|
154
|
+
if (!noref.includes('referrerpolicy="no-referrer"')) fail(`author referrerpolicy not honored: ${noref}`)
|
|
155
|
+
|
|
156
|
+
// `allow` (Permissions-Policy delegation, e.g. YouTube autoplay) preserved
|
|
157
|
+
const allowAttr = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" allow="autoplay; encrypted-media" allowfullscreen></iframe>`)
|
|
158
|
+
if (!allowAttr.includes('allow="autoplay; encrypted-media"')) fail(`allow attribute stripped: ${allowAttr}`)
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------------------------
|
|
161
|
+
// 4. Everything sanitize_user_html preserves must still be preserved
|
|
162
|
+
// ---------------------------------------------------------------------------------------------
|
|
163
|
+
const heading = sanitize_user_html_with_iframes(`<h1>Welcome</h1><h3 style="color:#333">Sub</h3>`)
|
|
164
|
+
if (!(heading.includes('<h1>') && heading.includes('<h3') && heading.toLowerCase().includes('color'))) fail(`headings/style stripped: ${heading}`)
|
|
165
|
+
|
|
166
|
+
const table = sanitize_user_html_with_iframes(`<table><thead><tr><th>H</th></tr></thead><tbody><tr><td style="padding:4px" colspan="2">cell</td></tr></tbody></table>`)
|
|
167
|
+
if (!(table.includes('<table') && table.includes('<td') && table.includes('colspan'))) fail(`table stripped: ${table}`)
|
|
168
|
+
|
|
169
|
+
const list = sanitize_user_html_with_iframes(`<ul><li>a</li></ul><ol start="3"><li>c</li></ol>`)
|
|
170
|
+
if (!(list.includes('<ul') && list.includes('<li') && list.includes('<ol'))) fail(`list stripped: ${list}`)
|
|
171
|
+
|
|
172
|
+
const link = sanitize_user_html_with_iframes(`<a href="https://example.com">link</a>`)
|
|
173
|
+
if (!link.includes('href="https://example.com"')) fail(`safe link stripped: ${link}`)
|
|
174
|
+
if (!link.toLowerCase().includes('noopener')) fail(`external link not hardened: ${link}`)
|
|
175
|
+
|
|
176
|
+
const img = sanitize_user_html_with_iframes(`<img src="https://cdn.example.com/a.png" alt="pic" width="200">`)
|
|
177
|
+
if (!(img.includes('src="https://cdn.example.com/a.png"') && img.includes('alt="pic"'))) fail(`image stripped: ${img}`)
|
|
178
|
+
|
|
179
|
+
const dataimg = sanitize_user_html_with_iframes(`<img src="data:image/png;base64,iVBORw0KGgo=">`)
|
|
180
|
+
if (!dataimg.includes('data:image/png')) fail(`data: image stripped: ${dataimg}`)
|
|
181
|
+
|
|
182
|
+
const fmt = sanitize_user_html_with_iframes(`<p><strong>b</strong> <em>i</em> <span style="font-size:14px">s</span></p><blockquote>q</blockquote>`)
|
|
183
|
+
if (!(fmt.includes('<strong>') && fmt.includes('<span') && fmt.toLowerCase().includes('font-size'))) fail(`formatting stripped: ${fmt}`)
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------------------------
|
|
186
|
+
// 5. The boundary: sanitize_user_html must NOT have been loosened
|
|
187
|
+
// ---------------------------------------------------------------------------------------------
|
|
188
|
+
const shared = sanitize_user_html(`<iframe src="https://www.youtube.com/embed/abc"></iframe>`)
|
|
189
|
+
if (shared.toLowerCase().includes('<iframe')) fail(`sanitize_user_html must still strip iframes: ${shared}`)
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------------------------
|
|
192
|
+
// 6. End to end: an admin's iframe survives templating, a patient-injected one does not
|
|
193
|
+
// ---------------------------------------------------------------------------------------------
|
|
194
|
+
const adminTemplate = `<p>Hi {{enduser.fname}}, watch this:</p><iframe src="https://www.youtube.com/embed/abc"></iframe>`
|
|
195
|
+
const templated = replace_form_field_template_values(adminTemplate, {
|
|
196
|
+
enduser: { fname: `<iframe src="https://evil.example/phish"></iframe>` } as any,
|
|
197
|
+
responses: [],
|
|
198
|
+
escapeNewlinesAsHTMLBreaks: true,
|
|
199
|
+
escapeHTMLValues: true,
|
|
200
|
+
})
|
|
201
|
+
const endToEnd = sanitize_user_html_with_iframes(templated)
|
|
202
|
+
if (!endToEnd.includes('src="https://www.youtube.com/embed/abc"')) fail(`admin's own iframe did not survive: ${endToEnd}`)
|
|
203
|
+
// The patient's markup must survive only as inert escaped text — so 'evil.example' may still
|
|
204
|
+
// appear as visible characters, but there must be exactly one LIVE frame (the admin's).
|
|
205
|
+
if (!endToEnd.includes('<iframe')) fail(`patient markup not escaped to inert text: ${endToEnd}`)
|
|
206
|
+
if ((endToEnd.toLowerCase().match(/<iframe/g) || []).length !== 1) fail(`patient-injected iframe became a live frame: ${endToEnd}`)
|
|
207
|
+
|
|
208
|
+
console.log("✅ sanitize_user_html_with_iframes XSS regression tests passed")
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (require.main === module) {
|
|
212
|
+
sanitize_user_html_with_iframes_tests()
|
|
213
|
+
.then(() => { console.log("✅ suite completed"); process.exit(0) })
|
|
214
|
+
.catch((err) => { console.error("❌ suite failed:", err); process.exit(1) })
|
|
215
|
+
}
|
|
@@ -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
|
+
}
|