@tellescope/sdk 1.255.19 → 1.256.0
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/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/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +431 -230
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/enduser.d.ts +0 -1
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/sdk.d.ts +2 -3
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/session.d.ts +0 -1
- package/lib/esm/session.d.ts.map +1 -1
- 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/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +431 -230
- 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/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-0166-allowed-paths-wildcard-bypass.test.ts +215 -0
- package/src/tests/tests.ts +212 -1
- package/test_generated.pdf +0 -0
|
@@ -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
|
+
}
|
|
@@ -146,6 +146,8 @@ export const allowed_paths_confinement_tests = async ({ sdk } : { sdk: Session,
|
|
|
146
146
|
let joinEnduserId: string | undefined
|
|
147
147
|
let formEnduserEmail: string | undefined
|
|
148
148
|
let hostUserId: string | undefined
|
|
149
|
+
let templateId: string | undefined
|
|
150
|
+
let bookingEnduserId: string | undefined
|
|
149
151
|
const eventIds: string[] = []
|
|
150
152
|
|
|
151
153
|
try {
|
|
@@ -245,6 +247,120 @@ export const allowed_paths_confinement_tests = async ({ sdk } : { sdk: Session,
|
|
|
245
247
|
// bare '/product' prefix entry did — that used to reach /products/prepare-stripe-checkout too
|
|
246
248
|
await expectDenied(S1, 'exact matching: /products not granted by get:/product', publicFormSession.authToken, baselineEnduserToken, 'get', '/products')
|
|
247
249
|
|
|
250
|
+
/* ---- field confinement on the granted generic update route ----
|
|
251
|
+
* 'patch:/enduser/:id' is granted for Portal V2's terms gate, so the route check alone no longer
|
|
252
|
+
* protects this session — SESSION_SCOPE_WRITABLE_FIELDS does. These probes are the live control:
|
|
253
|
+
* the consent write lands, and every contactable/identity field is refused. Without them the
|
|
254
|
+
* module test is the only thing standing between a scope-table edit and account takeover.
|
|
255
|
+
*/
|
|
256
|
+
const formEnduserId = publicFormSession.enduserId
|
|
257
|
+
|
|
258
|
+
// The refresh payload for a fromPublicSession token must carry the session's OWN id — portal V2
|
|
259
|
+
// seeds its store from it and then addresses itself by id, so without it every write goes to
|
|
260
|
+
// /enduser/undefined — while still withholding everything else about the patient. Both halves
|
|
261
|
+
// matter: adding the id must not become a foothold for widening this payload.
|
|
262
|
+
const publicRefresh = await request(publicFormSession.authToken, 'post', '/refresh-enduser-session', {})
|
|
263
|
+
const refreshedEnduser = publicRefresh.body?.enduser ?? {}
|
|
264
|
+
probes.push({
|
|
265
|
+
scenario: S1,
|
|
266
|
+
label: 'public refresh exposes the session\'s own id',
|
|
267
|
+
path: '/refresh-enduser-session',
|
|
268
|
+
expected: 'allowed',
|
|
269
|
+
status: refreshedEnduser.id === formEnduserId ? 200 : 500,
|
|
270
|
+
pass: refreshedEnduser.id === formEnduserId,
|
|
271
|
+
note: `id=${refreshedEnduser.id}`,
|
|
272
|
+
})
|
|
273
|
+
const withheld = ['email', 'phone', 'dateOfBirth', 'fname', 'lname', 'note', 'insurance', 'tags']
|
|
274
|
+
.filter(f => refreshedEnduser[f] !== undefined)
|
|
275
|
+
probes.push({
|
|
276
|
+
scenario: S1,
|
|
277
|
+
label: 'public refresh still withholds the patient record',
|
|
278
|
+
path: '/refresh-enduser-session',
|
|
279
|
+
expected: 'denied',
|
|
280
|
+
status: withheld.length ? 200 : 403,
|
|
281
|
+
pass: withheld.length === 0,
|
|
282
|
+
note: withheld.length ? `LEAKED [${withheld}]` : `keys=[${Object.keys(refreshedEnduser)}]`,
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
await expectAllowed(
|
|
286
|
+
S1, 'field-restricted: may record terms consent', publicFormSession.authToken,
|
|
287
|
+
'patch', `/enduser/${formEnduserId}`,
|
|
288
|
+
{ updates: { termsSigned: new Date().toISOString(), termsVersion: '1' } },
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
// The write being confined is not enough: the update RESPONSE is not redacted for enduser sessions
|
|
292
|
+
// (applyRedactions runs only for user sessions on that path), so an unprojected echo would hand
|
|
293
|
+
// this session the record it can't otherwise read — GET /enduser/:id is not even in this scope.
|
|
294
|
+
// Assert the response carries ONLY what the session was allowed to write.
|
|
295
|
+
const consentEcho = await request(
|
|
296
|
+
publicFormSession.authToken, 'patch', `/enduser/${formEnduserId}`,
|
|
297
|
+
{ updates: { termsVersion: '2' } },
|
|
298
|
+
)
|
|
299
|
+
const echoedKeys = Object.keys(consentEcho.body ?? {})
|
|
300
|
+
const leakedKeys = echoedKeys.filter(k => !['id', 'termsSigned', 'termsVersion'].includes(k))
|
|
301
|
+
probes.push({
|
|
302
|
+
scenario: S1,
|
|
303
|
+
label: 'field-restricted: update response echoes only the writable fields',
|
|
304
|
+
path: `/enduser/${formEnduserId}`,
|
|
305
|
+
expected: 'allowed',
|
|
306
|
+
status: leakedKeys.length === 0 ? consentEcho.status : 500,
|
|
307
|
+
pass: consentEcho.status === 200 && leakedKeys.length === 0,
|
|
308
|
+
note: leakedKeys.length ? `LEAKED [${leakedKeys}]` : `echoed [${echoedKeys}]`,
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// email/phone are the takeover vector: password reset resolves by findOne({ email }) and OTP codes
|
|
312
|
+
// deliver to enduser.phone. dateOfBirth/fname are the public-form MATCH keys, so writing them would
|
|
313
|
+
// let a session redirect which patient a later submission attaches to.
|
|
314
|
+
const fieldsThatMustBeRefused: [string, any][] = [
|
|
315
|
+
['email', `f0166-takeover-${RAND()}@tellescope.example`],
|
|
316
|
+
['phone', '+15555550123'],
|
|
317
|
+
['landline', '+15555550124'],
|
|
318
|
+
['dateOfBirth', '1990-01-01'],
|
|
319
|
+
['fname', 'F0166Overwritten'],
|
|
320
|
+
]
|
|
321
|
+
for (const [field, value] of fieldsThatMustBeRefused) {
|
|
322
|
+
const r = await request(
|
|
323
|
+
publicFormSession.authToken, 'patch', `/enduser/${formEnduserId}`, { updates: { [field]: value } },
|
|
324
|
+
)
|
|
325
|
+
probes.push({
|
|
326
|
+
scenario: S1,
|
|
327
|
+
label: `field-restricted: cannot write enduser '${field}'`,
|
|
328
|
+
path: `/enduser/${formEnduserId}`,
|
|
329
|
+
expected: 'denied',
|
|
330
|
+
status: r.status,
|
|
331
|
+
pass: r.status === 403,
|
|
332
|
+
note: 'route is granted; the field allowlist is what refuses this',
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// the field guard must reject the whole request rather than filtering, or the allowed key would
|
|
337
|
+
// carry a disallowed one through alongside it
|
|
338
|
+
const mixed = await request(
|
|
339
|
+
publicFormSession.authToken, 'patch', `/enduser/${formEnduserId}`,
|
|
340
|
+
{ updates: { termsVersion: '2', email: `f0166-mixed-${RAND()}@tellescope.example` } },
|
|
341
|
+
)
|
|
342
|
+
probes.push({
|
|
343
|
+
scenario: S1,
|
|
344
|
+
label: 'field-restricted: a mixed allowed+denied update is refused whole',
|
|
345
|
+
path: `/enduser/${formEnduserId}`,
|
|
346
|
+
expected: 'denied',
|
|
347
|
+
status: mixed.status,
|
|
348
|
+
pass: mixed.status === 403,
|
|
349
|
+
note: 'no partial application — email must not ride along with termsVersion',
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// and the email on record must be untouched by all of the above
|
|
353
|
+
const afterProbes = await sdk.api.endusers.getOne(formEnduserId)
|
|
354
|
+
probes.push({
|
|
355
|
+
scenario: S1,
|
|
356
|
+
label: 'field-restricted: stored email survived every attempt',
|
|
357
|
+
path: `/enduser/${formEnduserId}`,
|
|
358
|
+
expected: 'denied',
|
|
359
|
+
status: afterProbes.email === formEnduserEmail ? 403 : 200,
|
|
360
|
+
pass: afterProbes.email === formEnduserEmail,
|
|
361
|
+
note: `stored=${afterProbes.email}`,
|
|
362
|
+
})
|
|
363
|
+
|
|
248
364
|
/* ============ S2: join-link session — RED before the fix ============ */
|
|
249
365
|
|
|
250
366
|
const S2 = 'S2 join-link (enduser)'
|
|
@@ -264,6 +380,36 @@ export const allowed_paths_confinement_tests = async ({ sdk } : { sdk: Session,
|
|
|
264
380
|
await expectAllowed(S2, 'widened: calendar events list', joinSession.authToken, 'get', '/calendar-events')
|
|
265
381
|
await expectAllowed(S2, 'widened: join meeting for event', joinSession.authToken, 'post', '/join-meeting-for-event', { calendarEventId: joinEvent.id })
|
|
266
382
|
|
|
383
|
+
// The WaitingRoom marks attendance here. This write is deliberately NOT field-restricted: the
|
|
384
|
+
// portal reads the event back through useCalendarEvents, whose updateElement REPLACES the local
|
|
385
|
+
// record with the update response (react state.tsx), so a truncated response would drop event
|
|
386
|
+
// fields out of local state mid-visit and break a live Twilio call. Assert both halves of that
|
|
387
|
+
// contract — the write lands, AND the response is still a whole event.
|
|
388
|
+
const attendanceWrite = await request(
|
|
389
|
+
joinSession.authToken, 'patch', `/calendar-event/${joinEvent.id}`,
|
|
390
|
+
{ updates: { joinedVideoCall: [{ id: joinEnduserId, at: new Date().toISOString() }] } },
|
|
391
|
+
)
|
|
392
|
+
probes.push({
|
|
393
|
+
scenario: S2,
|
|
394
|
+
label: 'marks joinedVideoCall on its pinned event',
|
|
395
|
+
path: `/calendar-event/${joinEvent.id}`,
|
|
396
|
+
expected: 'allowed',
|
|
397
|
+
status: attendanceWrite.status,
|
|
398
|
+
pass: attendanceWrite.status === 200,
|
|
399
|
+
})
|
|
400
|
+
// title/durationInMinutes were set at fixture creation, so their presence proves the response was
|
|
401
|
+
// not projected down to the written field
|
|
402
|
+
const echoedEvent = attendanceWrite.body ?? {}
|
|
403
|
+
probes.push({
|
|
404
|
+
scenario: S2,
|
|
405
|
+
label: 'attendance write returns the WHOLE event (client replaces local state from it)',
|
|
406
|
+
path: `/calendar-event/${joinEvent.id}`,
|
|
407
|
+
expected: 'allowed',
|
|
408
|
+
status: (echoedEvent.title && echoedEvent.durationInMinutes) ? 200 : 500,
|
|
409
|
+
pass: !!echoedEvent.title && !!echoedEvent.durationInMinutes && !!echoedEvent.joinedVideoCall,
|
|
410
|
+
note: `echoed ${Object.keys(echoedEvent).length} keys`,
|
|
411
|
+
})
|
|
412
|
+
|
|
267
413
|
// confinement must survive a refresh rather than widening
|
|
268
414
|
const refreshedJoin = await request(joinSession.authToken, 'post', '/refresh-enduser-session', {})
|
|
269
415
|
const refreshedJoinToken = refreshedJoin.body?.authToken
|
|
@@ -398,6 +544,73 @@ export const allowed_paths_confinement_tests = async ({ sdk } : { sdk: Session,
|
|
|
398
544
|
await expectAllowed(S6, 'after refresh: my-meetings still allowed', rescopedToken, 'get', '/my-meetings')
|
|
399
545
|
}
|
|
400
546
|
|
|
547
|
+
/* ============ S7: appointment-booking session — the SAME terms gate as S1 ============
|
|
548
|
+
* Portal V2's terms gate wraps the booking route too, so this scope needs the same enduser write
|
|
549
|
+
* (and only that write). Without the grant the gate is unpassable and the patient never reaches the
|
|
550
|
+
* booking page at all. Field restriction and response projection must behave exactly as in S1 —
|
|
551
|
+
* mirrored here rather than assumed, since the two scopes are separate table entries.
|
|
552
|
+
*/
|
|
553
|
+
const S7 = 'S7 appointment-booking'
|
|
554
|
+
|
|
555
|
+
const bookingTemplate = await sdk.api.calendar_event_templates.createOne({
|
|
556
|
+
title: `F-0166 booking ${RAND()}`, durationInMinutes: 30,
|
|
557
|
+
})
|
|
558
|
+
templateId = bookingTemplate.id
|
|
559
|
+
|
|
560
|
+
const bookingEnduserEmail = `f0166-booking-${RAND()}@tellescope.example`
|
|
561
|
+
const bookingEnduser = await sdk.api.endusers.createOne({
|
|
562
|
+
email: bookingEnduserEmail, fname: 'Booking', lname: 'Confinement',
|
|
563
|
+
})
|
|
564
|
+
bookingEnduserId = bookingEnduser.id
|
|
565
|
+
|
|
566
|
+
const bookingSession = await new EnduserSession({ host, businessId })
|
|
567
|
+
.api.calendar_events.session_for_public_appointment_booking({
|
|
568
|
+
businessId,
|
|
569
|
+
calendarEventTemplateId: bookingTemplate.id,
|
|
570
|
+
email: bookingEnduserEmail,
|
|
571
|
+
fname: 'Booking',
|
|
572
|
+
lname: 'Confinement',
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
await expectAllowed(
|
|
576
|
+
S7, 'field-restricted: may record terms consent', bookingSession.authToken,
|
|
577
|
+
'patch', `/enduser/${bookingEnduser.id}`,
|
|
578
|
+
{ updates: { termsSigned: new Date().toISOString(), termsVersion: '1' } },
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
const bookingEcho = await request(
|
|
582
|
+
bookingSession.authToken, 'patch', `/enduser/${bookingEnduser.id}`, { updates: { termsVersion: '2' } },
|
|
583
|
+
)
|
|
584
|
+
const bookingLeaked = Object.keys(bookingEcho.body ?? {})
|
|
585
|
+
.filter(k => !['id', 'termsSigned', 'termsVersion'].includes(k))
|
|
586
|
+
probes.push({
|
|
587
|
+
scenario: S7,
|
|
588
|
+
label: 'field-restricted: update response echoes only the writable fields',
|
|
589
|
+
path: `/enduser/${bookingEnduser.id}`,
|
|
590
|
+
expected: 'allowed',
|
|
591
|
+
status: bookingLeaked.length === 0 ? bookingEcho.status : 500,
|
|
592
|
+
pass: bookingEcho.status === 200 && bookingLeaked.length === 0,
|
|
593
|
+
note: bookingLeaked.length ? `LEAKED [${bookingLeaked}]` : undefined,
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
for (const [field, value] of [['email', `f0166-bt-${RAND()}@tellescope.example`], ['phone', '+15555550188']] as [string, any][]) {
|
|
597
|
+
const r = await request(bookingSession.authToken, 'patch', `/enduser/${bookingEnduser.id}`, { updates: { [field]: value } })
|
|
598
|
+
probes.push({
|
|
599
|
+
scenario: S7,
|
|
600
|
+
label: `field-restricted: cannot write enduser '${field}'`,
|
|
601
|
+
path: `/enduser/${bookingEnduser.id}`,
|
|
602
|
+
expected: 'denied',
|
|
603
|
+
status: r.status,
|
|
604
|
+
pass: r.status === 403,
|
|
605
|
+
note: 'route is granted; the field allowlist is what refuses this',
|
|
606
|
+
})
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// the booking scope must not have gained anything else along with the enduser write
|
|
610
|
+
await expectDenied(S7, 'unlisted: form responses', bookingSession.authToken, baselineEnduserToken, 'get', '/form-responses')
|
|
611
|
+
await expectDenied(S7, 'unlisted: calendar events', bookingSession.authToken, baselineEnduserToken, 'get', '/calendar-events')
|
|
612
|
+
await expectAllowed(S7, 'listed: refresh enduser session', bookingSession.authToken, 'post', '/refresh-enduser-session')
|
|
613
|
+
|
|
401
614
|
/* ============ S5: ordinary sessions with no allowedPaths claim ============ */
|
|
402
615
|
|
|
403
616
|
const S5 = 'S5 unconfined sessions'
|
|
@@ -479,6 +692,8 @@ export const allowed_paths_confinement_tests = async ({ sdk } : { sdk: Session,
|
|
|
479
692
|
} catch {}
|
|
480
693
|
}
|
|
481
694
|
if (hostUserId) { try { await sdk.api.users.deleteOne(hostUserId) } catch {} }
|
|
695
|
+
if (bookingEnduserId) { try { await sdk.api.endusers.deleteOne(bookingEnduserId) } catch {} }
|
|
696
|
+
if (templateId) { try { await sdk.api.calendar_event_templates.deleteOne(templateId) } catch {} }
|
|
482
697
|
if (productId) { try { await sdk.api.products.deleteOne(productId) } catch {} }
|
|
483
698
|
if (formId) { try { await sdk.api.forms.deleteOne(formId) } catch {} }
|
|
484
699
|
}
|